/// P3 Task 11 集成测试 · 主流程(MuMu + 真实 cn-api/MinIO/worker/sync/ov)。 /// /// 覆盖(对照计划 Task 11 第一条): /// 登录 → 建相册(zh+en,Material 日期+时间选择器)→ 加分类 → 邀成员 → /// 传 3 图 → 队列全 completed → 服务端历史可见(sourceDeviceId=本设备)→ /// viewer 腿(ov 公开相册 API 见 3 张,替代网页断言——登记偏差)→ /// 隐藏一张 → App 管理列表状态同步 → 断网(DIO 故障注入)waiting_network → /// 恢复重试 → 修改密码(会话保留)→ 退出重登录 → App 内语言切换冒烟。 /// /// 运行方式(勿直接 flutter test——账号/图片由脚本预置): /// `bash scripts/run-mobile-it.sh` library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:photo_live/app/locale_controller.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/features/uploads/task_state.dart'; import 'helpers/app_boot.dart'; import 'helpers/it_config.dart'; import 'helpers/ui_steps.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final harness = ItAppHarness.create(); // 单一大步骤串行全流程:会话/队列状态跨腿复用,顺序即语义。 testWidgets( 'P3 全流程:真实后端 登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录', (tester) async { final account = ItConfig.mainAccount; var images = const []; // ── 0. 启动与资源定位 ───────────────────────────────────────────── await tester.pumpWidget(harness.appWidget()); images = await resolveSmallImages(); debugPrint('[IT] 使用测试图片:$images'); // ── 1. 登录(本次运行专属账号)──────────────────────────────────── await loginViaUi(tester, account, ItConfig.mainPassword); debugPrint('[IT] leg1 登录 OK:$account'); // ── 2. 建相册(zh+en 双语标题 + 必填开始时间)────────────────────── await createAlbumViaUi( tester, ItConfig.albumTitleZh, ItConfig.albumTitleEn, ); debugPrint('[IT] leg2 建相册 OK:${ItConfig.albumTitleZh}'); // 相册 id / 公开令牌(后续 API 断言与 ov 检查用)。 final api = harness.container.read(apiClientProvider); final albumsPage = await api.listAlbums(limit: 50); final album = albumsPage.items.firstWhere( (a) => a.translations.any( (t) => t.locale == 'zh-CN' && t.title == ItConfig.albumTitleZh, ), ); final albumId = album.id; final publicToken = album.publicToken; debugPrint('[IT] albumId=$albumId publicToken=$publicToken'); // ── 3. 详情 + 分类 ──────────────────────────────────────────────── await openAlbumDetail(tester, ItConfig.albumTitleZh); await addCategoryViaUi(tester, '全程连拍', 'Burst'); debugPrint('[IT] leg3 分类 OK'); // ── 4. 邀请成员(staff 账号由脚本预建)──────────────────────────── await inviteMemberViaUi(tester, ItConfig.staffAccount); debugPrint('[IT] leg4 邀请 OK:${ItConfig.staffAccount}'); // ── 5. 上传 3 图(选图替身 → 真实准备/队列/引擎/原生桥接/后端)──── await enqueueViaGallery(tester, harness, account, images, 3); await waitAllCompleted(tester, harness, account, 3); debugPrint('[IT] leg5 队列 3 张全部 completed'); // 队列页 UI:3 个「已完成」徽章(IndexedStack 常驻,切页签后断言)。 // 详情页是压在首页骨架之上的路由(无底部导航),先退回首页再切页签。 await goBack(tester); await tester.pump(); await tester.tap(find.byIcon(Icons.cloud_upload_outlined)); await tester.pump(); expect(find.text('已完成').evaluate().length, greaterThanOrEqualTo(3)); // ── 6. 服务端上传历史(跨设备记录 + 本设备标记,A19)─────────────── await tester.tap(find.byTooltip('查看服务端上传历史')); await pumpUntil( tester, find.textContaining('本设备'), timeout: const Duration(seconds: 60), because: '服务端历史出现本设备记录', ); final ownDeviceRows = find.textContaining('本设备').evaluate().length; expect(ownDeviceRows, greaterThanOrEqualTo(3)); debugPrint('[IT] leg6 服务端历史 OK:$ownDeviceRows 条本设备记录'); await goBack(tester); await tester.pump(); // ── 7. viewer 腿:ov 公开相册 API 可见 3 张(真实同步管线)──────── final ovCount = await waitOvPublicPhotoCount(publicToken, 3); expect(ovCount, greaterThanOrEqualTo(3)); debugPrint('[IT] leg7 ov 公开相册 OK:$ovCount 张'); // ── 8. 隐藏一张 → App 管理列表同步「已隐藏」─────────────────────── // (App 照片页为 Task 4 占位无操作按钮——经同一 ApiClient 调真实 // 契约端点隐藏,再回 UI 断言状态同步;登记偏差。) await hideFirstPhotoViaApi(harness, albumId); await tester.tap(find.byIcon(Icons.photo_library_outlined)); await tester.pump(); await openAlbumDetail(tester, ItConfig.albumTitleZh); await openPhotosPageAndWait(tester, 2); expect(find.text('已隐藏').evaluate().length, greaterThanOrEqualTo(1)); expect(find.text('已发布').evaluate().length, greaterThanOrEqualTo(2)); debugPrint('[IT] leg8 隐藏同步 OK'); await goBack(tester); // 照片管理 → 详情 await tester.pump(); // ── 9. 断网场景(DIO 故障注入):waiting_network → 恢复重试 ──────── harness.gate.offline = true; debugPrint('[IT] leg9 故障闸门关闭(模拟断网)'); await enqueueViaGallery(tester, harness, account, [images.first], 1); await pollUntil>( tester, () => queueSnapshot(harness, account), (counts) => (counts[UploadTaskState.waitingNetwork] ?? 0) >= 1, timeout: const Duration(seconds: 90), because: '断网下任务进入 waitingNetwork', ); // 队列页 UI:「等待网络」徽章 + 行内重试按钮(先退回首页才有底部导航)。 await goBack(tester); await tester.pump(); await tester.tap(find.byIcon(Icons.cloud_upload_outlined)); await tester.pump(); expect(find.text('等待网络').evaluate().length, greaterThanOrEqualTo(1)); debugPrint('[IT] leg9 waitingNetwork 断言 OK(UI+store)'); harness.gate.offline = false; final retryButton = find.byTooltip('重试'); await tester.ensureVisible(retryButton); await tester.tap(retryButton); await waitAllCompleted(tester, harness, account, 4); debugPrint('[IT] leg9 网络恢复重试 OK:4 张全部 completed'); // ── 10. 修改密码(服务端保留当前会话)→ 会话仍有效 ──────────────── await tester.tap(find.byIcon(Icons.settings_outlined)); await tester.pump(); await tester.tap(find.text('修改密码')); // 设置页入口(此时唯一) await pumpUntil( tester, find.widgetWithText(TextFormField, '当前密码'), because: '修改密码表单', ); await tester.enterText( find.widgetWithText(TextFormField, '当前密码'), ItConfig.mainPassword, ); await tester.enterText( find.widgetWithText(TextFormField, '新密码'), ItConfig.rotatedPassword, ); await tester.enterText( find.widgetWithText(TextFormField, '确认新密码'), ItConfig.rotatedPassword, ); // 软键盘遮挡 + 「修改密码」与 AppBar 标题同文案——收键盘(动画需时, // hitTestable 可能瞬时为空致 .last 抛错),滚动定位 + 重试点击。 final submitChange = find .descendant( of: find.byType(FilledButton), matching: find.text('修改密码'), ); await dismissKeyboard(tester); await tester.pump(const Duration(seconds: 1)); // ensureVisible 曾因 finder 瞬时为空(提交中 spinner 替换文本)抛 // 「No element」——表单页一屏可容,直接重试点击直到成功提示出现。 for (var attempt = 0; attempt < 3; attempt++) { if (find.text('密码已修改').evaluate().isNotEmpty) break; final target = submitChange.evaluate().isNotEmpty ? submitChange.last : null; if (target != null) { await tester.tap(target, warnIfMissed: false); } await tester.pump(const Duration(seconds: 2)); } await pumpUntil( tester, find.text('密码已修改'), because: '修改密码成功提示', ); // 改密页成功后自行 pop(Task 3 实现)——此刻已在设置页,无需再返回。 await tester.pump(); // 会话保留断言:当前令牌仍可调需认证端点(refresh 204 语义)。 await harness.container.read(apiClientProvider).refresh(); debugPrint('[IT] leg10 修改密码 OK(会话保留,refresh 成功)'); // ── 11. 退出登录 → 新密码重登录 ─────────────────────────────────── await pumpUntil( tester, find.text('退出登录'), because: '设置页退出入口', ); await tester.tap(find.text('退出登录')); await pumpUntil( tester, find.widgetWithText(TextFormField, '账号'), because: '回到登录页', ); await loginViaUi(tester, account, ItConfig.rotatedPassword); debugPrint('[IT] leg11 新密码重登录 OK'); // ── 12. App 内语言切换冒烟(zh→en→zh,P3 Task 10 真机级)────────── // 弹层选项与顶栏切换器同文案且命中不稳——改为驱动真实 LocaleSettings // provider(弹层交互已由 Task 10 widget 测试覆盖;此处验证的是 // provider→MaterialApp 全局重构链路,登记偏差)。 await harness.container .read(localeSettingsProvider.notifier) .setChoice(AppLocaleChoice.en); await pumpUntil( tester, find.text('Albums'), because: '界面切换为英文', ); debugPrint('[IT] leg12 语言切换 en OK'); await harness.container .read(localeSettingsProvider.notifier) .setChoice(AppLocaleChoice.zh); await pumpUntil( tester, find.text('相册'), because: '界面切回中文', ); debugPrint('[IT] leg12 语言切回 zh OK —— 全流程完成'); }, timeout: const Timeout(Duration(minutes: 15)), ); tearDownAll(harness.container.dispose); }