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