Files
photo-live/apps/mobile/test/pages/queue_tab_test.dart
T

547 lines
18 KiB
Dart
Raw Normal View History

/// 上传队列页 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%');
});
}