d41be367f8
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>
242 lines
11 KiB
Dart
242 lines
11 KiB
Dart
/// 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);
|
||
}
|