2026-09-21 14:55:29 +08:00
|
|
|
|
/// 底部导航与「上传」角标测试(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<void> _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<void> pumpHome(WidgetTester tester) async {
|
|
|
|
|
|
// 相册列表页签进入即拉列表;空页兜底即可。
|
|
|
|
|
|
final adapter = FakeAdapter(
|
|
|
|
|
|
(o) => o.path == '/v1/albums'
|
|
|
|
|
|
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
|
|
|
|
|
: 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<void> 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 默认跳过,故显式
|
2026-09-21 17:20:43 +08:00
|
|
|
|
// 关闭 skipOffstage)。各两次(页签 AppBar 标题 + 导航标签)——UI-01
|
|
|
|
|
|
// 起上传中心页标题与导航标签同名 Uploads。
|
2026-09-21 14:55:29 +08:00
|
|
|
|
expect(find.text('Albums', skipOffstage: false), findsNWidgets(2));
|
2026-09-21 17:20:43 +08:00
|
|
|
|
expect(find.text('Uploads', skipOffstage: false), findsNWidgets(2));
|
2026-09-21 14:55:29 +08:00
|
|
|
|
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 = <String>[
|
|
|
|
|
|
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<UploadTaskState, int> counts(Map<UploadTaskState, int> patch) =>
|
|
|
|
|
|
<UploadTaskState, int>{
|
|
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|