Files
photo-live/apps/mobile/integration_test/helpers/ui_steps.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

489 lines
18 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.
/// 集成测试共用 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;
}