ab51616c70
Co-Authored-By: Claude Code <noreply@anthropic.com>
777 lines
27 KiB
Dart
777 lines
27 KiB
Dart
/// 上传中心 widget 测试(UI-03 页面 14/15)。
|
||
///
|
||
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
|
||
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
|
||
/// 覆盖:页头(Uploads 标题 + History 入口)、摘要卡(范围标注 + 四组
|
||
/// 任务数,突出已完成)、主段筛选(All/Active/Needs attention)、次级
|
||
/// 筛选菜单(相册/时间/已完成)、任务行渲染(状态徽章/状态专属文案/
|
||
/// 错误文案/相册名)、按状态动作(重试/重新选择/取消/移除记录 + 确认
|
||
/// 弹窗)、异常视图(仅失败行 + 说明行 + 摘要全量口径 + 专用空态)、
|
||
/// 批量重试条口径(仅可重试失败)、写通知防抖实时刷新、行级进度条
|
||
/// (分数 + 百分比文案 + 未订阅行不重建)。
|
||
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 {
|
||
// 加高视口:摘要卡 + 筛选栏 + 批量条压缩列表区,默认 800x600 下
|
||
// 第二行落在懒构建区外(ListView.builder 不构建不可见项)。
|
||
tester.view.physicalSize = const Size(800, 1600);
|
||
tester.view.devicePixelRatio = 1;
|
||
addTearDown(tester.view.reset);
|
||
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: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
// History 入口跳转断言用占位路由。
|
||
routes: <String, WidgetBuilder>{
|
||
'/server-history': (context) =>
|
||
const Scaffold(body: Center(child: Text('SERVER_HISTORY_MARK'))),
|
||
},
|
||
home: const 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;
|
||
}
|
||
|
||
/// 行级锚定:任务行 Padding 带 ValueKey('queue-task-{task.id}')。
|
||
Finder rowOf(String filename) =>
|
||
find.byKey(ValueKey('queue-task-task-$filename'));
|
||
|
||
/// 等防抖重查落定(写通知 → 200ms Timer → 异步查询;不 pumpAndSettle——
|
||
/// uploading 行的不确定态进度条动画不会停)。
|
||
Future<void> settleDebounce(WidgetTester tester) async {
|
||
await tester.pump(kQueueRefreshDebounce);
|
||
await tester.pump(const Duration(milliseconds: 120));
|
||
}
|
||
|
||
/// 打开次级筛选菜单(相册 × 时间 × 已完成)。
|
||
Future<void> openFilterSheet(WidgetTester tester) async {
|
||
await tester.tap(find.byIcon(Icons.tune));
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||
Future<void> pressButtonRealIo(WidgetTester tester, Finder button) async {
|
||
final widget = button.evaluate().single.widget;
|
||
final VoidCallback onPressed;
|
||
if (widget is IconButton) {
|
||
onPressed = widget.onPressed!;
|
||
} else if (widget is TextButton) {
|
||
onPressed = widget.onPressed!;
|
||
} else {
|
||
throw StateError('unsupported button type: $widget');
|
||
}
|
||
await tester.runAsync(() async {
|
||
onPressed();
|
||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||
});
|
||
await tester.pump(kQueueRefreshDebounce);
|
||
await tester.pump(const Duration(milliseconds: 60));
|
||
}
|
||
|
||
testWidgets('页头:Uploads 标题 + History 入口跳服务端历史页',
|
||
(tester) async {
|
||
await pumpQueue(tester);
|
||
expect(find.text('Uploads'), findsOneWidget);
|
||
expect(find.text('History'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('History'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('SERVER_HISTORY_MARK'), findsOneWidget,
|
||
reason: 'History 入口 push /server-history(与本机队列数据源区分)');
|
||
});
|
||
|
||
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(突出已完成)',
|
||
(tester) async {
|
||
await seed('done1.jpg', state: UploadTaskState.completed);
|
||
await seed('done2.jpg', state: UploadTaskState.completed);
|
||
await seed('up.jpg', state: UploadTaskState.uploading);
|
||
await seed('q1.jpg');
|
||
await seed('q2.jpg');
|
||
await seed(
|
||
'bad.jpg',
|
||
state: UploadTaskState.retryableFailed,
|
||
lastError: 'NETWORK_ERROR',
|
||
);
|
||
await pumpQueue(tester);
|
||
|
||
// 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。
|
||
expect(find.text('Any time · This device'), findsOneWidget);
|
||
|
||
// 四组计数:摘要标签小写、主段大写——find.text 区分大小写不冲突。
|
||
void expectSummary(String label, String value) {
|
||
final row = find.ancestor(
|
||
of: find.text(label),
|
||
matching: find.byType(Row),
|
||
);
|
||
expect(
|
||
find.descendant(of: row, matching: find.text(value)),
|
||
findsOneWidget,
|
||
reason: '摘要「$label」= $value',
|
||
);
|
||
}
|
||
|
||
expectSummary('completed', '2');
|
||
expectSummary('uploading', '1');
|
||
expectSummary('waiting', '2');
|
||
expectSummary('need attention', '1');
|
||
});
|
||
|
||
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;断言锚定在行内——主段文案与部分徽章同名)。
|
||
for (final (filename, badge) in [
|
||
('prep.jpg', 'Preparing photo'),
|
||
('ok.jpg', 'Completed'),
|
||
('net.jpg', 'Waiting for network'),
|
||
('alb2.jpg', 'Uploading'),
|
||
]) {
|
||
expect(
|
||
find.descendant(of: rowOf(filename), matching: find.text(badge)),
|
||
findsOneWidget,
|
||
reason: '$filename 行徽章/状态文案 $badge',
|
||
);
|
||
}
|
||
// 文件名。
|
||
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);
|
||
|
||
// §4.4:失败行显示本地化原因(独立错误行),状态行附尝试次数。
|
||
expect(find.text('Photo exceeds the 50MB limit'), findsOneWidget);
|
||
expect(find.text('Network error. Check your connection and retry'),
|
||
findsOneWidget);
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('flaky.jpg'),
|
||
matching: find.text('Upload failed'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('flaky.jpg'),
|
||
matching: find.text('2 attempt(s)'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('等待网络行:徽章 + 自动恢复提示 + 保留手动重试', (tester) async {
|
||
await seed(
|
||
'net.jpg',
|
||
state: UploadTaskState.waitingNetwork,
|
||
lastError: 'NETWORK_ERROR',
|
||
);
|
||
await pumpQueue(tester);
|
||
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('net.jpg'),
|
||
matching: find.text('Waiting for network'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
// §4.4:等待网络附自动恢复说明(用户无需手动干预)。
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('net.jpg'),
|
||
matching: find.text('Resumes automatically when the network recovers'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
// §4.4「保留已有重试」:等待网络行仍有刷新入口。
|
||
expect(
|
||
find.descendant(of: rowOf('net.jpg'), matching: find.byIcon(Icons.refresh)),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('主段筛选 Needs attention → 仅失败行 + 说明行 + 摘要仍全量(页面 15)',
|
||
(tester) async {
|
||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||
await seed('good.jpg', state: UploadTaskState.completed);
|
||
await pumpQueue(tester);
|
||
|
||
await tester.tap(find.text('Needs attention'));
|
||
await settleDebounce(tester);
|
||
|
||
// 页面 15:异常视图只列失败任务,附「正在显示需关注任务」说明行。
|
||
expect(find.text('bad.jpg'), findsOneWidget);
|
||
expect(find.text('good.jpg'), findsNothing);
|
||
expect(
|
||
find.text('Showing tasks that need your attention'),
|
||
findsOneWidget,
|
||
);
|
||
// 摘要不随状态筛选变口径:completed 1 / need attention 1 仍可见。
|
||
expect(find.text('Any time · This device'), findsOneWidget);
|
||
void expectSummary(String label, String value) {
|
||
final row = find.ancestor(
|
||
of: find.text(label),
|
||
matching: find.byType(Row),
|
||
);
|
||
expect(find.descendant(of: row, matching: find.text(value)),
|
||
findsOneWidget);
|
||
}
|
||
expectSummary('completed', '1');
|
||
expectSummary('need attention', '1');
|
||
});
|
||
|
||
testWidgets('异常视图空态:无失败任务 → 专用空文案(页面 15)',
|
||
(tester) async {
|
||
await seed('good.jpg', state: UploadTaskState.completed);
|
||
await pumpQueue(tester);
|
||
|
||
await tester.tap(find.text('Needs attention'));
|
||
await settleDebounce(tester);
|
||
|
||
expect(find.text('No tasks need attention'), findsOneWidget);
|
||
expect(find.text('good.jpg'), findsNothing, reason: '非失败任务不显示');
|
||
});
|
||
|
||
testWidgets('主段筛选 Active → 活任务(含等待网络)', (tester) async {
|
||
await seed('live.jpg');
|
||
await seed('net.jpg', state: UploadTaskState.waitingNetwork);
|
||
await seed('done.jpg', state: UploadTaskState.completed);
|
||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||
await pumpQueue(tester);
|
||
|
||
await tester.tap(find.text('Active'));
|
||
await settleDebounce(tester);
|
||
|
||
// 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。
|
||
expect(find.text('live.jpg'), findsOneWidget);
|
||
expect(find.text('net.jpg'), findsOneWidget);
|
||
expect(find.text('done.jpg'), findsNothing);
|
||
expect(find.text('bad.jpg'), findsNothing);
|
||
});
|
||
|
||
testWidgets('次级筛选菜单:Completed → 仅已完成行(主三段不亮)',
|
||
(tester) async {
|
||
await seed('done.jpg', state: UploadTaskState.completed);
|
||
await seed('gone.jpg', state: UploadTaskState.cancelled);
|
||
await seed('q.jpg');
|
||
await pumpQueue(tester);
|
||
|
||
await openFilterSheet(tester);
|
||
// 行内徽章同名「Completed」——锚定筛选面板的 ListTile。
|
||
await tester.tap(find.widgetWithText(ListTile, 'Completed'));
|
||
await tester.pumpAndSettle();
|
||
await settleDebounce(tester);
|
||
|
||
// §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。
|
||
expect(find.text('done.jpg'), findsOneWidget);
|
||
expect(find.text('gone.jpg'), findsNothing);
|
||
expect(find.text('q.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 openFilterSheet(tester);
|
||
await tester.tap(find.widgetWithText(RadioListTile<QueueTimeFilter>, 'Today'));
|
||
await tester.pumpAndSettle();
|
||
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 openFilterSheet(tester);
|
||
// 任务行副标题同名相册名——锚定筛选面板的单选行。
|
||
await tester.tap(
|
||
find.widgetWithText(RadioListTile<String>, 'Wedding Day'));
|
||
await tester.pumpAndSettle();
|
||
await settleDebounce(tester);
|
||
|
||
expect(find.text('two.jpg'), findsOneWidget);
|
||
expect(find.text('one.jpg'), findsNothing);
|
||
// 摘要范围标注随筛选更新:相册名 · 时间 · 本设备。
|
||
expect(find.text('Wedding Day · Any time · This device'), findsOneWidget);
|
||
});
|
||
|
||
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 行无动作(传输中不打断)。
|
||
expect(
|
||
find.descendant(of: rowOf('q.jpg'), matching: find.byIcon(Icons.close)),
|
||
findsOneWidget,
|
||
reason: 'queued 行有取消按钮',
|
||
);
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('done.jpg'),
|
||
matching: find.text('Remove record'),
|
||
),
|
||
findsOneWidget,
|
||
reason: 'completed 行有移除记录按钮',
|
||
);
|
||
expect(
|
||
find.descendant(of: rowOf('up.jpg'), matching: find.byType(IconButton))
|
||
.evaluate(),
|
||
isEmpty,
|
||
reason: 'uploading 行无图标动作(传输中不打断)',
|
||
);
|
||
expect(
|
||
find.descendant(of: rowOf('up.jpg'), matching: find.byType(TextButton))
|
||
.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: rowOf('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: rowOf('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);
|
||
|
||
// §4.4:准备失败行是「重新选择」(image_search 图标),不是刷新重试。
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('broken.jpg'),
|
||
matching: find.text('File unavailable'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
final selectAgain = find.descendant(
|
||
of: rowOf('broken.jpg'),
|
||
matching: find.widgetWithText(TextButton, 'Select again'),
|
||
);
|
||
|
||
// 换源复制是真实文件 IO——runAsync 放行真实事件循环完成。
|
||
await pressButtonRealIo(tester, selectAgain);
|
||
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('移除记录:确认弹窗明确不删云端照片;确认后行消失 + 库内清除',
|
||
(tester) async {
|
||
final task = await seed('done.jpg', state: UploadTaskState.completed);
|
||
await pumpQueue(tester);
|
||
|
||
await tester.tap(find.descendant(
|
||
of: rowOf('done.jpg'),
|
||
matching: find.text('Remove record'),
|
||
));
|
||
await tester.pumpAndSettle();
|
||
|
||
// §4.4:确认弹窗明确「仅清本机记录,不删云端照片」。
|
||
expect(find.text('Remove this record?'), findsOneWidget);
|
||
expect(
|
||
find.textContaining('Photos already uploaded are not deleted'),
|
||
findsOneWidget,
|
||
);
|
||
// 取消 → 记录保留。
|
||
await tester.tap(find.text('Cancel'));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('done.jpg'), findsOneWidget);
|
||
|
||
// 再删 → 确认 → 行消失。
|
||
await tester.tap(find.descendant(
|
||
of: rowOf('done.jpg'),
|
||
matching: find.text('Remove record'),
|
||
));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Remove'));
|
||
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',
|
||
);
|
||
await seed(
|
||
'f2.jpg',
|
||
state: UploadTaskState.waitingNetwork,
|
||
lastError: 'NETWORK_ERROR',
|
||
);
|
||
await seed(
|
||
'f3.jpg',
|
||
state: UploadTaskState.preparationFailed,
|
||
lastError: 'PREP_COPY_FAILED',
|
||
);
|
||
await pumpQueue(tester);
|
||
|
||
// 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败),
|
||
// 按钮文案「重试可恢复项」+ 范围说明(页面 15)。
|
||
expect(find.text('Retry eligible'), findsOneWidget);
|
||
expect(find.text('Needs attention · 2'), findsOneWidget);
|
||
expect(find.textContaining('recover on their own'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Retry eligible'));
|
||
await tester.pumpAndSettle();
|
||
|
||
// §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备
|
||
// 失败需换源重选,均不进入批量重试。
|
||
expect(engine.retryCalls, [('alice', auto1.id)]);
|
||
expect(
|
||
engine.retryCalls.map((c) => c.$2),
|
||
isNot(contains('task-f3.jpg')),
|
||
reason: '准备失败需换源重选,不进批量重试',
|
||
);
|
||
expect(
|
||
engine.retryCalls.map((c) => c.$2),
|
||
isNot(contains('task-f2.jpg')),
|
||
reason: '等待网络自动恢复,不进批量重试',
|
||
);
|
||
});
|
||
|
||
testWidgets('无失败任务 → 无批量条', (tester) async {
|
||
await seed('q.jpg');
|
||
await pumpQueue(tester);
|
||
|
||
expect(find.text('Retry eligible'), 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('行级进度:进度事件只刷新订阅行,未订阅行不整行重建',
|
||
(tester) async {
|
||
final task1 = await seed('up1.jpg', state: UploadTaskState.uploading);
|
||
await seed('up2.jpg', state: UploadTaskState.uploading);
|
||
await pumpQueue(tester);
|
||
|
||
final context = tester.element(find.byType(QueueTab));
|
||
final hub = ProviderScope.containerOf(context)
|
||
.read(uploadProgressHubProvider);
|
||
|
||
LinearProgressIndicator indicatorOf(String filename) => find
|
||
.descendant(
|
||
of: rowOf(filename),
|
||
matching: find.byType(LinearProgressIndicator),
|
||
)
|
||
.evaluate()
|
||
.single
|
||
.widget as LinearProgressIndicator;
|
||
|
||
expect(indicatorOf('up1.jpg').value, isNull, reason: '无事件时不确定态');
|
||
|
||
// 快照另一行的 widget 实例——行级订阅下它不应随 up1 的进度重建。
|
||
final otherBefore = indicatorOf('up2.jpg');
|
||
|
||
hub.emit(task1.id, 50, 100);
|
||
await tester.pump();
|
||
|
||
expect(indicatorOf('up1.jpg').value, 0.5, reason: '进度事件行级刷新为 50%');
|
||
expect(
|
||
find.descendant(
|
||
of: rowOf('up1.jpg'),
|
||
matching: find.text('Uploading 50%'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
expect(
|
||
identical(indicatorOf('up2.jpg'), otherBefore),
|
||
isTrue,
|
||
reason: '未收到进度事件的行不重建(行级进度,不整页刷新)',
|
||
);
|
||
});
|
||
}
|