feat(mobile): in-app locale switching
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/// 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user