feat(mobile): upload queue ui and continuous capture page

Task 9 (P3 plan): queue tab replaces the stub, continuous capture page
enqueues per shot, album detail gains an upload FAB, and the upload
engine lifecycle is now bound to the login session.

- Engine scope: session account drives UploadEngine start/stop; wired
  from app boot, login success, logout, and 401 eviction. Tasks stay in
  the store across logout (accountId hard isolation).
- Queue tab: album x state x time filter bar, task rows with thumbnail,
  state chip, localized error copy, attempt count, per-row progress via
  a broadcast progress hub, state-dependent actions (re-pick retry,
  retryNow, cancel, delete), batch retry bar, 200ms-debounced live
  refresh from drift table updates.
- Capture page: camera preview via injected CaptureSource, one enqueue
  per shutter press, persistent album/category/badges overlay with a
  switch-only-affects-new-shots hint, gallery multi-pick, exit confirm
  while uploads are pending, permission-denied and init-failure views.
- POST_NOTIFICATIONS runtime permission via a small MethodChannel
  (photolive/permissions) + PermissionPlugin.kt: single runtime
  permission need, avoids permission_handler dependency; requested once
  on first enqueue, silent degradation when denied or unavailable.
- Album detail FAB: capture-and-upload / pick-from-gallery sheet.
- l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine
  scope lifecycle, permission gate, queue tab, capture page, FAB entry);
  existing page tests updated to inject session/task-store fakes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 04:47:31 +08:00
parent 42e1c2e34b
commit 955f9b13c1
30 changed files with 4206 additions and 51 deletions
+3 -1
View File
@@ -33,9 +33,11 @@ void main() {
);
// PhotoLiveApp.initState 读取被 override 的客户端并注册 onUnauthorized。
// Task 9 起根组件 watch 引擎作用域(会话→引擎 start),真实
// flutter_secure_storage/path_provider 平台通道在测试环境挂起,须替身。
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
overrides: [...pageTestOverrides(session: store, client: client)],
child: const PhotoLiveApp(),
),
);
@@ -0,0 +1,80 @@
/// POST_NOTIFICATIONS 权限门测试(P3 计划 Task 9)。
///
/// 覆盖:Android 首次请求一次(幂等,二次调用不触通道)、非 Android 跳过、
/// 通道缺失/异常静默降级不阻断入队。
library;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/notification_permission.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel(NotificationPermission.kChannelName);
// 测试绑定的 defaultBinaryMessenger 即 TestDefaultBinaryMessenger
// (setMockMethodCallHandler 所在)。
final byteData = TestWidgetsFlutterBinding.instance.defaultBinaryMessenger;
tearDown(() => byteData.setMockMethodCallHandler(channel, null));
test('Android:首次请求触发通道一次;再次调用不再打扰(幂等)', () async {
var calls = 0;
byteData.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'requestPostNotifications');
calls++;
return 'granted';
});
final permission =
NotificationPermission(isAndroid: true, channel: channel);
await permission.ensureRequested();
await permission.ensureRequested();
await permission.ensureRequested();
expect(calls, 1, reason: '进程内只请求一次');
});
test('非 Android:不触通道(iOS 后台传输不依赖通知权限)', () async {
var calls = 0;
byteData.setMockMethodCallHandler(channel, (call) async {
calls++;
return 'granted';
});
final permission =
NotificationPermission(isAndroid: false, channel: channel);
await permission.ensureRequested();
expect(calls, 0);
});
test('通道缺失(iOS/桌面无此方法)→ 静默降级不抛', () async {
byteData.setMockMethodCallHandler(
channel,
(call) async => throw MissingPluginException('no handler'),
);
final permission =
NotificationPermission(isAndroid: true, channel: channel);
await permission.ensureRequested(); // 不抛即通过。
});
test('通道异常后仍标记已请求(失败也不二次打扰)', () async {
var calls = 0;
byteData.setMockMethodCallHandler(
channel,
(call) async {
calls++;
throw PlatformException(code: 'ACTIVITY_MISSING');
},
);
final permission =
NotificationPermission(isAndroid: true, channel: channel);
await permission.ensureRequested();
await permission.ensureRequested();
expect(calls, 1);
});
}
@@ -0,0 +1,154 @@
/// 引擎生命周期绑定测试(P3 计划 Task 9,Task 6 关注点 1 接线)。
///
/// ProviderContainer 级(非 widget):登录会话账号 → uploadEngineScopeProvider
/// 驱动 UploadEngine.start/stop。覆盖:登录启动、登出停止且任务保留库内、
/// 账号切换重启。真实平台通道(secure storage 等)全部以替身注入。
library;
import 'package:dio/dio.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/engine_scope.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import '../../helpers/fakes.dart';
/// 固定设备 ID(引擎出队才会读;空库场景实际不触网络)。
class _FixedDeviceIdStore implements DeviceIdStore {
@override
Future<String?> read() async => '0123456789abcdef0123456789abcdef';
@override
Future<void> write(String value) async {}
}
void main() {
late FakeSessionStore session;
late TaskStore store;
setUp(() {
session = FakeSessionStore();
store = TaskStore.memory();
});
tearDown(() => store.close());
ProviderContainer pumpContainer() {
final container = ProviderContainer(
overrides: [
sessionStoreProvider.overrideWithValue(session),
taskStoreProvider.overrideWithValue(store),
deviceIdProvider
.overrideWith((ref) => DeviceId(store: _FixedDeviceIdStore())),
apiClientProvider.overrideWith(
(ref) => ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = FakeAdapter(
(_) => throw StateError('空库场景不应发起请求'),
),
),
),
],
);
addTearDown(container.dispose);
return container;
}
/// 读一次 scope Provider 使绑定生效,并等会话账号 future 落定。
Future<String?> settleScope(ProviderContainer container) async {
final account = await container.read(sessionAccountProvider.future);
// 触发 scope Provider 重建(引擎 start/stop 发生在 watch 传播中)。
container.read(uploadEngineScopeProvider);
await Future<void>.delayed(Duration.zero);
return account;
}
test('登录(会话在场)→ 引擎 start(isRunning,scope=账号)', () async {
session.session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
final container = pumpContainer();
final account = await settleScope(container);
final engine = container.read(uploadEngineProvider);
expect(account, 'alice');
expect(container.read(uploadEngineScopeProvider), 'alice');
expect(engine.isRunning, isTrue, reason: '登录后引擎应周期出队');
});
test('登出 → 引擎 stop,任务行保留库内', () async {
session.session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
final container = pumpContainer();
await settleScope(container);
final engine = container.read(uploadEngineProvider);
expect(engine.isRunning, isTrue);
// 种子一条任务(登出后必须保留——账号切换安全由 accountId 硬隔离保障)。
final task = await store.create(UploadTaskDraft(
id: 'task-keep',
accountId: 'alice',
albumId: 'alb-1',
clientTaskId: 'ct-keep',
sourceFilePath: '/tmp/keep.jpg',
filename: 'keep.jpg',
bytes: 16,
contentType: 'image/jpeg',
));
expect(task.state, UploadTaskState.preparing);
session.session = null;
await container.read(sessionAccountProvider.notifier).refresh();
await Future<void>.delayed(Duration.zero);
expect(engine.isRunning, isFalse, reason: '登出后引擎必须停止出队');
expect(container.read(uploadEngineScopeProvider), isNull);
expect(
await store.get('alice', 'task-keep'),
isNotNull,
reason: '登出不清任务库',
);
});
test('账号切换 alice → bob → 引擎随新账号重启', () async {
session.session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
final container = pumpContainer();
await settleScope(container);
expect(container.read(uploadEngineScopeProvider), 'alice');
session.session = Session(
token: 'tok-2',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'bob',
);
await container.read(sessionAccountProvider.notifier).refresh();
await Future<void>.delayed(Duration.zero);
final engine = container.read(uploadEngineProvider);
expect(container.read(uploadEngineScopeProvider), 'bob');
expect(engine.isRunning, isTrue, reason: '切换账号 = 新 scope 继续出队');
});
test('未登录启动 App → 引擎不启动', () async {
final container = pumpContainer();
await settleScope(container);
expect(container.read(uploadEngineScopeProvider), isNull);
expect(container.read(uploadEngineProvider).isRunning, isFalse);
});
}
@@ -0,0 +1,265 @@
/// 队列筛选纯逻辑测试(P3 计划 Task 9,需求 4.3 三维筛选)。
///
/// 覆盖:状态分组映射、时间快捷 cutoff、[QueueFilter.matches] 全矩阵
/// (相册 × 状态 × 时间 + 时区归一边界)、计数汇总与 copyWith。
/// 任务行经内存 TaskStore 种子(真实迁移链,无需 widget。
library;
import 'package:flutter_test/flutter_test.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';
void main() {
late TaskStore store;
var seq = 0;
setUp(() {
store = TaskStore.memory();
seq = 0;
});
tearDown(() => store.close());
/// 种子一条指定状态/相册/创建时间的任务(真实迁移链抵达目标状态)。
Future<UploadTask> seedTask({
String accountId = 'alice',
String albumId = 'alb-1',
UploadTaskState state = UploadTaskState.queued,
DateTime? createdAt,
}) async {
final id = 'task-${seq++}';
final draft = UploadTaskDraft(
id: id,
accountId: accountId,
albumId: albumId,
clientTaskId: 'ct-$id',
sourceFilePath: '/tmp/$id.jpg',
filename: '$id.jpg',
bytes: 16,
contentType: 'image/jpeg',
);
var task = await store.create(draft, now: createdAt);
// 迁移链:preparing → 目标态(按状态机合法路径逐步推进)。
switch (state) {
case UploadTaskState.preparing:
break;
case UploadTaskState.preparationFailed:
task = await store.updateState(task.id, state, now: createdAt);
case UploadTaskState.queued:
case UploadTaskState.cancelled:
task = await store.updateState(task.id, state, now: createdAt);
case UploadTaskState.waitingNetwork:
task = await store.updateState(
task.id,
UploadTaskState.queued,
now: createdAt,
);
task = await store.updateState(task.id, state, now: createdAt);
case UploadTaskState.uploading:
case UploadTaskState.retryableFailed:
task = await store.updateState(
task.id,
UploadTaskState.queued,
now: createdAt,
);
task = await store.updateState(
task.id,
UploadTaskState.uploading,
now: createdAt,
);
if (state == UploadTaskState.retryableFailed) {
task = await store.updateState(task.id, state, now: createdAt);
}
case UploadTaskState.uploadedConfirming:
case UploadTaskState.completed:
task = await store.updateState(
task.id,
UploadTaskState.queued,
now: createdAt,
);
task = await store.updateState(
task.id,
UploadTaskState.uploading,
now: createdAt,
);
task = await store.updateState(
task.id,
UploadTaskState.uploadedConfirming,
now: createdAt,
);
if (state == UploadTaskState.completed) {
task = await store.updateState(task.id, state, now: createdAt);
}
}
return task;
}
group('QueueStateFilter.states 分组', () {
test('all → 空集(不过滤)', () {
expect(QueueStateFilter.all.states, isEmpty);
});
test('failed 组 = 准备失败 + 上传失败(不含等待网络)', () {
expect(QueueStateFilter.failed.states, {
UploadTaskState.preparationFailed,
UploadTaskState.retryableFailed,
});
});
test('uploading 组 = 上传中 + 确认中;completed 组不含 cancelled', () {
expect(QueueStateFilter.uploading.states, {
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
});
expect(QueueStateFilter.completed.states,
{UploadTaskState.completed});
});
});
group('QueueTimeFilter.since', () {
final now = DateTime(2026, 9, 19, 15, 30);
test('all → null(不过滤)', () {
expect(QueueTimeFilter.all.since(now), isNull);
});
test('today → 本地当日零点;7/30 天 → 对应回溯', () {
expect(QueueTimeFilter.today.since(now), DateTime(2026, 9, 19));
expect(
QueueTimeFilter.last7Days.since(now),
DateTime(2026, 9, 12, 15, 30),
);
expect(
QueueTimeFilter.last30Days.since(now),
DateTime(2026, 8, 20, 15, 30),
);
});
});
group('QueueFilter.matches 矩阵', () {
final now = DateTime(2026, 9, 19, 15, 30);
test('相册维度:命中与不命中', () async {
final inAlbum =
await seedTask(albumId: 'alb-1', createdAt: now);
final other =
await seedTask(albumId: 'alb-2', createdAt: now);
final filter =
const QueueFilter(albumId: 'alb-1');
expect(filter.matches(inAlbum, now: now), isTrue);
expect(filter.matches(other, now: now), isFalse);
});
test('状态维度:failed 组命中组内两态、排除组外', () async {
final prep = await seedTask(
state: UploadTaskState.preparationFailed,
createdAt: now,
);
final upload = await seedTask(
state: UploadTaskState.retryableFailed,
createdAt: now,
);
final waiting = await seedTask(
state: UploadTaskState.waitingNetwork,
createdAt: now,
);
final filter = const QueueFilter(
state: QueueStateFilter.failed,
);
expect(filter.matches(prep, now: now), isTrue);
expect(filter.matches(upload, now: now), isTrue);
expect(filter.matches(waiting, now: now), isFalse);
});
test('时间维度:cutoff 恰好含、早一毫秒不含(本地→UTC 归一)',
() async {
final edge = await seedTask(
createdAt: DateTime(2026, 9, 19, 0, 0), // = today cutoff
);
final before = await seedTask(
createdAt:
DateTime(2026, 9, 18, 23, 59, 59, 999),
);
final filter = const QueueFilter(
time: QueueTimeFilter.today,
);
expect(filter.matches(edge, now: now), isTrue);
expect(filter.matches(before, now: now), isFalse);
});
test('today 匹配 UTC 落库 createdAt(跨时区比较归一)', () async {
// UTC 落库时刻(本地 9/19 当日内)——create 落库即 UTC。
final task = await seedTask(
createdAt: DateTime.utc(2026, 9, 19, 2, 30),
);
const filter =
QueueFilter(time: QueueTimeFilter.today);
expect(filter.matches(task, now: now), isTrue);
});
test('三维叠加:全部命中才匹配', () async {
final hit = await seedTask(
albumId: 'alb-1',
state: UploadTaskState.retryableFailed,
createdAt: DateTime(2026, 9, 19, 10, 0),
);
final wrongAlbum = await seedTask(
albumId: 'alb-2',
state: UploadTaskState.retryableFailed,
createdAt: DateTime(2026, 9, 19, 10, 0),
);
final wrongState = await seedTask(
albumId: 'alb-1',
state: UploadTaskState.queued,
createdAt: DateTime(2026, 9, 19, 10, 0),
);
final tooOld = await seedTask(
albumId: 'alb-1',
state: UploadTaskState.retryableFailed,
createdAt: DateTime(2026, 8, 1),
);
const filter = QueueFilter(
albumId: 'alb-1',
state: QueueStateFilter.failed,
time: QueueTimeFilter.last30Days,
);
expect(filter.matches(hit, now: now), isTrue);
expect(filter.matches(wrongAlbum, now: now), isFalse);
expect(filter.matches(wrongState, now: now), isFalse);
expect(filter.matches(tooOld, now: now), isFalse);
});
});
group('QueueFilter.copyWith / 计数汇总', () {
test('copyWith 覆盖与 clearAlbum 显式清空', () {
const base = QueueFilter(albumId: 'alb-1');
expect(base.copyWith(albumId: 'alb-2').albumId, 'alb-2');
expect(base.copyWith(clearAlbum: true).albumId, isNull);
// 不传时保持。
expect(base.copyWith().albumId, 'alb-1');
});
test('pendingCountOf/failedCountOf 汇总', () {
final counts = <UploadTaskState, int>{
for (final s in UploadTaskState.values) s: 0,
}
..[UploadTaskState.preparing] = 1
..[UploadTaskState.queued] = 2
..[UploadTaskState.uploading] = 3
..[UploadTaskState.uploadedConfirming] = 4
..[UploadTaskState.waitingNetwork] = 5
..[UploadTaskState.preparationFailed] = 6
..[UploadTaskState.retryableFailed] = 7
..[UploadTaskState.completed] = 8
..[UploadTaskState.cancelled] = 9;
expect(pendingCountOf(counts), 15);
expect(failedCountOf(counts), 13);
});
});
}
+30
View File
@@ -9,7 +9,11 @@ import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_store.dart';
/// 内存会话存储(测试替身)。
class FakeSessionStore implements SessionStore {
@@ -25,6 +29,32 @@ class FakeSessionStore implements SessionStore {
Future<void> clear() async => session = null;
}
/// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达
/// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage
/// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需
/// 注入替身)。
///
/// 返回 [FakeSessionStore] + 内存 [TaskStore] + 可选 FakeAdapter ApiClient
/// 的 override 列表。Riverpod 3 已不公开 `Override` 类型(无法作签名注解),
/// 故元素类型为 dynamic——调用点以 spread 展开进 ProviderScope.overrides
/// (元素真实类型即 Override,展开本身类型安全):
/// `ProviderScope(overrides: [...pageTestOverrides(...), 其他], ...)`。
List<dynamic> pageTestOverrides({
FakeSessionStore? session,
TaskStore? taskStore,
ApiClient? client,
}) {
// 每个测试各自 new 一块内存库(独立 QueryExecutor,随容器销毁),
// drift 的“多实例同执行器”告警在此是误报——测试输出降噪。
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final store = taskStore ?? TaskStore.memory();
return <dynamic>[
if (session != null) sessionStoreProvider.overrideWithValue(session),
taskStoreProvider.overrideWithValue(store),
if (client != null) apiClientProvider.overrideWith((ref) => client),
];
}
/// 记录到适配器的请求快照。
class RecordedRequest {
const RecordedRequest({
@@ -4,16 +4,31 @@
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
library;
import 'dart:io';
import 'package:camera/camera.dart' show XFile;
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/notification_permission.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/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_detail_page.dart';
import 'package:photo_live/pages/capture_page.dart';
import '../helpers/fakes.dart';
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
void main() {
late FakeSessionStore store;
late List<Map<String, dynamic>> categories;
@@ -55,7 +70,11 @@ void main() {
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
// 真实平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: client),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
@@ -79,7 +98,8 @@ void main() {
);
expect(find.text('sharetoken000000000001'), findsOneWidget);
expect(find.text('大宴会厅'), findsOneWidget);
expect(find.text('Upload'), findsOneWidget);
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
expect(find.text('Upload'), findsNWidgets(2));
expect(find.text('Original'), findsOneWidget);
// 动作行(5 项)。
expect(find.text('Edit'), findsOneWidget);
@@ -266,4 +286,131 @@ void main() {
expect(find.text('仪式 · Ceremony'), findsNothing);
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
});
group('上传入口 FAB(Task 9)', () {
late Directory tmp;
late List<String> galleryPaths;
late TaskStore taskStore;
setUp(() async {
store.session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
tmp = await Directory.systemTemp.createTemp('detail-upload-');
galleryPaths = <String>[];
taskStore = TaskStore.memory();
});
tearDown(() async {
await taskStore.close();
await tmp.delete(recursive: true);
});
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
/// 非平台相机源)。
Future<void> pumpDetailForUpload(WidgetTester tester) async {
final adapter = detailAdapter();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: store,
taskStore: taskStore,
client: clientOf(adapter),
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
taskStore,
baseDirProvider: () async => tmp,
),
),
notificationPermissionProvider.overrideWith(
(ref) => NotificationPermission(isAndroid: false),
),
captureSourceFactoryProvider
.overrideWith((ref) => () async => _StaticCaptureSource()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumDetailPage(albumId: 'alb-1'),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
await pumpDetailForUpload(tester);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.text('Capture and upload'), findsOneWidget);
expect(find.text('Pick from gallery'), findsOneWidget);
});
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
await pumpDetailForUpload(tester);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await tester.tap(find.text('Capture and upload'));
await tester.pumpAndSettle();
expect(find.byType(CapturePage), findsOneWidget);
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
expect(
find.descendant(
of: find.byType(CapturePage),
matching: find.text('开幕晚宴'),
),
findsOneWidget,
);
});
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpDetailForUpload(tester);
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
await tester.runAsync(() async {
(tile.evaluate().single.widget as ListTile).onTap!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(
find.text('Added 2 photo(s) to the upload queue'),
findsOneWidget,
);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(2));
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
});
});
}
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
class _StaticCaptureSource implements CaptureSource {
@override
Future<void> initialize() async {}
@override
Future<XFile> capture() => throw UnimplementedError();
@override
Widget buildPreview() => const SizedBox();
@override
Future<void> dispose() async {}
}
@@ -0,0 +1,336 @@
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
///
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
/// 权限拒绝/初始化失败视图与重试。
library;
import 'dart:io';
import 'package:camera/camera.dart' show CameraException, XFile;
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/notification_permission.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/albums/models.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/l10n/app_localizations.dart';
import 'package:photo_live/pages/capture_page.dart';
import '../helpers/fakes.dart';
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
class FakeCaptureSource implements CaptureSource {
FakeCaptureSource(this._files);
final List<File> _files;
int initializeCalls = 0;
int failNextInits = 0;
Object? initError;
@override
Future<void> initialize() async {
initializeCalls++;
if (failNextInits > 0) {
failNextInits--;
throw initError ?? StateError('init failed');
}
}
@override
Future<XFile> capture() async => XFile(_files.removeAt(0).path);
@override
Widget buildPreview() => const ColoredBox(
color: Color(0xFF101010),
child: Center(child: Text('PREVIEW_MARK')),
);
@override
Future<void> dispose() async {}
}
void main() {
late FakeSessionStore session;
late TaskStore store;
late Directory tmp;
late FakeCaptureSource source;
late List<String> galleryPaths;
final categories = <Category>[
const Category(
id: 'cat-1',
albumId: 'alb-1',
sortOrder: 0,
translations: [
CategoryTranslation(locale: 'zh-CN', name: '仪式'),
CategoryTranslation(locale: 'en', name: 'Ceremony'),
],
),
const Category(
id: 'cat-2',
albumId: 'alb-1',
sortOrder: 1,
translations: [
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
CategoryTranslation(locale: 'en', name: 'Gala'),
],
),
];
setUp(() async {
session = FakeSessionStore()
..session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
store = TaskStore.memory();
tmp = await Directory.systemTemp.createTemp('capture-test-');
final shot1 = File('${tmp.path}/IMG_0001.jpg')..writeAsBytesSync(kJpgBytes);
final shot2 = File('${tmp.path}/IMG_0002.jpg')..writeAsBytesSync(kJpgBytes);
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
source = FakeCaptureSource([shot1, shot2, shot3]);
galleryPaths = <String>[];
});
tearDown(() async {
await store.close();
await tmp.delete(recursive: true);
});
CaptureTarget target() => CaptureTarget(
accountId: 'alice',
albumId: 'alb-1',
albumTitle: 'Opening Gala',
categories: categories,
);
Future<void> pumpCapture(WidgetTester tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: session,
taskStore: store,
client: ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = FakeAdapter(
(_) => throw StateError('连拍页不应发起 API 请求')),
),
),
captureSourceFactoryProvider
.overrideWith((ref) => () async => source),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
store,
baseDirProvider: () async => tmp,
),
),
notificationPermissionProvider.overrideWith(
(ref) => NotificationPermission(isAndroid: false),
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: CapturePage(target: target()),
),
),
);
// 相机初始化完成:帧级 pump 直至预览出现(首个测试字体/主题冷启动
// 较慢,固定双 pump 不够稳)。
await tester.pump();
var guard = 0;
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
await tester.pump(const Duration(milliseconds: 50));
}
}
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
Future<void> pressRealIo(WidgetTester tester, IconData icon) async {
final button = find
.ancestor(of: find.byIcon(icon), matching: find.byType(IconButton))
.evaluate()
.single
.widget as IconButton;
await tester.runAsync(() async {
button.onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump(kQueueRefreshDebounce);
await tester.pump(const Duration(milliseconds: 60));
}
/// 快门(即拍即入队链路经 [pressRealIo])。
Future<void> shoot(WidgetTester tester, {int times = 1}) async {
for (var i = 0; i < times; i++) {
await pressRealIo(tester, Icons.camera);
}
}
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
await pumpCapture(tester);
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
expect(find.text('Pending 0'), findsOneWidget);
await shoot(tester);
final tasks = await store.listByAccount('alice');
expect(tasks, hasLength(1));
expect(tasks.single.state, UploadTaskState.queued);
expect(tasks.single.accountId, 'alice');
expect(tasks.single.albumId, 'alb-1');
expect(tasks.single.categoryId, isNull, reason: '默认分类');
expect(tasks.single.filename, 'IMG_0001.jpg');
expect(find.text('Pending 1'), findsOneWidget, reason: '徽章实时刷新');
});
testWidgets('连拍两张串行入队(不逐张确认)', (tester) async {
await pumpCapture(tester);
await shoot(tester, times: 2);
final tasks = await store.listByAccount('alice');
expect(tasks.map((t) => t.filename), unorderedEquals(<String>[
'IMG_0001.jpg',
'IMG_0002.jpg',
]));
expect(find.text('Pending 2'), findsOneWidget);
});
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
(tester) async {
await pumpCapture(tester);
await shoot(tester); // 第一张:默认分类。
await tester.tap(find.text('Gala'));
await tester.pump();
expect(
find.text('Switching only affects photos captured afterwards'),
findsOneWidget,
reason: '切换归属不变提示',
);
await tester.pumpAndSettle(); // snackbar 退场。
await shoot(tester); // 第二张:新分类。
final tasks = await store.listByAccount('alice');
final first =
tasks.firstWhere((t) => t.filename == 'IMG_0001.jpg');
final second =
tasks.firstWhere((t) => t.filename == 'IMG_0002.jpg');
expect(first.categoryId, isNull, reason: '既有任务归属不变');
expect(second.categoryId, 'cat-2', reason: '切换后拍摄走新分类');
});
testWidgets('相册多选补充入队 → 入队数提示', (tester) async {
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpCapture(tester);
// 多选入队走真实文件 IO(同快门路径)。
await pressRealIo(tester, Icons.photo_library_outlined);
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
expect(await store.listByAccount('alice'), hasLength(2));
});
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
(tester) async {
await pumpCapture(tester);
expect(find.byType(CapturePage), findsOneWidget);
await shoot(tester);
// home 路由无返回按钮——经 Navigator.maybePop 驱动系统返回路径
//(PopScope.canPop=false → onPopInvokedWithResult 弹确认)。
Future<void> requestBack() async {
final navigator = tester.state<NavigatorState>(
find.byType(Navigator).first,
);
await navigator.maybePop();
await tester.pumpAndSettle();
}
// 有待传 → 弹确认。
await requestBack();
expect(find.text('Leave shooting?'), findsOneWidget);
expect(
find.textContaining('still uploading'),
findsOneWidget,
reason: '正文含待传数与后台继续说明',
);
// 保留继续拍 → 不退出。
await tester.tap(find.text('Keep shooting'));
await tester.pumpAndSettle();
expect(find.byType(CapturePage), findsOneWidget);
// 再次退出 → 确认离开 → 出栈。
await requestBack();
await tester.tap(find.text('Leave'));
await tester.pumpAndSettle();
expect(find.byType(CapturePage), findsNothing);
});
testWidgets('权限拒绝视图:文案 + 重试入口', (tester) async {
source.initError = CameraException('CameraAccessDenied', 'denied');
source.failNextInits = 1;
await pumpCapture(tester);
expect(
find.text(
'Camera unavailable. Allow camera access in system settings'),
findsOneWidget,
);
// 重试成功 → 预览就绪。
await tester.tap(find.text('Retry'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('PREVIEW_MARK'), findsOneWidget);
});
testWidgets('初始化失败视图:非权限异常文案 + 重试恢复', (tester) async {
source.failNextInits = 1;
source.initError = StateError('camera broken');
await pumpCapture(tester);
expect(find.text('Camera failed to initialize'), findsOneWidget);
await tester.tap(find.text('Retry'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('PREVIEW_MARK'), findsOneWidget);
});
testWidgets('常驻信息层:相册名 + 分类候选', (tester) async {
await pumpCapture(tester);
expect(find.text('Opening Gala'), findsOneWidget);
expect(find.text('Default category'), findsOneWidget);
expect(find.text('Ceremony'), findsOneWidget);
expect(find.text('Gala'), findsOneWidget);
});
}
+5 -1
View File
@@ -37,7 +37,11 @@ void main() {
Future<void> pumpLoginPage(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
// Task 9 起登录成功会刷新 sessionAccountProvider——真实
// flutter_secure_storage 平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: client),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
+546
View File
@@ -0,0 +1,546 @@
/// 上传队列页 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<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
class _FixedDeviceIdStore implements DeviceIdStore {
@override
Future<String?> read() async => '0123456789abcdef0123456789abcdef';
@override
Future<void> 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<void> retryNow(String accountId, String taskId) async =>
retryCalls.add((accountId, taskId));
@override
Future<bool> 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<String> 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(<Map<String, dynamic>>[
albumJson(id: 'alb-1'),
albumJson(
id: 'alb-2',
translations: <Map<String, dynamic>>[
<String, dynamic>{
'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 = <String>[];
});
tearDown(() async {
await store.close();
await tmp.delete(recursive: true);
});
Future<void> 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<UploadTask> 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<UploadTask> 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<void> 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<void>.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%');
});
}
@@ -38,7 +38,11 @@ void main() {
Future<void> pumpHome(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
// Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore),
// 真实平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: client),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,