/// 集成测试共用 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: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 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.delayed(const Duration(milliseconds: 200)); } await tester.pump(); if (finder.evaluate().isNotEmpty) return; fail('等待超时($timeout)未见到「$because」:$finder'); } /// 轮询直到谓词为真(store/网络等非 UI 条件)。 Future pollUntil( WidgetTester tester, Future 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.delayed(const Duration(milliseconds: 500)); latest = await probe(); } if (!done(latest)) { fail('等待超时($timeout)「$because」未达成,最后状态:$latest'); } return latest; } /// Material 日期/时间选择器的确认按钮(zh=确定;回退 OK 防本地化差异)。 Future 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> queueSnapshot( ItAppHarness harness, String account, ) async { final store = harness.container.read(taskStoreProvider); final tasks = await store.listByAccount(account); final counts = {}; for (final task in tasks) { counts[task.state] = (counts[task.state] ?? 0) + 1; } return counts; } /// 等待账号下任务数达到 [total] 且全部 completed。 Future waitAllCompleted( WidgetTester tester, ItAppHarness harness, String account, int total, ) { return pollUntil>( tester, () => queueSnapshot(harness, account), (counts) => (counts[UploadTaskState.completed] ?? 0) >= total && counts.length == 1, timeout: const Duration(minutes: 3), because: '$total 个任务全部 completed', ); } // ── 登录 ─────────────────────────────────────────────────────────────────── /// 登录表单填写提交并等待进入首页(FAB「新建相册」可见)。 Future 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 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')); // 内容 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 openAlbumDetail(WidgetTester tester, String zhTitle) async { await tester.tap(find.text(zhTitle)); await pumpUntil( tester, find.text('成员管理'), because: '相册详情页动作行', ); } /// 新增分类(详情页对话框)。 Future 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 tester.tap(find.text('保存').last); await pumpUntil( tester, find.text('$zhName · $enName'), because: '分类列表出现新分类', ); } /// 邀请成员:搜索账号前缀 → 点「邀请」→ 成员列表出现(协作者)。 Future inviteMemberViaUi(WidgetTester tester, String account) async { await tester.tap(find.text('成员管理')); await pumpUntil(tester, find.byType(TextField), because: '成员管理页'); await tester.enterText(find.byType(TextField), account); // 防抖 300ms + 搜索请求。 await Future.delayed(const Duration(seconds: 2)); await tester.pump(); await pumpUntil( tester, find.text('邀请'), because: '搜索结果出现邀请按钮', ); await tester.tap(find.text('邀请')); await pumpUntil( tester, find.text(account), timeout: const Duration(seconds: 30), because: '成员列表出现受邀账号', ); await pumpUntil(tester, find.text('协作者'), because: '协作者角色标签'); await tester.pageBack(); await tester.pump(); } /// 详情页 FAB「上传」→「从相册选择」→ 选图替身返回 [paths] → 等待 /// 入队提示。调用前须已在相册详情页。 Future enqueueViaGallery( WidgetTester tester, ItAppHarness harness, List paths, int expectedCount, ) async { harness.picker.stage(paths); // 详情页 InfoCard 的「上传」状态标签与 FAB 文案同字(tagUpload == // uploadAction ==「上传」),必须收窄到 FAB 内的文本。 await tester.tap( find.descendant( of: find.byType(FloatingActionButton), matching: find.text('上传'), ), ); await pumpUntil(tester, find.text('从相册选择'), because: '上传底部菜单'); await tester.tap(find.text('从相册选择')); await pumpUntil( tester, find.text('已加入 $expectedCount 张上传队列'), because: '入队提示', ); } // ── 照片管理 / 服务端历史 ────────────────────────────────────────────────── /// 打开照片管理页并等待首屏行(含 [minCount] 个「已发布」)。 Future 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 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>( '/v1/public/albums/$publicToken/photos', queryParameters: {'limit': 50}, ); final items = (resp.data?['items'] as List? ?? const []); last = items.length; if (last >= expected) return last; } on DioException { // 相册/照片尚未同步到 ov:继续轮询。 } await Future.delayed(const Duration(seconds: 2)); } fail( 'ov 公开相册照片数未达到 $expected(最后 $last)——' '检查 dev:worker / dev:sync 是否在运行', ); } finally { dio.close(); } } /// 隐藏一张照片(管理动作;App 照片页为 Task 4 占位无操作按钮——登记 /// 偏差:经 App 同一 ApiClient 走真实契约端点,随后回 UI 断言状态同步)。 Future 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> resolveSmallImages() async { final support = await getApplicationSupportDirectory(); final dir = await resolveImageDir(support.path); final list = Directory(dir) .listSync() .whereType() .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; }