/// 底部导航与「上传」角标测试(UI-01 Task 3,§4.1)。 /// /// 覆盖:三页签改名(Albums/Uploads/Settings)与 IndexedStack 状态保持、 /// 角标 = 当前账号未结束任务数(countsByState 聚合,写通知防抖刷新)、 /// 他账号任务不计入(账号隔离)、任务全部结束后角标隐藏、 /// [unfinishedCountOf] 纯函数口径(含 retryableFailed)。 library; 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/app/theme.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/session_store.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/l10n/app_localizations.dart'; import 'package:photo_live/pages/albums_tab.dart'; import 'package:photo_live/pages/home_page.dart'; import 'package:photo_live/pages/queue_tab.dart'; import '../helpers/fakes.dart'; UploadTaskDraft _draft(String id, {String account = 'staff01'}) => UploadTaskDraft( id: id, accountId: account, albumId: 'alb-1', clientTaskId: 'ct-$id', sourceFilePath: '/tmp/$id.jpg', filename: '$id.jpg', bytes: 10, contentType: 'image/jpeg', ); /// 沿合法状态机单步迁移(自迁移非法,须按序调用)。 Future _transition(TaskStore store, String id, UploadTaskState to) => store.updateState(id, to); void main() { late FakeSessionStore session; late TaskStore taskStore; setUp(() { session = FakeSessionStore() ..session = Session( token: 'tok-live-1', expiresAt: DateTime.utc(2099, 1, 1), account: 'staff01', ); taskStore = TaskStore.memory(); }); Future pumpHome(WidgetTester tester) async { // 相册列表页签进入即拉列表;空页兜底即可。 final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody(>[])) : throw StateError('unexpected ${o.path}'), ); final client = ApiClient( sessionStore: session, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, ); await tester.pumpWidget( ProviderScope( overrides: [ ...pageTestOverrides( session: session, taskStore: taskStore, client: client, ), ], child: MaterialApp( theme: AppTheme.light, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const HomePage(), ), ), ); await tester.pumpAndSettle(); } /// 任务库写通知 → 200ms 防抖重查(kQueueRefreshDebounce)。 /// /// 不用 pumpAndSettle:uploading 任务的行级进度条为不确定态(常驻动画)。 Future pumpCounts(WidgetTester tester) async { await tester.pump(kQueueRefreshDebounce * 2); await tester.pump(const Duration(milliseconds: 50)); } testWidgets('三页签改名 + IndexedStack 保持页面状态', (tester) async { await pumpHome(tester); // IndexedStack 常驻三页签(非当前页签 offstage,find 默认跳过,故显式 // 关闭 skipOffstage)。各两次(页签 AppBar 标题 + 导航标签)——UI-01 // 起上传中心页标题与导航标签同名 Uploads。 expect(find.text('Albums', skipOffstage: false), findsNWidgets(2)); expect(find.text('Uploads', skipOffstage: false), findsNWidgets(2)); expect(find.text('Settings', skipOffstage: false), findsNWidgets(2)); expect( find.text('Queue', skipOffstage: false), findsNothing, reason: '旧页签名已移除', ); expect(find.byType(IndexedStack), findsOneWidget); expect(find.byType(AlbumsTab, skipOffstage: false), findsOneWidget); expect(find.byType(QueueTab, skipOffstage: false), findsOneWidget); expect(find.byType(Badge), findsNothing, reason: '初始无任务无角标'); }); testWidgets('角标 = 当前账号未完成任务数;他账号任务不计入', (tester) async { await pumpHome(tester); // 其他账号任务:账号隔离,不显示角标。 final other = await taskStore.create(_draft('t-o1', account: 'staff02')); expect(other.accountId, 'staff02'); await pumpCounts(tester); expect(find.byType(Badge), findsNothing, reason: '他账号任务不计入'); // 本账号 3 个未完成(2 queued + 1 uploading)。 final t1 = await taskStore.create(_draft('t-1')); final t2 = await taskStore.create(_draft('t-2')); final t3 = await taskStore.create(_draft('t-3')); await _transition(taskStore, t1.id, UploadTaskState.queued); await _transition(taskStore, t2.id, UploadTaskState.queued); await _transition(taskStore, t3.id, UploadTaskState.queued); await _transition(taskStore, t3.id, UploadTaskState.uploading); await pumpCounts(tester); expect(find.byType(Badge), findsOneWidget); expect(find.text('3'), findsOneWidget, reason: '角标计数 3'); }); testWidgets('任务全部结束 → 角标隐藏', (tester) async { await pumpHome(tester); final tasks = [ for (var i = 1; i <= 2; i++) (await taskStore.create(_draft('t-$i'))).id, ]; for (final id in tasks) { await _transition(taskStore, id, UploadTaskState.queued); } await pumpCounts(tester); expect(find.text('2'), findsOneWidget); for (final id in tasks) { await _transition(taskStore, id, UploadTaskState.uploading); await _transition(taskStore, id, UploadTaskState.uploadedConfirming); await _transition(taskStore, id, UploadTaskState.completed); } await pumpCounts(tester); expect(find.byType(Badge), findsNothing, reason: '0 隐藏角标'); }); test('unfinishedCountOf 口径:待传 + 等待网络 + 可重试失败', () { Map counts(Map patch) => { for (final state in UploadTaskState.values) state: patch[state] ?? 0, }; // 待传口径(pendingCountOf)之外,可重试失败计入;准备失败/取消/完成不计。 expect( unfinishedCountOf( counts({UploadTaskState.queued: 2, UploadTaskState.uploading: 1}), ), 3, ); expect( unfinishedCountOf( counts({ UploadTaskState.retryableFailed: 1, UploadTaskState.waitingNetwork: 2, }), ), 3, ); expect( unfinishedCountOf( counts({ UploadTaskState.completed: 5, UploadTaskState.cancelled: 1, UploadTaskState.preparationFailed: 1, }), ), 0, ); }); }