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>
489 lines
18 KiB
Dart
489 lines
18 KiB
Dart
/// 集成测试共用 UI 步骤与轮询助手(P3 Task 11)。
|
||
///
|
||
/// 文案查找器固定中文(App 装配固定 zh,见 app_boot.dart)。
|
||
///
|
||
/// 轮询模式说明:IntegrationTestWidgetsFlutterBinding 是 live binding——
|
||
/// 真实时间流逝(引擎 15s 周期 drain、退避 timer、SnackBar 动画都在真
|
||
/// 时间轴上),因此等待 = `Future.delayed` 真等 + 定期 `tester.pump()`
|
||
/// 让帧重建。**刻意不用 pumpAndSettle**:队列页的进度条/加载指示器是不
|
||
/// 定动画,pumpAndSettle 会等到自身超时。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:integration_test/integration_test.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:photo_live/core/api_client.dart';
|
||
import 'package:photo_live/features/photos/models.dart';
|
||
import 'package:photo_live/features/uploads/providers.dart';
|
||
import 'package:photo_live/features/uploads/task_state.dart';
|
||
|
||
import 'app_boot.dart';
|
||
import 'it_config.dart';
|
||
|
||
// ── 通用等待 ───────────────────────────────────────────────────────────────
|
||
|
||
/// 轮询直到 [finder] 可见;超时抛 Fail(附 [because] 上下文)。
|
||
Future<void> pumpUntil(
|
||
WidgetTester tester,
|
||
Finder finder, {
|
||
Duration timeout = const Duration(seconds: 30),
|
||
String because = 'finder 出现',
|
||
}) async {
|
||
final deadline = DateTime.now().add(timeout);
|
||
while (DateTime.now().isBefore(deadline)) {
|
||
await tester.pump();
|
||
if (finder.evaluate().isNotEmpty) return;
|
||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
await tester.pump();
|
||
if (finder.evaluate().isNotEmpty) return;
|
||
fail('等待超时($timeout)未见到「$because」:$finder');
|
||
}
|
||
|
||
/// 轮询直到谓词为真(store/网络等非 UI 条件)。
|
||
Future<T> pollUntil<T>(
|
||
WidgetTester tester,
|
||
Future<T> Function() probe,
|
||
bool Function(T) done, {
|
||
required Duration timeout,
|
||
String because = '条件满足',
|
||
}) async {
|
||
final deadline = DateTime.now().add(timeout);
|
||
T latest = await probe();
|
||
while (!done(latest) && DateTime.now().isBefore(deadline)) {
|
||
await tester.pump();
|
||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||
latest = await probe();
|
||
}
|
||
if (!done(latest)) {
|
||
fail('等待超时($timeout)「$because」未达成,最后状态:$latest');
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
/// Material 日期/时间选择器的确认按钮(zh=确定;回退 OK 防本地化差异)。
|
||
Future<void> tapDialogOk(WidgetTester tester) async {
|
||
final ok = find
|
||
.text('确定')
|
||
.last
|
||
.evaluate()
|
||
.isNotEmpty
|
||
? find.text('确定').last
|
||
: find.text('OK').last;
|
||
await tester.tap(ok, warnIfMissed: false);
|
||
await tester.pump();
|
||
}
|
||
|
||
// ── 队列状态探针(drift 任务库直读——UI 徽章断言的权威旁证)──────────────
|
||
|
||
/// 当前账号任务的状态计数。
|
||
Future<Map<UploadTaskState, int>> queueSnapshot(
|
||
ItAppHarness harness,
|
||
String account,
|
||
) async {
|
||
final store = harness.container.read(taskStoreProvider);
|
||
final tasks = await store.listByAccount(account);
|
||
final counts = <UploadTaskState, int>{};
|
||
for (final task in tasks) {
|
||
counts[task.state] = (counts[task.state] ?? 0) + 1;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
/// 等待账号下任务数达到 [total] 且全部 completed。
|
||
Future<void> waitAllCompleted(
|
||
WidgetTester tester,
|
||
ItAppHarness harness,
|
||
String account,
|
||
int total,
|
||
) {
|
||
return pollUntil<Map<UploadTaskState, int>>(
|
||
tester,
|
||
() => queueSnapshot(harness, account),
|
||
(counts) =>
|
||
(counts[UploadTaskState.completed] ?? 0) >= total &&
|
||
counts.length == 1,
|
||
timeout: const Duration(minutes: 3),
|
||
because: '$total 个任务全部 completed',
|
||
);
|
||
}
|
||
|
||
// ── 登录 ───────────────────────────────────────────────────────────────────
|
||
|
||
/// 登录表单填写提交并等待进入首页(FAB「新建相册」可见)。
|
||
Future<void> loginViaUi(
|
||
WidgetTester tester,
|
||
String account,
|
||
String password,
|
||
) async {
|
||
await pumpUntil(
|
||
tester,
|
||
find.widgetWithText(TextFormField, '账号'),
|
||
because: '登录表单',
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '账号'),
|
||
account,
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '密码'),
|
||
password,
|
||
);
|
||
await tester.tap(find.text('登录'));
|
||
// 相册 tab 的 FAB 是首页就绪标志。
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('新建相册'),
|
||
timeout: const Duration(seconds: 30),
|
||
because: '登录成功进入首页',
|
||
);
|
||
}
|
||
|
||
// ── 相册创建(含 Material 日期+时间选择器驱动)────────────────────────────
|
||
|
||
/// 新建双语相册:zh 标题 → English 标题 → 开始时间(选择器默认值确认)→
|
||
/// 创建 → 返回列表见到新卡片。
|
||
Future<void> createAlbumViaUi(
|
||
WidgetTester tester,
|
||
String zhTitle,
|
||
String enTitle,
|
||
) async {
|
||
await tester.tap(find.text('新建相册'));
|
||
await pumpUntil(tester, find.text('创建'), because: '相册表单');
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '标题 *'),
|
||
zhTitle,
|
||
);
|
||
await tester.tap(
|
||
find
|
||
.text('English')
|
||
.last, // 内容 tab 切英文条目(非界面语言;.last = 表单 tab,避开顶栏语言切换器)
|
||
);
|
||
await tester.pump();
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '标题'),
|
||
enTitle,
|
||
);
|
||
// 开始时间(必填):占位文本「请选择开始时间」即入口。
|
||
await tester.tap(find.text('请选择开始时间'));
|
||
await pumpUntil(tester, find.text('确定'), because: '日期选择器');
|
||
await tapDialogOk(tester); // 日期:默认今天
|
||
await pumpUntil(tester, find.text('确定'), because: '时间选择器');
|
||
await tapDialogOk(tester); // 时间:默认当前时刻
|
||
await tester.pump();
|
||
await tester.ensureVisible(find.text('创建'));
|
||
await tester.tap(find.text('创建').last);
|
||
await pumpUntil(
|
||
tester,
|
||
find.text(zhTitle),
|
||
timeout: const Duration(seconds: 30),
|
||
because: '相册创建成功回到列表',
|
||
);
|
||
}
|
||
|
||
/// 打开相册详情(点击卡片标题)。
|
||
Future<void> openAlbumDetail(WidgetTester tester, String zhTitle) async {
|
||
await tester.tap(find.text(zhTitle));
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('成员管理'),
|
||
because: '相册详情页动作行',
|
||
);
|
||
}
|
||
|
||
/// 新增分类(详情页对话框)。
|
||
Future<void> addCategoryViaUi(
|
||
WidgetTester tester,
|
||
String zhName,
|
||
String enName,
|
||
) async {
|
||
await tester.ensureVisible(find.byTooltip('新增分类'));
|
||
await tester.tap(find.byTooltip('新增分类'));
|
||
await pumpUntil(tester, find.text('中文名称'), because: '分类对话框');
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '中文名称'),
|
||
zhName,
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '英文名称'),
|
||
enName,
|
||
);
|
||
// 软键盘会遮挡对话框按钮(曾致「保存」点击落空、对话框滞留、后续腿全断)——
|
||
// 收起键盘 + 滚动到按钮后再点。
|
||
await dismissKeyboard(tester);
|
||
await tester.ensureVisible(find.text('保存').last);
|
||
await tester.tap(find.text('保存').last);
|
||
// 分类列表以「 · 」拼接 zh/en 名,但拼接顺序随服务端 translations 返回顺序
|
||
// (locale 排序可能 en 在前)——顺序无关断言:两个名字都在分类区出现。
|
||
await pumpUntil(
|
||
tester,
|
||
find.textContaining(zhName),
|
||
because: '分类列表出现新分类(zh 名)',
|
||
);
|
||
await pumpUntil(
|
||
tester,
|
||
find.textContaining(enName),
|
||
because: '分类列表出现新分类(en 名)',
|
||
);
|
||
}
|
||
|
||
/// 收起软键盘(done 动作;无焦点时无害)。
|
||
Future<void> dismissKeyboard(WidgetTester tester) async {
|
||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||
await tester.pump(const Duration(milliseconds: 300));
|
||
}
|
||
|
||
/// 返回上一页。Material 主题的 AppBar 返回键是 BackButton 组件/arrow_back
|
||
/// 图标——`tester.pageBack()` 只认 Cupertino 返回键,在本 App 不可用。
|
||
/// 注意:路由栈叠页时树里有多个返回键,必须取「可命中的最后一个」
|
||
/// (= 栈顶页面的),`.first` 会点中被压在下面的页面返回键(曾致腿 9
|
||
/// 停留在照片管理页)。
|
||
Future<void> goBack(WidgetTester tester) async {
|
||
final byType = find.byType(BackButton).hitTestable();
|
||
final byIcon = find.byIcon(Icons.arrow_back).hitTestable();
|
||
final target = byType.evaluate().isNotEmpty
|
||
? byType.last
|
||
: byIcon.evaluate().isNotEmpty
|
||
? byIcon.last
|
||
: null;
|
||
if (target == null) {
|
||
fail('goBack: 找不到可命中的 Material 返回键(BackButton / arrow_back)');
|
||
}
|
||
await tester.tap(target);
|
||
await tester.pump(const Duration(milliseconds: 400));
|
||
}
|
||
|
||
/// 邀请成员:搜索账号前缀 → 点「邀请」→ 成员列表出现(协作者)。
|
||
Future<void> inviteMemberViaUi(WidgetTester tester, String account) async {
|
||
// 不用 pumpAndSettle(见文件头说明:页面可能含不定动画)——定长泵即可。
|
||
await tester.pump(const Duration(seconds: 1));
|
||
await tester.ensureVisible(find.text('成员管理').first);
|
||
await tester.tap(find.text('成员管理').first);
|
||
// 页面可能同时存在多个 TextField——按邀请搜索框的 hintText 精确定位。
|
||
final searchField = find.widgetWithText(
|
||
TextField,
|
||
'输入账号前缀搜索邀请(至少 2 个字符)',
|
||
);
|
||
try {
|
||
await pumpUntil(
|
||
tester,
|
||
searchField,
|
||
timeout: const Duration(seconds: 20),
|
||
because: '成员管理页搜索框',
|
||
);
|
||
} on TestFailure {
|
||
// 诊断:截图 + 关键计数,帮助定位是「页面没打开」还是「finder 不匹配」。
|
||
final binding = IntegrationTestWidgetsFlutterBinding.instance;
|
||
await binding.takeScreenshot('invite-member-miss');
|
||
debugPrint(
|
||
'[IT-DIAG] 成员管理文本数=${find.text('成员管理').evaluate().length} '
|
||
'TextField数=${find.byType(TextField).evaluate().length} '
|
||
'中文名称(残留对话框)=${find.text('中文名称').evaluate().length}',
|
||
);
|
||
for (final t in find.byType(TextField).evaluate()) {
|
||
debugPrint('[IT-DIAG] TextField: $t');
|
||
}
|
||
rethrow;
|
||
}
|
||
await tester.enterText(searchField, account);
|
||
// 防抖 300ms + 搜索请求。
|
||
await Future<void>.delayed(const Duration(seconds: 2));
|
||
await tester.pump();
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('邀请'),
|
||
because: '搜索结果出现邀请按钮',
|
||
);
|
||
// 搜索结果的「邀请」按钮同样可能被键盘遮挡——先收起再点。
|
||
await dismissKeyboard(tester);
|
||
await tester.ensureVisible(find.text('邀请').first);
|
||
await tester.tap(find.text('邀请').first);
|
||
await pumpUntil(
|
||
tester,
|
||
find.text(account),
|
||
timeout: const Duration(seconds: 30),
|
||
because: '成员列表出现受邀账号',
|
||
);
|
||
await pumpUntil(tester, find.text('协作者'), because: '协作者角色标签');
|
||
await goBack(tester);
|
||
await tester.pump();
|
||
}
|
||
|
||
/// 详情页 FAB「上传」→「从相册选择」→ 选图替身返回 [paths] → 等待
|
||
/// 入队(相册多选)。调用前须已在相册详情页。[account] = 任务归属账号
|
||
/// (任务库按账号隔离,权威判定需用同一账号查询)。
|
||
Future<void> enqueueViaGallery(
|
||
WidgetTester tester,
|
||
ItAppHarness harness,
|
||
String account,
|
||
List<String> paths,
|
||
int expectedCount,
|
||
) async {
|
||
harness.picker.stage(paths);
|
||
// 就绪等待:goBack 回详情页后页面处于数据加载态(FAB 尚未挂出,曾致
|
||
// 第 18 轮腿 9 失败)——以动作行出现为准。
|
||
try {
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('成员管理'),
|
||
timeout: const Duration(seconds: 15),
|
||
because: '相册详情页就绪(动作行)',
|
||
);
|
||
} on TestFailure {
|
||
// 页面自拍照:转储当前树里的可见文本,定位「实际在哪一页」。
|
||
debugPrint('[IT-DIAG] 详情页未就绪,当前页面文本:');
|
||
for (final w in find.byType(Text).evaluate().take(40)) {
|
||
final t = (w.widget as Text).data;
|
||
if (t != null && t.trim().isNotEmpty) debugPrint('[IT-DIAG] Text: $t');
|
||
}
|
||
rethrow;
|
||
}
|
||
// 详情页 InfoCard 的「上传」状态标签与 FAB 文案同字(tagUpload ==
|
||
// uploadAction ==「上传」),必须收窄到 FAB 内的文本。
|
||
await tester.tap(
|
||
find.descendant(
|
||
of: find.byType(FloatingActionButton),
|
||
matching: find.text('上传').hitTestable(),
|
||
).first,
|
||
);
|
||
await pumpUntil(tester, find.text('从相册选择'), because: '上传底部菜单');
|
||
// 底部弹层有入场动画:byType(BottomSheet) 在动画中途即满足,而此刻选项
|
||
// 尚不可命中(hitTestable=false)——曾两次因此竞态失败。直接等待
|
||
// 「选项可命中」这一最终条件。
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('从相册选择').hitTestable(),
|
||
because: '上传底部弹层·从相册选择可命中',
|
||
);
|
||
await tester.tap(find.text('从相册选择').hitTestable());
|
||
// 诊断窗口:3 秒后打印 选择器是否被消费 / 会话账号 / 任务库快照,
|
||
// 区分 处理器未触发 / 账号为空 / 入队失败 三类路径(IT-DIAG 前缀)。
|
||
await Future<void>.delayed(const Duration(seconds: 3));
|
||
await tester.pump();
|
||
final session = await harness.container
|
||
.read(sessionStoreProvider)
|
||
.read();
|
||
debugPrint(
|
||
'[IT-DIAG] pickerConsumed=${harness.picker.consumed} '
|
||
'sessionAccount=${session?.account} '
|
||
'snapshot=${await queueSnapshot(harness, account)}',
|
||
);
|
||
// 入队提示 Snackbar 是转瞬即逝的装饰(曾因时序/文案未捕获而误报)——
|
||
// 权威判定 = 本地任务库出现 expectedCount 个任务(准备完成入 queued+)。
|
||
await pollUntil<Map<UploadTaskState, int>>(
|
||
tester,
|
||
() => queueSnapshot(harness, account),
|
||
(counts) => counts.values.fold(0, (a, b) => a + b) >= expectedCount,
|
||
timeout: const Duration(seconds: 60),
|
||
because: '本地任务库出现 $expectedCount 个任务',
|
||
);
|
||
}
|
||
|
||
// ── 照片管理 / 服务端历史 ──────────────────────────────────────────────────
|
||
|
||
/// 打开照片管理页并等待首屏行(含 [minCount] 个「已发布」)。
|
||
Future<void> openPhotosPageAndWait(
|
||
WidgetTester tester,
|
||
int minCount,
|
||
) async {
|
||
await tester.ensureVisible(find.text('照片管理'));
|
||
await tester.tap(find.text('照片管理'));
|
||
await pumpUntil(
|
||
tester,
|
||
find.text('已发布'),
|
||
timeout: const Duration(seconds: 60),
|
||
because: '照片管理列表出现已发布照片',
|
||
);
|
||
final published = find.text('已发布').evaluate().length;
|
||
if (published < minCount) {
|
||
fail('照片管理页已发布数量不足:$published < $minCount');
|
||
}
|
||
}
|
||
|
||
// ── ov 观众侧 API 检查(viewer 腿,替代网页断言——登记偏差)──────────────
|
||
|
||
/// 轮询 ov 公开相册照片数(经真实 cn→worker→outbox→sync→ov 管线)达到
|
||
/// [expected] 张。dev:sync 推送周期 2s + worker 处理,正常 <30s,给足 2min。
|
||
Future<int> waitOvPublicPhotoCount(String publicToken, int expected) async {
|
||
final dio = Dio(
|
||
BaseOptions(
|
||
baseUrl: ItConfig.ovBase,
|
||
connectTimeout: const Duration(seconds: 10),
|
||
receiveTimeout: const Duration(seconds: 10),
|
||
),
|
||
);
|
||
try {
|
||
final deadline = DateTime.now().add(const Duration(minutes: 2));
|
||
var last = -1;
|
||
while (DateTime.now().isBefore(deadline)) {
|
||
try {
|
||
final resp = await dio.get<Map<String, dynamic>>(
|
||
'/v1/public/albums/$publicToken/photos',
|
||
queryParameters: {'limit': 50},
|
||
);
|
||
final items = (resp.data?['items'] as List<dynamic>? ?? const <dynamic>[]);
|
||
last = items.length;
|
||
if (last >= expected) return last;
|
||
} on DioException {
|
||
// 相册/照片尚未同步到 ov:继续轮询。
|
||
}
|
||
await Future<void>.delayed(const Duration(seconds: 2));
|
||
}
|
||
fail(
|
||
'ov 公开相册照片数未达到 $expected(最后 $last)——'
|
||
'检查 dev:worker / dev:sync 是否在运行',
|
||
);
|
||
} finally {
|
||
dio.close();
|
||
}
|
||
}
|
||
|
||
/// 隐藏一张照片(管理动作;App 照片页为 Task 4 占位无操作按钮——登记
|
||
/// 偏差:经 App 同一 ApiClient 走真实契约端点,随后回 UI 断言状态同步)。
|
||
Future<void> hideFirstPhotoViaApi(
|
||
ItAppHarness harness,
|
||
String albumId,
|
||
) async {
|
||
final api = harness.container.read(apiClientProvider);
|
||
final page = await api.listAlbumPhotos(albumId, limit: 20);
|
||
final published = page.items
|
||
.where((photo) => photo.visibility == PhotoVisibility.published)
|
||
.toList();
|
||
if (published.isEmpty) {
|
||
fail('相册 $albumId 无已发布照片可隐藏');
|
||
}
|
||
await api.updatePhotoVisibility(
|
||
published.first.photoId,
|
||
PhotoVisibility.hidden,
|
||
);
|
||
}
|
||
|
||
// ── 测试图片定位 ───────────────────────────────────────────────────────────
|
||
|
||
/// 解析设备端测试图片并返回小图绝对路径列表(it_a/b/c... 顺序稳定)。
|
||
Future<List<String>> resolveSmallImages() async {
|
||
final support = await getApplicationSupportDirectory();
|
||
final dir = await resolveImageDir(support.path);
|
||
final list = Directory(dir)
|
||
.listSync()
|
||
.whereType<File>()
|
||
.map((file) => file.path)
|
||
.where((path) {
|
||
final name = p.basename(path);
|
||
return name.startsWith('it_') &&
|
||
name.endsWith('.jpg') &&
|
||
!name.startsWith('it_large');
|
||
})
|
||
.toList()
|
||
..sort();
|
||
if (list.length < 3) {
|
||
fail('测试图片不足 3 张($dir 下仅 ${list.length}):$list');
|
||
}
|
||
return list;
|
||
}
|