fix(ui): harden settings focus and semantics

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent f8bfecf57d
commit b41fb4fe75
159 changed files with 12054 additions and 2050 deletions
+329
View File
@@ -0,0 +1,329 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
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<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('${temporaryDirectory.path}/cache')..createSync();
final marker = File('${cache.path}/marker')..writeAsStringSync('keep');
final output = File('${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) => entity.path).toList(), [marker.path]);
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('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync(duplicateAllKeys);
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync(fractionalText);
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
final marker = File('${cache.path}/marker')..writeAsStringSync('keep');
final output = File('${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) => entity.path).toList(), [marker.path]);
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));
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../../scripts/generate_hid_key_labels.dart';
void main() {
late String validSource;
setUpAll(() async {
validSource = await File(defaultHidKeyLabelsInput).readAsString();
});
test('parse and render are deterministic and reproduce the checked-in artifact', () async {
final first = renderHidKeyLabels(parseHidKeyLabelsCatalog(validSource));
final second = renderHidKeyLabels(parseHidKeyLabelsCatalog(validSource));
expect(second, first);
expect(first, await File(defaultHidKeyLabelsOutput).readAsString());
});
test('rejects invalid schema, empty groups, group names, keys, labels, and IDs', () {
_expectInvalid(validSource, (catalog) => catalog['schemaVersion'] = 2);
_expectInvalid(validSource, (catalog) => catalog['groups'] = <Object?>[]);
_expectInvalid(validSource, (catalog) => _group(catalog, 0)['name'] = ' ');
_expectInvalid(validSource, (catalog) => _group(catalog, 0)['keys'] = <Object?>[]);
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['label'] = '');
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['id'] = '0000001');
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['id'] = '0000001A');
});
test('rejects globally duplicate or noncanonical numeric IDs', () {
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 1)['id'] = _key(catalog, 0, 0)['id']);
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 1)['id'] = '0000000f');
_expectInvalid(validSource, (catalog) => _key(catalog, 1, 0)['id'] = '0000000f');
});
test('escapes controls in labels that require double-quoted Dart literals', () {
const catalog = HidKeyCatalog(
groups: [
HidKeyGroup(
name: 'Test',
keys: [HidKeyLabel(id: '00000001', label: "Player's\nKey")],
),
],
);
expect(renderHidKeyLabels(catalog), contains("\"Player's\\nKey\""));
});
test('invalid input fails before the existing output is replaced', () async {
final directory = await Directory.systemTemp.createTemp('plezy_hid_generator_test.');
addTearDown(() => directory.delete(recursive: true));
final input = File('${directory.path}/invalid.json');
final output = File('${directory.path}/output.dart');
await input.writeAsString('{"schemaVersion":1,"groups":[]}');
await output.writeAsString('sentinel');
await expectLater(generateHidKeyLabels(input.path, output.path), throwsA(isA<FormatException>()));
expect(await output.readAsString(), 'sentinel');
});
test('atomic writer replaces the complete destination', () async {
final directory = await Directory.systemTemp.createTemp('plezy_hid_atomic_test.');
addTearDown(() => directory.delete(recursive: true));
final output = File('${directory.path}/output.dart');
await output.writeAsString('old');
await writeFileAtomically(output.path, 'new bytes\n');
expect(await output.readAsString(), 'new bytes\n');
expect(directory.listSync().where((entry) => entry.path.contains('.tmp.')), isEmpty);
});
}
void _expectInvalid(String source, void Function(Map<String, Object?> catalog) mutate) {
final catalog = (jsonDecode(source) as Map).cast<String, Object?>();
mutate(catalog);
expect(() => parseHidKeyLabelsCatalog(jsonEncode(catalog)), throwsA(isA<FormatException>()));
}
Map<String, Object?> _group(Map<String, Object?> catalog, int index) {
final groups = catalog['groups']! as List<Object?>;
return (groups[index]! as Map).cast<String, Object?>();
}
Map<String, Object?> _key(Map<String, Object?> catalog, int groupIndex, int keyIndex) {
final keys = _group(catalog, groupIndex)['keys']! as List<Object?>;
return (keys[keyIndex]! as Map).cast<String, Object?>();
}
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../../scripts/generate_iso_639_data.dart';
void main() {
late String validSource;
setUpAll(() async {
validSource = await File(defaultIso639Input).readAsString();
});
test('parse and render are deterministic and reproduce the checked-in artifact', () async {
final first = renderIso639Data(parseIso639Catalog(validSource));
final second = renderIso639Data(parseIso639Catalog(validSource));
expect(second, first);
expect(first, await File(defaultIso639Output).readAsString());
});
test('rejects invalid schema, keys, primary consistency, names, and field shapes', () {
_expectInvalid(validSource, (catalog) => catalog['schemaVersion'] = 2);
_expectInvalid(validSource, (catalog) => catalog['languages'] = <String, Object?>{});
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['primary'] = 'ab');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['terminology'] = 'AAR');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'bo')['bibliographic'] = 'TIB');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['name'] = ' ');
_expectInvalid(validSource, (catalog) {
final languages = _languages(catalog);
languages['a'] = languages.remove('aa');
});
});
test('rejects duplicate aliases and noncanonical primary ordering', () {
_expectInvalid(
validSource,
(catalog) => _entry(catalog, 'ab')['terminology'] = _entry(catalog, 'aa')['terminology'],
);
_expectInvalid(
validSource,
(catalog) => _entry(catalog, 'bo')['bibliographic'] = _entry(catalog, 'aa')['terminology'],
);
_expectInvalid(validSource, (catalog) {
final languages = _languages(catalog);
final first = languages.remove('aa');
languages['aa'] = first;
});
});
test('escapes controls in names that require double-quoted Dart literals', () {
const catalog = Iso639Catalog(
entries: [Iso639CatalogEntry(primary: 'aa', terminology: 'aaa', bibliographic: null, name: "People's\nLanguage")],
);
expect(renderIso639Data(catalog), contains("\"People's\\nLanguage\""));
});
test('invalid input fails before the existing output is replaced', () async {
final directory = await Directory.systemTemp.createTemp('plezy_iso_generator_test.');
addTearDown(() => directory.delete(recursive: true));
final input = File('${directory.path}/invalid.json');
final output = File('${directory.path}/output.dart');
await input.writeAsString('{"schemaVersion":1,"languages":{}}');
await output.writeAsString('sentinel');
await expectLater(generateIso639Data(input.path, output.path), throwsA(isA<FormatException>()));
expect(await output.readAsString(), 'sentinel');
});
test('atomic writer replaces the complete destination', () async {
final directory = await Directory.systemTemp.createTemp('plezy_iso_atomic_test.');
addTearDown(() => directory.delete(recursive: true));
final output = File('${directory.path}/output.dart');
await output.writeAsString('old');
await writeFileAtomically(output.path, 'new bytes\n');
expect(await output.readAsString(), 'new bytes\n');
expect(directory.listSync().where((entry) => entry.path.contains('.tmp.')), isEmpty);
});
}
void _expectInvalid(String source, void Function(Map<String, Object?> catalog) mutate) {
final catalog = (jsonDecode(source) as Map).cast<String, Object?>();
mutate(catalog);
expect(() => parseIso639Catalog(jsonEncode(catalog)), throwsA(isA<FormatException>()));
}
Map<String, Object?> _languages(Map<String, Object?> catalog) {
return (catalog['languages']! as Map).cast<String, Object?>();
}
Map<String, Object?> _entry(Map<String, Object?> catalog, String code) {
return (_languages(catalog)[code]! as Map).cast<String, Object?>();
}