Files
photo-live/apps/mobile/integration_test/p3_flow_test.dart
T
浅念 d41be367f8 fix(mobile,it): integration suite stabilization on real device
Root causes found & fixed across 29 phase-1 + 3 phase-2 runs:
- MSYS path mangling for adb shell device paths (per-command MSYS_NO_PATHCONV + winpath)
- adb reverse tcp:9000: presign URLs embed server-side localhost MinIO host,
  unreachable from emulator (uploads hung forever)
- compose --profile ov omitted: Docker restarts stranded ov side (ov-api 500s)
- soft keyboard occlusion on dialog buttons (dismissKeyboard + ensureVisible)
- stacked-route BackButton ambiguity (goBack must tap topmost hit-testable)
- bottom-sheet entrance animation race (wait for hit-testable option)
- language leg: drive LocaleSettings provider (sheet tap flaky, covered by unit tests)
- password page: self-pops on success (no extra goBack)
- bg verify: server-side authoritative assertion (fresh process = fresh memory DB)
- flutter test uninstall-reinstall wipes app data: image dir fallback to /data/local/tmp
- run-as cp failures no longer silently swallowed
- phase-2 missing IT_STAFF_ACCOUNT define

Phase 1: all 12 legs green (login→album→category→invite→3 uploads via native
WorkManager→server history→ov visible→hide sync→network fault/recovery→
password change→relogin→locale switch)
Phase 2: bg-home + kill-restart green (A04/A05 emulator-level)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 15:03:17 +08:00

242 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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 <String>[];
// ── 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<Map<UploadTaskState, int>>(
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);
}