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/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('pins immutable Unicode and CLDR source descriptors', () { expect(allKeysSource.url, 'https://www.unicode.org/Public/UCA/13.0.0/allkeys.txt'); expect(allKeysSource.sha256Digest, 'a3255d45b7af97f4dc14fb8364d7573b434425e5c58cacf00d16901ce081c78d'); expect(allKeysSource.bundledFileName, 'allkeys-13.0.0.txt.gz'); expect(allKeysSource.licenseUrl, 'https://www.unicode.org/license.txt'); expect( fractionalUcaSource.url, 'https://raw.githubusercontent.com/unicode-org/cldr/' '651afecf9ccf1541a49306993e8210fa2209aa0b/common/uca/FractionalUCA.txt', ); expect(fractionalUcaSource.sha256Digest, 'a6144d0c8c19cc899a5d2f48fbc14e3e31e819049a73aa86b67111f1f3f81637'); expect(fractionalUcaSource.bundledFileName, 'FractionalUCA-651afecf9ccf1541a49306993e8210fa2209aa0b.txt.gz'); expect(fractionalUcaSource.licenseUrl, contains('/651afecf9ccf1541a49306993e8210fa2209aa0b/LICENSE')); }); test('bundled gzip inputs have deterministic normalized headers and bytes', () { final allKeys = File('scripts/data/${allKeysSource.bundledFileName}').readAsBytesSync(); final fractional = File('scripts/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.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.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()), ); 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()), ); 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().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()), ); 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 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()), ); 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().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 bytes) => SourceDownloadResponse(statusCode: statusCode, bytes: Stream>.value(bytes));