Files
photo-live/apps/mobile/test/l10n/arb_completeness_test.dart
T
2026-09-19 05:00:11 +08:00

122 lines
4.3 KiB
Dart

/// 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<String, Object?> _loadArb(String fileName) {
final file = File('lib/l10n/$fileName');
if (!file.existsSync()) {
fail('arb 文件缺失: ${file.path}');
}
return jsonDecode(file.readAsStringSync()) as Map<String, Object?>;
}
/// `{name}` 占位符 token 提取(ICU 复数/选择子句的内层同名 token 同样
/// 命中;`{count, plural,` 等带参数形式不误报为 token)。
Set<String> _placeholdersOf(String message) =>
RegExp(r'\{([a-zA-Z][a-zA-Z0-9_]*)\}')
.allMatches(message)
.map((m) => m.group(1)!)
.toSet();
Set<String> _messageKeys(Map<String, Object?> 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 = <String>[];
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<Object?, Object?>?)
?.keys
.whereType<String>()
.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');
});
}