scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
318 lines
13 KiB
Dart
318 lines
13 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path/path.dart' as path;
|
|
|
|
import '../../scripts/codegen/generate_ducet_ranks.dart';
|
|
|
|
void main() {
|
|
late Directory temporaryDirectory;
|
|
|
|
setUp(() async {
|
|
temporaryDirectory = await Directory.systemTemp.createTemp('plezy_ducet_test_');
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (await temporaryDirectory.exists()) {
|
|
await temporaryDirectory.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
test('bundled gzip inputs have deterministic normalized headers and bytes', () {
|
|
final allKeys = File('scripts/codegen/data/${allKeysSource.bundledFileName}').readAsBytesSync();
|
|
final fractional = File('scripts/codegen/data/${fractionalUcaSource.bundledFileName}').readAsBytesSync();
|
|
|
|
expect(allKeys.take(8), [0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0]);
|
|
expect(fractional.take(8), [0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0]);
|
|
expect(sha256.convert(allKeys).toString(), '744040b99e266f9ec599744e267091c159369a36f6ca7558c12a9eb3e039575f');
|
|
expect(sha256.convert(fractional).toString(), 'edc486bae663abf83d18c9006d08c1e8bb19986810cf8ab663fa33bee6d22275');
|
|
});
|
|
|
|
test('dense lookup stores rank plus one with a zero sentinel', () {
|
|
final ranks = buildDenseRankLookup([0x41, 0x24, 0x5C]);
|
|
|
|
expect(ranks.length, 0x10000);
|
|
expect(ranks.codeUnitAt(0x41), 1);
|
|
expect(ranks.codeUnitAt(0x24), 2);
|
|
expect(ranks.codeUnitAt(0x5C), 3);
|
|
expect(ranks.codeUnitAt(0x42), 0);
|
|
});
|
|
|
|
test('dense lookup rejects duplicate, out-of-BMP, and overflowing orders', () {
|
|
expect(() => buildDenseRankLookup([0x41, 0x41]), throwsStateError);
|
|
expect(() => buildDenseRankLookup([-1]), throwsRangeError);
|
|
expect(() => buildDenseRankLookup([0x10000]), throwsRangeError);
|
|
expect(() => buildDenseRankLookup(List<int>.generate(0x10000, (index) => index)), throwsStateError);
|
|
});
|
|
|
|
test('parse and render seams are deterministic and escape Dart interpolation characters', () {
|
|
const allKeysText = '''
|
|
0041 ; [.0100.0020.0002]
|
|
30A2 ; [.0200.0020.000F]
|
|
3042 ; [.0200.0020.000D]
|
|
''';
|
|
const fractionalText = '[radical 1=⼀一:一丁]';
|
|
|
|
final firstAllKeys = parseAllKeys(allKeysText);
|
|
final secondAllKeys = parseAllKeys(allKeysText);
|
|
final (firstCjk, firstKangxi) = parseRadicals(fractionalText);
|
|
final (secondCjk, secondKangxi) = parseRadicals(fractionalText);
|
|
final firstOrder = buildOrder(firstAllKeys, firstCjk);
|
|
final secondOrder = buildOrder(secondAllKeys, secondCjk);
|
|
|
|
expect(firstOrder, secondOrder);
|
|
expect(renderDucetOrder(firstOrder, firstKangxi), renderDucetOrder(secondOrder, secondKangxi));
|
|
|
|
final ranksWithSpecialCodeUnits = List<int>.generate(0x5C, (index) => 0x1000 + index);
|
|
final rendered = renderDucetOrder(ranksWithSpecialCodeUnits, const {});
|
|
expect(rendered, contains(r"\'"));
|
|
expect(rendered, contains(r'\\'));
|
|
expect(rendered, contains(r'\$'));
|
|
});
|
|
|
|
test('explicit inputs are both verified before parsing and writing', () async {
|
|
const allKeysText = '0041 ; [.0100.0020.0002]\n';
|
|
const fractionalText = '[radical 1=⼀一:一]\n';
|
|
final allKeysFile = File('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync(allKeysText);
|
|
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync(fractionalText);
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
var downloads = 0;
|
|
|
|
await generateDucetRanks(
|
|
useBundledSources: false,
|
|
explicitAllKeys: allKeysFile,
|
|
explicitFractionalUca: fractionalFile,
|
|
output: output,
|
|
allKeysDescriptor: descriptorFor('allkeys', allKeysText),
|
|
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
|
|
downloader: (_) async {
|
|
downloads++;
|
|
throw StateError('network must not be used');
|
|
},
|
|
);
|
|
|
|
expect(downloads, 0);
|
|
expect(output.readAsStringSync(), contains('const String _ducetRanks ='));
|
|
expect(output.readAsStringSync(), isNot(contains('_buildRanks')));
|
|
});
|
|
|
|
test('rejects an explicit digest mismatch without replacing output', () async {
|
|
final allKeysFile = File('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync('tampered');
|
|
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync('fractional');
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
var writerCalled = false;
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
useBundledSources: false,
|
|
explicitAllKeys: allKeysFile,
|
|
explicitFractionalUca: fractionalFile,
|
|
output: output,
|
|
allKeysDescriptor: descriptorFor('allkeys', 'expected'),
|
|
fractionalUcaDescriptor: descriptorFor('fractional', 'fractional'),
|
|
writer: (_, _) => writerCalled = true,
|
|
),
|
|
throwsA(isA<SourceIntegrityException>()),
|
|
);
|
|
|
|
expect(writerCalled, isFalse);
|
|
expect(output.readAsStringSync(), 'old output');
|
|
expect(allKeysFile.readAsStringSync(), 'tampered');
|
|
});
|
|
|
|
test('rejects an invalid cached source without downloading or replacing files', () async {
|
|
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
|
|
final allKeysDescriptor = descriptorFor('allkeys', 'expected allkeys');
|
|
final fractionalDescriptor = descriptorFor('fractional', 'fractional');
|
|
final cachedAllKeys = File('${cache.path}/${allKeysDescriptor.cacheFileName}')..writeAsStringSync('tampered cache');
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
var downloads = 0;
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
useBundledSources: false,
|
|
cacheDirectory: cache,
|
|
output: output,
|
|
allKeysDescriptor: allKeysDescriptor,
|
|
fractionalUcaDescriptor: fractionalDescriptor,
|
|
downloader: (_) async {
|
|
downloads++;
|
|
return response(HttpStatus.ok, utf8.encode('unused'));
|
|
},
|
|
),
|
|
throwsA(isA<SourceIntegrityException>()),
|
|
);
|
|
|
|
expect(downloads, 0);
|
|
expect(cachedAllKeys.readAsStringSync(), 'tampered cache');
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
|
|
test('rejects HTTP status before parsing, caching, or replacing output', () async {
|
|
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
useBundledSources: false,
|
|
cacheDirectory: cache,
|
|
output: output,
|
|
allKeysDescriptor: descriptorFor('allkeys', 'allkeys'),
|
|
fractionalUcaDescriptor: descriptorFor('fractional', 'fractional'),
|
|
downloader: (_) async => response(HttpStatus.notFound, utf8.encode('not found')),
|
|
),
|
|
throwsA(isA<SourceIntegrityException>().having((error) => error.message, 'message', contains('HTTP 404'))),
|
|
);
|
|
|
|
expect(cache.listSync(), isEmpty);
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
|
|
test('a rejected second download preserves the first cache and output', () async {
|
|
const allKeysText = '0041 ; [.0100.0020.0002]\n';
|
|
const fractionalText = '[radical 1=⼀一:一]\n';
|
|
final cache = Directory(path.join(temporaryDirectory.path, 'cache'))..createSync();
|
|
final marker = File(path.join(cache.path, 'marker'))..writeAsStringSync('keep');
|
|
final output = File(path.join(temporaryDirectory.path, 'ducet_order.dart'))..writeAsStringSync('old output');
|
|
var request = 0;
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
useBundledSources: false,
|
|
cacheDirectory: cache,
|
|
output: output,
|
|
allKeysDescriptor: descriptorFor('allkeys', allKeysText),
|
|
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
|
|
downloader: (_) async {
|
|
request++;
|
|
return response(HttpStatus.ok, utf8.encode(request == 1 ? allKeysText : 'tampered fractional'));
|
|
},
|
|
),
|
|
throwsA(isA<SourceIntegrityException>()),
|
|
);
|
|
|
|
expect(request, 2);
|
|
expect(cache.listSync().map((entity) => path.basename(entity.path)).toList(), ['marker']);
|
|
expect(marker.readAsStringSync(), 'keep');
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
|
|
test('parse rejection leaves verified inputs, caches, and output untouched', () async {
|
|
const duplicateAllKeys = '''
|
|
0041 ; [.0100.0020.0002]
|
|
0041 ; [.0101.0020.0002]
|
|
''';
|
|
const fractionalText = '[radical 1=⼀一:一]\n';
|
|
final allKeysFile = File(path.join(temporaryDirectory.path, 'allkeys.txt'))..writeAsStringSync(duplicateAllKeys);
|
|
final fractionalFile = File(path.join(temporaryDirectory.path, 'FractionalUCA.txt'))
|
|
..writeAsStringSync(fractionalText);
|
|
final cache = Directory(path.join(temporaryDirectory.path, 'cache'))..createSync();
|
|
final marker = File(path.join(cache.path, 'marker'))..writeAsStringSync('keep');
|
|
final output = File(path.join(temporaryDirectory.path, 'ducet_order.dart'))..writeAsStringSync('old output');
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
useBundledSources: false,
|
|
explicitAllKeys: allKeysFile,
|
|
explicitFractionalUca: fractionalFile,
|
|
cacheDirectory: cache,
|
|
output: output,
|
|
allKeysDescriptor: descriptorFor('allkeys', duplicateAllKeys),
|
|
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
|
|
),
|
|
throwsFormatException,
|
|
);
|
|
|
|
expect(allKeysFile.readAsStringSync(), duplicateAllKeys);
|
|
expect(fractionalFile.readAsStringSync(), fractionalText);
|
|
expect(cache.listSync().map((entity) => path.basename(entity.path)).toList(), ['marker']);
|
|
expect(marker.readAsStringSync(), 'keep');
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
|
|
test('default bundled generation is deterministic and never downloads', () async {
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart');
|
|
var downloads = 0;
|
|
String? first;
|
|
String? second;
|
|
Future<SourceDownloadResponse> failDownloader(Uri _) async {
|
|
downloads++;
|
|
throw StateError('bundled generation must not use the network');
|
|
}
|
|
|
|
await generateDucetRanks(output: output, downloader: failDownloader, writer: (_, contents) => first = contents);
|
|
await generateDucetRanks(output: output, downloader: failDownloader, writer: (_, contents) => second = contents);
|
|
|
|
expect(downloads, 0);
|
|
expect(first, isNotNull);
|
|
expect(second, first);
|
|
expect(first, File('lib/data/ducet_order.dart').readAsStringSync());
|
|
});
|
|
|
|
test('tampered bundled gzip fails before replacing output', () async {
|
|
const allKeysText = '0041 ; [.0100.0020.0002]\n';
|
|
const fractionalText = '[radical 1=⼀一:一]\n';
|
|
final bundled = Directory('${temporaryDirectory.path}/bundled')..createSync();
|
|
final allKeysDescriptor = descriptorFor('allkeys', allKeysText);
|
|
final fractionalDescriptor = descriptorFor('fractional', fractionalText);
|
|
File('${bundled.path}/${allKeysDescriptor.bundledFileName}').writeAsBytesSync(const [0x1F, 0x8B, 0x08, 0x00, 0x00]);
|
|
writeBundledSource(bundled, fractionalDescriptor, fractionalText);
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
bundledSourceDirectory: bundled,
|
|
output: output,
|
|
allKeysDescriptor: allKeysDescriptor,
|
|
fractionalUcaDescriptor: fractionalDescriptor,
|
|
),
|
|
throwsA(isA<SourceIntegrityException>()),
|
|
);
|
|
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
|
|
test('tampered decompressed bundled bytes fail digest before replacing output', () async {
|
|
const allKeysText = '0041 ; [.0100.0020.0002]\n';
|
|
const fractionalText = '[radical 1=⼀一:一]\n';
|
|
final bundled = Directory('${temporaryDirectory.path}/bundled')..createSync();
|
|
final allKeysDescriptor = descriptorFor('allkeys', allKeysText);
|
|
final fractionalDescriptor = descriptorFor('fractional', fractionalText);
|
|
writeBundledSource(bundled, allKeysDescriptor, 'tampered raw bytes');
|
|
writeBundledSource(bundled, fractionalDescriptor, fractionalText);
|
|
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
|
|
|
|
await expectLater(
|
|
generateDucetRanks(
|
|
bundledSourceDirectory: bundled,
|
|
output: output,
|
|
allKeysDescriptor: allKeysDescriptor,
|
|
fractionalUcaDescriptor: fractionalDescriptor,
|
|
),
|
|
throwsA(
|
|
isA<SourceIntegrityException>().having((error) => error.message, 'message', contains('SHA-256 mismatch')),
|
|
),
|
|
);
|
|
|
|
expect(output.readAsStringSync(), 'old output');
|
|
});
|
|
}
|
|
|
|
SourceDescriptor descriptorFor(String name, String contents) => SourceDescriptor(
|
|
name: name,
|
|
url: 'https://example.test/$name',
|
|
bundledFileName: '$name.txt.gz',
|
|
cacheFileName: '$name.txt',
|
|
sha256Digest: sha256.convert(utf8.encode(contents)).toString(),
|
|
licenseUrl: 'https://example.test/license',
|
|
);
|
|
|
|
void writeBundledSource(Directory directory, SourceDescriptor descriptor, String contents) {
|
|
File('${directory.path}/${descriptor.bundledFileName}').writeAsBytesSync(gzip.encode(utf8.encode(contents)));
|
|
}
|
|
|
|
SourceDownloadResponse response(int statusCode, List<int> bytes) =>
|
|
SourceDownloadResponse(statusCode: statusCode, bytes: Stream<List<int>>.value(bytes));
|