8f3966d7b5
Co-Authored-By: Claude Code <noreply@anthropic.com>
122 lines
4.3 KiB
Dart
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');
|
|
});
|
|
}
|