/// arb 完整性单测(P3 计划 Task 10:漏译检查)。 /// /// zh(模板)/en 两份 arb 直接按 JSON 解析比对: /// 1. 消息键集合相等(任一侧新增/删除键即失败——杜绝漏译); /// 2. 每条消息的占位符 token 集合相等(en 用了 {count} 而 zh 没有即失败, /// 反之亦然——杜绝运行时 Intl 占位符缺失/多参崩溃); /// 3. `@key` 元数据键必须对应真实消息键,且其 placeholders 名称与 /// 消息体中提取的 token 一致(两侧一致)。 library; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; Map _loadArb(String fileName) { final file = File('lib/l10n/$fileName'); if (!file.existsSync()) { fail('arb 文件缺失: ${file.path}'); } return jsonDecode(file.readAsStringSync()) as Map; } /// `{name}` 占位符 token 提取(ICU 复数/选择子句的内层同名 token 同样 /// 命中;`{count, plural,` 等带参数形式不误报为 token)。 Set _placeholdersOf(String message) => RegExp(r'\{([a-zA-Z][a-zA-Z0-9_]*)\}') .allMatches(message) .map((m) => m.group(1)!) .toSet(); Set _messageKeys(Map arb) => arb.keys.where((k) => !k.startsWith('@')).toSet(); void main() { final zh = _loadArb('app_zh.arb'); final en = _loadArb('app_en.arb'); final zhKeys = _messageKeys(zh); final enKeys = _messageKeys(en); test('zh/en 消息键集合相等(漏译防护)', () { final zhOnly = zhKeys.difference(enKeys).toList()..sort(); final enOnly = enKeys.difference(zhKeys).toList()..sort(); expect(zhOnly, isEmpty, reason: 'en 缺少这些键: $zhOnly'); expect(enOnly, isEmpty, reason: 'zh 缺少这些键: $enOnly'); }); test('@@locale 元数据正确', () { expect(zh['@@locale'], 'zh'); expect(en['@@locale'], 'en'); }); test('每条消息的占位符 token 两侧一致', () { final mismatched = []; for (final key in zhKeys.intersection(enKeys)) { final zhValue = zh[key]; final enValue = en[key]; if (zhValue is! String || enValue is! String) continue; final zhTokens = _placeholdersOf(zhValue); final enTokens = _placeholdersOf(enValue); final sameSet = zhTokens.length == enTokens.length && zhTokens.containsAll(enTokens); if (!sameSet) { mismatched.add( '$key: zh${zhTokens.toList()} vs en${enTokens.toList()}', ); } } expect(mismatched, isEmpty, reason: '占位符不一致:\n${mismatched.join('\n')}'); }); test('@key 元数据与消息键对应且 placeholders 名称一致', () { for (final arb in [zh, en]) { final locale = arb['@@locale'] as String?; final messageKeys = _messageKeys(arb); final metaKeys = arb.keys .where((k) => k.startsWith('@') && k != '@@locale') .toSet(); final dangling = {for (final k in metaKeys) k.substring(1)} .difference(messageKeys) .toList() ..sort(); expect( dangling, isEmpty, reason: '$locale 存在无对应消息键的元数据: $dangling', ); for (final metaKey in metaKeys) { final key = metaKey.substring(1); final meta = arb[metaKey]; if (meta is! Map) continue; final declared = (meta['placeholders'] as Map?) ?.keys .whereType() .toSet(); if (declared == null) continue; final message = arb[key]; if (message is! String) continue; expect( _placeholdersOf(message), declared, reason: '$locale `$key` 消息体 token 与 @placeholders 声明不一致', ); } } }); test('生成的 AppLocalizations 覆盖全部 arb 消息键', () { // 拦截「改了 arb 忘了重新生成本地化代码」的漂移(flutter test 本身 // 会触发生成,此处守护绕过 flutter 工具链的手工改动)。 final generated = File('lib/l10n/app_localizations.dart').readAsStringSync(); final missing = zhKeys .where((key) => !RegExp('\\b$key\\b').hasMatch(generated)) .toList() ..sort(); expect(missing, isEmpty, reason: '生成代码缺少这些键: $missing'); }); }