/// 设置页 widget 测试(P3 计划 Task 3)。 /// /// 覆盖:修改密码入口跳 /password-change;退出登录 → 调 api.logout /// (尽力而为)→ 清空本地会话 → 清栈回 /login。 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/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'; import 'package:photo_live/pages/home_page.dart'; import 'package:photo_live/pages/login_page.dart'; import 'package:photo_live/pages/password_change_page.dart'; import '../helpers/fakes.dart'; void main() { late FakeSessionStore store; setUp(() { store = FakeSessionStore(); store.session = Session( token: 'tok-live-1', expiresAt: DateTime.utc(2099, 1, 1), account: 'staff01', ); }); ApiClient clientOf(FakeAdapter adapter) => ApiClient( sessionStore: store, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, ); Future pumpHome( WidgetTester tester, ApiClient client, { List extraOverrides = const [], }) async { await tester.pumpWidget( ProviderScope( // Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore), // 真实平台通道在测试环境挂起,必须注入替身。 overrides: [ ...pageTestOverrides(session: store, client: client), ...extraOverrides, ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const HomePage(), routes: { '/login': (context) => const LoginPage(), '/password-change': (context) => const PasswordChangePage(), }, ), ), ); await tester.pumpAndSettle(); // 切到设置 tab(底部导航第三项)。 await tester.tap(find.byIcon(Icons.settings_outlined)); await tester.pumpAndSettle(); } testWidgets('修改密码入口 → 跳 /password-change', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpHome(tester, clientOf(adapter)); await tester.tap(find.text('Change password')); await tester.pumpAndSettle(); expect(find.byType(PasswordChangePage), findsOneWidget); }); testWidgets('退出登录:确认弹窗(本机任务保留说明)→ 确认后清会话回 /login', (tester) async { // 服务端撤销失败(500)也照样本地登出——尽力而为语义。 // 首页「相册」页签(Task 4)进入即自动拉列表,需一并兜底路由。 final adapter = FakeAdapter((o) { if (o.path == '/v1/auth/logout') return (500, errorBody('INTERNAL_ERROR')); if (o.path == '/v1/albums') return (500, errorBody('INTERNAL_ERROR')); throw StateError('unexpected ${o.path}'); }); await pumpHome(tester, clientOf(adapter)); // 点列表项 → 先弹确认(§4.5:明确本机任务保留,不承诺后台继续)。 await tester.tap(find.text('Sign out')); await tester.pumpAndSettle(); expect(find.text('Sign out?'), findsOneWidget); expect( find.textContaining('preserved on this device'), findsOneWidget, reason: '确认文案明确本地任务保留、重新登录后继续', ); // 相册页签进入即拉列表(见上注释),此处只约束未发生 logout。 expect( adapter.requests.where((r) => r.path == '/v1/auth/logout'), isEmpty, reason: '未确认不调 logout', ); // 确认(弹窗内 FilledButton 与列表项同名,按按钮类型定位)。 await tester.tap(find.widgetWithText(FilledButton, 'Sign out')); await tester.pumpAndSettle(); final logout = adapter.requests.singleWhere( (r) => r.path == '/v1/auth/logout', ); expect(logout.method, 'POST'); expect(store.session, isNull, reason: '本地会话必须清除'); expect(find.byType(LoginPage), findsOneWidget); expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈'); }); testWidgets('设置分组:账号/语言/安全/上传帮助/关于(UI-01 §4.5)', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); // 分组列表长于默认 800×600 视口(「关于」在折叠线下不可建树)。 tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await pumpHome(tester, clientOf(adapter)); // 五个分组标题(SectionHeader 文案)。 for (final section in [ 'Account', 'Language', 'Security', 'Upload help', 'About', ]) { expect(find.text(section), findsOneWidget, reason: '分组「$section」'); } // 账号分组:当前账号 + 说明副标题。 expect(find.text('staff01'), findsOneWidget); expect(find.text('Currently signed-in account'), findsOneWidget); // 上传帮助:展开后说明系统限制 / 强退 / 网络等待三分态(不承诺 // 「后台上传已开启」)。 await tester.tap(find.text('How background uploads work')); await tester.pumpAndSettle(); expect(find.textContaining('battery limits'), findsOneWidget); expect( find.textContaining('start automatically once a connection is available'), findsOneWidget, ); // 关于分组:版本 tile。 expect(find.text('Version'), findsOneWidget); expect(find.text(kAppVersion), findsOneWidget); }); 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, reason: '语言分组标题'); expect(find.text('Follow system'), findsOneWidget, reason: '默认档位行标题'); // 行标题即当前档位,点它打开弹层。 await tester.tap(find.text('Follow system')); await tester.pumpAndSettle(); // 弹层三选项(语言名以自身语言书写,跨 locale 不变)。 // 「Follow system」出现两次:主档行标题 + 弹层选项。 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'); }); }