955f9b13c1
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>
547 lines
18 KiB
Dart
547 lines
18 KiB
Dart
/// 上传队列页 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%');
|
||
});
|
||
}
|