feat(mobile): in-app locale switching

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 05:00:11 +08:00
parent 955f9b13c1
commit 8f3966d7b5
8 changed files with 548 additions and 17 deletions
+172
View File
@@ -0,0 +1,172 @@
/// App 内语言切换测试(P3 计划 Task 10)。
///
/// 覆盖:
/// - 启动:持久档位注入 Notifier → 首帧即对应语言(无闪变);
/// - 切换:setChoice → MaterialApp 即刻重建(登录页文案随切)+ 写持久层;
/// - 解析矩阵(system 档):系统 zh → zh、系统 en → en、系统其他 →
/// 回退模板语言 zh(l10n.yaml template=app_zh.arb);
/// - 显式选择优先于系统 locale;
/// - 持久值解析容错(null/非法值/存储异常 → system)。
library;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/app/app.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import '../helpers/fakes.dart';
/// 写入即抛的存储替身(持久化失败容错断言用)。
class ThrowingWriteLocaleStore extends FakeLocaleStore {
@override
Future<void> write(String value) async => throw StateError('disk full');
}
void main() {
group('持久值解析(纯逻辑)', () {
test('fromPersisted:合法值/空值/非法值', () {
expect(AppLocaleChoice.fromPersisted('system'), AppLocaleChoice.system);
expect(AppLocaleChoice.fromPersisted('zh'), AppLocaleChoice.zh);
expect(AppLocaleChoice.fromPersisted('en'), AppLocaleChoice.en);
expect(AppLocaleChoice.fromPersisted(null), AppLocaleChoice.system);
expect(AppLocaleChoice.fromPersisted('fr'), AppLocaleChoice.system,
reason: '未来档位/手改损坏的旧值一律回退 system');
});
test('readPersisted:读取异常 → system(启动不因存储故障卡死)', () async {
final store = ThrowingWriteLocaleStore();
final choice = await LocaleSettings.readPersisted(store);
expect(choice, AppLocaleChoice.system);
});
test('setChoice 写失败不回滚本次会话状态', () async {
final container = ProviderContainer(
overrides: [
localeSettingsProvider.overrideWith(
() => LocaleSettings(store: ThrowingWriteLocaleStore()),
),
],
);
addTearDown(container.dispose);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.en);
expect(container.read(localeSettingsProvider), AppLocaleChoice.en);
});
});
group('PhotoLiveApp 语言接线(widget)', () {
late FakeSessionStore session;
late ApiClient client;
setUp(() {
// 无会话:App 停在登录页(文案断言载体);客户端拒绝一切请求
//(语言测试不触网,仅为满足 Provider 依赖图)。
session = FakeSessionStore();
client = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter =
FakeAdapter((o) => throw StateError('不应发起请求: ${o.path}')),
);
});
Future<ProviderContainer> pumpApp(
WidgetTester tester, {
AppLocaleChoice initial = AppLocaleChoice.system,
FakeLocaleStore? localeStore,
}) async {
final store = localeStore ?? FakeLocaleStore();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(session: session, client: client),
localeSettingsProvider.overrideWith(
() => LocaleSettings(initial: initial, store: store),
),
],
child: const PhotoLiveApp(),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(PhotoLiveApp)),
);
}
testWidgets('持久档位 zh → 首帧中文文案', (tester) async {
final store = FakeLocaleStore()..value = 'zh';
await pumpApp(tester,
initial: AppLocaleChoice.zh, localeStore: store);
expect(find.text('登录'), findsOneWidget);
expect(find.text('Sign in'), findsNothing);
});
testWidgets('切换 en → 登录页即刻英文 + 持久化 en', (tester) async {
final store = FakeLocaleStore();
final container = await pumpApp(tester,
initial: AppLocaleChoice.zh, localeStore: store);
expect(find.text('登录'), findsOneWidget);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.en);
await tester.pumpAndSettle();
expect(find.text('Sign in'), findsOneWidget,
reason: 'MaterialApp 已随 provider 重建');
expect(find.text('登录'), findsNothing);
expect(store.writes, ['en'], reason: '写入持久层');
});
testWidgets('切换回 system → 交还系统 locale', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('en')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
final container = await pumpApp(tester, initial: AppLocaleChoice.zh);
expect(find.text('登录'), findsOneWidget);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.system);
await tester.pumpAndSettle();
expect(find.text('Sign in'), findsOneWidget, reason: '系统 en 接管');
});
testWidgets('解析矩阵:system + 系统 zh → zh', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('zh')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('登录'), findsOneWidget);
});
testWidgets('解析矩阵:system + 系统 en → en', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('en')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('Sign in'), findsOneWidget);
});
testWidgets('解析矩阵:system + 系统其他(fr)→ 回退模板语言 zh',
(tester) async {
// Localizations 解析用 platformDispatcher.locales(首选语言列表),
// 单值 localeTestValue 不影响它——须整体注入列表。
tester.platformDispatcher.localesTestValue = const [Locale('fr')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('登录'), findsOneWidget,
reason: '不依赖 supportedLocales 排序(默认解析会回退 en)');
});
testWidgets('显式选择优先于系统 locale(选 en + 系统 zh → en)',
(tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('zh')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester, initial: AppLocaleChoice.en);
expect(find.text('Sign in'), findsOneWidget);
});
});
}
+19
View File
@@ -10,6 +10,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/providers.dart';
@@ -29,6 +30,24 @@ class FakeSessionStore implements SessionStore {
Future<void> clear() async => session = null;
}
/// 内存语言偏好存储(测试替身,P3 Task 10)。
class FakeLocaleStore implements LocalePrefsStore {
/// 当前持久值(read 返回);null = 未设置(跟随系统)。
String? value;
/// 按序记录全部写入(断言持久化调用用)。
final List<String> writes = <String>[];
@override
Future<String?> read() async => value;
@override
Future<void> write(String value) async {
writes.add(value);
this.value = value;
}
}
/// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达
/// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage
/// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需
@@ -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');
});
}
+42 -1
View File
@@ -8,6 +8,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
@@ -35,13 +36,18 @@ void main() {
..httpClientAdapter = adapter,
);
Future<void> pumpHome(WidgetTester tester, ApiClient client) async {
Future<void> pumpHome(
WidgetTester tester,
ApiClient client, {
List<dynamic> extraOverrides = const <dynamic>[],
}) async {
await tester.pumpWidget(
ProviderScope(
// Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore),
// 真实平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: client),
...extraOverrides,
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -92,4 +98,39 @@ void main() {
expect(find.byType(LoginPage), findsOneWidget);
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
});
testWidgets('语言切换:底部弹层三选一 → 更新档位 + 持久化(P3 Task 10)',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
final localeStore = FakeLocaleStore();
await pumpHome(
tester,
clientOf(adapter),
extraOverrides: [
localeSettingsProvider.overrideWith(
() => LocaleSettings(store: localeStore),
),
],
);
// 本 harness 的 MaterialApp 固定 en(live 全 App 重建在
// app/locale_test.dart 的 PhotoLiveApp 用例覆盖)。
expect(find.text('Language'), findsOneWidget);
expect(find.text('Follow system'), findsOneWidget, reason: '默认档位副标题');
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
// 弹层三选项(语言名以自身语言书写,跨 locale 不变)。
// 「Follow system」出现两次:主档 tile 副标题 + 弹层选项。
expect(find.text('Follow system'), findsNWidgets(2));
expect(find.text('中文'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
await tester.tap(find.text('中文'));
await tester.pumpAndSettle();
expect(find.text('中文'), findsOneWidget, reason: '副标题更新为当前档位');
expect(localeStore.writes, ['zh'], reason: '选择写入 app.locale');
});
}