/// 上传队列页 widget 测试(P3 计划 Task 9,需求 4.3)。 /// /// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore /// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。 /// 覆盖:任务行渲染(状态徽章/错误文案/相册名)、三维筛选交互、按状态 /// 动作(重试/取消/删除)、批量重试条、写通知防抖实时刷新、行级进度条。 library; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/device_id.dart'; import 'package:photo_live/core/session_store.dart'; import 'package:photo_live/features/uploads/enqueue.dart'; import 'package:photo_live/features/uploads/preparer.dart'; import 'package:photo_live/features/uploads/providers.dart'; import 'package:photo_live/features/uploads/queue_controller.dart'; import 'package:photo_live/features/uploads/task_state.dart'; import 'package:photo_live/features/uploads/task_store.dart'; import 'package:photo_live/features/uploads/upload_engine.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/queue_tab.dart'; import '../helpers/fakes.dart'; /// JPEG 魔数(准备器换源重试用真实临时文件)。 const List kJpgBytes = [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, ]; class _FixedDeviceIdStore implements DeviceIdStore { @override Future read() async => '0123456789abcdef0123456789abcdef'; @override Future write(String value) async {} } /// 引擎替身:记录 retryNow/cancel,不出队(动作流断言点;真实出队语义 /// 由 upload_engine_test 覆盖)。 class RecordingEngine extends UploadEngine { RecordingEngine( super.store, super.api, super.deviceId, { required Dio objectDio, }) : super(objectDio: objectDio); final List<(String, String)> retryCalls = <(String, String)>[]; final List<(String, String)> cancelCalls = <(String, String)>[]; @override Future retryNow(String accountId, String taskId) async => retryCalls.add((accountId, taskId)); @override Future cancel(String accountId, String taskId) async { cancelCalls.add((accountId, taskId)); return true; } @override void notifyWorkAvailable() {} } void main() { late FakeSessionStore session; late TaskStore store; late RecordingEngine engine; late ApiClient api; late Directory tmp; late List pickedPaths; setUp(() async { session = FakeSessionStore() ..session = Session( token: 'tok-1', expiresAt: DateTime.utc(2099, 1, 1), account: 'alice', ); store = TaskStore.memory(); api = ApiClient( sessionStore: session, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = FakeAdapter((o) { if (o.method == 'GET' && o.path == '/v1/albums') { return ( 200, cursorPageBody(>[ albumJson(id: 'alb-1'), albumJson( id: 'alb-2', translations: >[ { 'locale': 'en', 'title': 'Wedding Day', 'description': null, 'venue': null, 'address': null, }, ], ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }), ); engine = RecordingEngine( store, api, DeviceId(store: _FixedDeviceIdStore()), objectDio: Dio() ..httpClientAdapter = FakeAdapter((_) => throw StateError('替身引擎不应发起传输')), ); tmp = await Directory.systemTemp.createTemp('queue-tab-test-'); pickedPaths = []; }); tearDown(() async { await store.close(); await tmp.delete(recursive: true); }); Future pumpQueue(WidgetTester tester) async { await tester.pumpWidget( ProviderScope( overrides: [ ...pageTestOverrides(session: session, taskStore: store, client: api), uploadEngineProvider.overrideWith((ref) => engine), uploadPreparerProvider.overrideWith( (ref) => UploadPreparer( store, baseDirProvider: () async => tmp, ), ), singleImagePickerProvider .overrideWith((ref) => () async => pickedPaths.removeAt(0)), ], child: const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: QueueTab(), ), ), ); // 不用 pumpAndSettle:uploading 行的不确定态进度条永远动画(帧不断)。 await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300)); } /// 种子任务(真实迁移链到目标态;attempt/lastError 供失败文案断言)。 Future seed( String filename, { String albumId = 'alb-1', UploadTaskState state = UploadTaskState.queued, DateTime? createdAt, String? lastError, int? attempt, }) async { final id = 'task-$filename'; var task = await store.create( UploadTaskDraft( id: id, accountId: 'alice', albumId: albumId, clientTaskId: 'ct-$filename', sourceFilePath: '${tmp.path}/$filename', filename: filename, bytes: 16, contentType: 'image/jpeg', ), now: createdAt, ); Future to(UploadTaskState s, {String? error, int? tries}) => store.updateState( task.id, s, patch: UploadTaskPatch(lastError: error, attempt: tries), now: createdAt, ); switch (state) { case UploadTaskState.preparing: break; case UploadTaskState.queued: case UploadTaskState.preparationFailed: case UploadTaskState.cancelled: task = await to(state, error: lastError); case UploadTaskState.waitingNetwork: task = await to(UploadTaskState.queued); task = await to(state, error: lastError); case UploadTaskState.uploading: case UploadTaskState.retryableFailed: task = await to(UploadTaskState.queued); task = await to(UploadTaskState.uploading); if (state == UploadTaskState.retryableFailed) { task = await to(state, error: lastError, tries: attempt); } case UploadTaskState.uploadedConfirming: case UploadTaskState.completed: task = await to(UploadTaskState.queued); task = await to(UploadTaskState.uploading); task = await to(UploadTaskState.uploadedConfirming); if (state == UploadTaskState.completed) { task = await to(state); } } return task; } /// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle—— /// uploading 行的不确定态进度条动画不会停)。 Future settleDebounce(WidgetTester tester) async { await tester.pump(kQueueRefreshDebounce); await tester.pump(const Duration(milliseconds: 120)); } testWidgets('各状态任务行:徽章文案 + 相册名(缓存)+ 缩略图占位', (tester) async { await seed('prep.jpg', state: UploadTaskState.preparing); await seed('ok.jpg', state: UploadTaskState.completed); await seed('net.jpg', state: UploadTaskState.waitingNetwork); await seed('alb2.jpg', albumId: 'alb-2', state: UploadTaskState.uploading); await pumpQueue(tester); // 状态徽章(en locale;断言锚定在行内——chip 栏文案与部分徽章同名)。 expect( find .descendant( of: find.widgetWithText(ListTile, 'prep.jpg'), matching: find.text('Preparing'), ) .evaluate(), isNotEmpty, ); expect( find .descendant( of: find.widgetWithText(ListTile, 'ok.jpg'), matching: find.text('Completed'), ) .evaluate(), isNotEmpty, ); expect( find .descendant( of: find.widgetWithText(ListTile, 'net.jpg'), matching: find.text('Waiting for network'), ) .evaluate(), isNotEmpty, ); expect( find .descendant( of: find.widgetWithText(ListTile, 'alb2.jpg'), matching: find.text('Uploading'), ) .evaluate(), isNotEmpty, ); // 文件名。 expect(find.text('ok.jpg'), findsOneWidget); // 相册名经 albums 缓存展示(en 主标题;3 条 alb-1 + 1 条 alb-2)。 expect(find.text('Opening Gala'), findsNWidgets(3)); expect(find.text('Wedding Day'), findsOneWidget); // 缩略图:每行一个 Image.file(源缺失走 errorBuilder 占位;fake-async // 下真实 IO 不完成,仅断言 Image 挂载)。 expect(find.byType(Image), findsNWidgets(4)); }); testWidgets('失败任务:本地化错误文案 + 尝试次数', (tester) async { await seed( 'big.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_TOO_LARGE', ); await seed( 'flaky.jpg', state: UploadTaskState.retryableFailed, lastError: 'NETWORK_ERROR', attempt: 2, ); await pumpQueue(tester); expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget); expect( find.text('Network error. Check your connection and retry · ' '2 attempt(s)'), findsOneWidget, ); }); testWidgets('状态筛选:Failed chip → 仅失败行', (tester) async { await seed('bad.jpg', state: UploadTaskState.preparationFailed); await seed('good.jpg', state: UploadTaskState.completed); await pumpQueue(tester); // 状态 chips 行横向滚动——Failed 在屏外,先滚入再点。 await tester.ensureVisible(find.text('Failed')); await tester.pumpAndSettle(); await tester.tap(find.text('Failed')); await settleDebounce(tester); expect(find.text('bad.jpg'), findsOneWidget); expect(find.text('good.jpg'), findsNothing); }); testWidgets('时间筛选:Today → 昨日任务排除', (tester) async { final now = DateTime.now(); await seed('fresh.jpg', createdAt: now); await seed( 'old.jpg', createdAt: now.subtract(const Duration(days: 40)), ); await pumpQueue(tester); expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间'); await tester.tap(find.text('Today')); await settleDebounce(tester); expect(find.text('fresh.jpg'), findsOneWidget); expect(find.text('old.jpg'), findsNothing); }); testWidgets('相册筛选:下拉选中 alb-2 → 仅该相册行', (tester) async { await seed('one.jpg', albumId: 'alb-1'); await seed('two.jpg', albumId: 'alb-2'); await pumpQueue(tester); await tester.tap(find.text('All albums')); await tester.pumpAndSettle(); await tester.tap(find.text('Wedding Day').last); await settleDebounce(tester); expect(find.text('two.jpg'), findsOneWidget); expect(find.text('one.jpg'), findsNothing); }); testWidgets('按状态动作可见性:queued 取消 / completed 删除 / uploading 无', (tester) async { await seed('q.jpg', state: UploadTaskState.queued); await seed('done.jpg', state: UploadTaskState.completed); await seed('up.jpg', state: UploadTaskState.uploading); await pumpQueue(tester); // queued 行 → 取消图标;completed 行 → 删除图标;uploading 行无动作。 final cancelTile = find.widgetWithText(ListTile, 'q.jpg'); final doneTile = find.widgetWithText(ListTile, 'done.jpg'); final upTile = find.widgetWithText(ListTile, 'up.jpg'); expect( find .descendant(of: cancelTile, matching: find.byIcon(Icons.close)) .evaluate() .length, 1, reason: 'queued 行有取消按钮', ); expect( find .descendant( of: doneTile, matching: find.byIcon(Icons.delete_outline), ) .evaluate() .length, 1, reason: 'completed 行有删除按钮', ); expect( find.descendant(of: upTile, matching: find.byType(IconButton)).evaluate(), isEmpty, reason: 'uploading 行无动作(传输中不打断)', ); }); testWidgets('取消流:queued 行点取消 → engine.cancel(账号, 任务)', (tester) async { final task = await seed('q.jpg'); await pumpQueue(tester); await tester.tap(find.descendant( of: find.widgetWithText(ListTile, 'q.jpg'), matching: find.byIcon(Icons.close), )); await tester.pumpAndSettle(); expect(engine.cancelCalls, [('alice', task.id)]); }); testWidgets('重试流:retryableFailed 行 → engine.retryNow', (tester) async { final task = await seed( 'flaky.jpg', state: UploadTaskState.retryableFailed, lastError: 'INTERNAL_ERROR', ); await pumpQueue(tester); await tester.tap(find.descendant( of: find.widgetWithText(ListTile, 'flaky.jpg'), matching: find.byIcon(Icons.refresh), )); await tester.pumpAndSettle(); expect(engine.retryCalls, [('alice', task.id)]); }); testWidgets('准备失败重试:重新选图 → 换源重新入队为 queued', (tester) async { final task = await seed( 'broken.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_SOURCE_UNREADABLE', ); // 替身选图器返回真实 JPEG 临时文件。 final picked = File('${tmp.path}/repicked.jpg') ..writeAsBytesSync(kJpgBytes); pickedPaths.add(picked.path); await pumpQueue(tester); // 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。 await tester.runAsync(() async { await tester.tap(find.descendant( of: find.widgetWithText(ListTile, 'broken.jpg'), matching: find.byIcon(Icons.refresh), )); await Future.delayed(const Duration(milliseconds: 400)); }); await settleDebounce(tester); final updated = await store.get('alice', task.id); expect(updated?.state, UploadTaskState.queued); expect(updated?.lastError, isNull); // 重试换源 = 新源复制进同一受管路径(路径不变、内容替换)。 expect(updated?.sourceFilePath, task.sourceFilePath); expect(File(task.sourceFilePath).readAsBytesSync(), kJpgBytes, reason: '受管副本内容已替换为重选文件'); }); testWidgets('删除流:completed 行删除 → 行消失 + 库内清除', (tester) async { final task = await seed('done.jpg', state: UploadTaskState.completed); await pumpQueue(tester); await tester.tap(find.descendant( of: find.widgetWithText(ListTile, 'done.jpg'), matching: find.byIcon(Icons.delete_outline), )); await settleDebounce(tester); expect(find.text('done.jpg'), findsNothing); expect(await store.get('alice', task.id), isNull); }); testWidgets('批量重试条:出现条件 + 一键重试全部可自动重试任务', (tester) async { final auto1 = await seed( 'f1.jpg', state: UploadTaskState.retryableFailed, lastError: 'INTERNAL_ERROR', ); final auto2 = await seed( 'f2.jpg', state: UploadTaskState.waitingNetwork, lastError: 'NETWORK_ERROR', ); await seed( 'f3.jpg', state: UploadTaskState.preparationFailed, lastError: 'PREP_COPY_FAILED', ); await pumpQueue(tester); // 2 条可自动重试(retryableFailed + waitingNetwork)→ 批量条出现。 expect(find.text('Retry all failed'), findsOneWidget); expect(find.text('Failed · 2'), findsOneWidget, reason: '失败计数 = 准备失败 + 上传失败(不含等待网络)'); await tester.tap(find.text('Retry all failed')); await tester.pumpAndSettle(); expect(engine.retryCalls, containsAll([ ('alice', auto1.id), ('alice', auto2.id), ])); expect( engine.retryCalls.map((c) => c.$2), isNot(contains('task-f3.jpg')), reason: '准备失败需换源重选,不进批量重试', ); }); testWidgets('无失败任务 → 无批量条', (tester) async { await seed('q.jpg'); await pumpQueue(tester); expect(find.text('Retry all failed'), findsNothing); }); testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)', (tester) async { await seed('first.jpg'); await pumpQueue(tester); expect(find.text('second.jpg'), findsNothing); // 模拟连拍页入队(不经 UI 直接写库)。 await seed('second.jpg'); await settleDebounce(tester); expect(find.text('second.jpg'), findsOneWidget); }); testWidgets('行级进度:uploading 行订阅进度总线,分数更新', (tester) async { final task = await seed('up.jpg', state: UploadTaskState.uploading); await pumpQueue(tester); final context = tester.element(find.byType(QueueTab)); final hub = ProviderScope.containerOf(context) .read(uploadProgressHubProvider); LinearProgressIndicator indicatorOf() => find .descendant( of: find.widgetWithText(ListTile, 'up.jpg'), matching: find.byType(LinearProgressIndicator), ) .evaluate() .single .widget as LinearProgressIndicator; expect(indicatorOf().value, isNull, reason: '无事件时不确定态进度条'); hub.emit(task.id, 50, 100); await tester.pump(); expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%'); }); }