feat(mobile): UI-03 capture and upload pages (13-16)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/// 上传中心 widget 测试(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
/// 上传中心 widget 测试(UI-03 页面 14/15)。
|
||||
///
|
||||
/// 打桩面:RecordingEngine(记录 retryNow/cancel,不出队)+ 内存 TaskStore
|
||||
/// 种子 + FakeAdapter 相册列表 + 临时目录准备器(准备失败重试换源)。
|
||||
/// 覆盖:顶部摘要卡(范围标注 + 四组任务数)、四组状态筛选 chips、
|
||||
/// 任务行渲染(状态徽章/状态专属文案/错误文案/相册名)、按状态动作
|
||||
/// (重试/重新选择/取消/移除记录 + 确认弹窗)、批量重试条口径(仅
|
||||
/// 可重试失败)、写通知防抖实时刷新、行级进度条(分数 + 百分比文案)。
|
||||
/// 覆盖:页头(Uploads 标题 + History 入口)、摘要卡(范围标注 + 四组
|
||||
/// 任务数,突出已完成)、主段筛选(All/Active/Needs attention)、次级
|
||||
/// 筛选菜单(相册/时间/已完成)、任务行渲染(状态徽章/状态专属文案/
|
||||
/// 错误文案/相册名)、按状态动作(重试/重新选择/取消/移除记录 + 确认
|
||||
/// 弹窗)、异常视图(仅失败行 + 说明行 + 摘要全量口径 + 专用空态)、
|
||||
/// 批量重试条口径(仅可重试失败)、写通知防抖实时刷新、行级进度条
|
||||
/// (分数 + 百分比文案 + 未订阅行不重建)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -148,10 +151,15 @@ void main() {
|
||||
singleImagePickerProvider
|
||||
.overrideWith((ref) => () async => pickedPaths.removeAt(0)),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: QueueTab(),
|
||||
// History 入口跳转断言用占位路由。
|
||||
routes: <String, WidgetBuilder>{
|
||||
'/server-history': (context) =>
|
||||
const Scaffold(body: Center(child: Text('SERVER_HISTORY_MARK'))),
|
||||
},
|
||||
home: const QueueTab(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -231,6 +239,12 @@ void main() {
|
||||
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 刷新徽章防抖。
|
||||
@@ -252,7 +266,20 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
testWidgets('摘要卡:范围标注(时间·本设备)+ 四组任务数(按任务数口径)',
|
||||
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);
|
||||
@@ -269,7 +296,7 @@ void main() {
|
||||
// 范围标注:未选相册 → 时间范围 · 本设备(跨设备记录在服务端历史页)。
|
||||
expect(find.text('Any time · This device'), findsOneWidget);
|
||||
|
||||
// 四组计数:摘要标签小写、chip 大写——find.text 区分大小写不冲突。
|
||||
// 四组计数:摘要标签小写、主段大写——find.text 区分大小写不冲突。
|
||||
void expectSummary(String label, String value) {
|
||||
final row = find.ancestor(
|
||||
of: find.text(label),
|
||||
@@ -297,7 +324,7 @@ void main() {
|
||||
albumId: 'alb-2', state: UploadTaskState.uploading);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态徽章(en locale;断言锚定在行内——chip 栏文案与部分徽章同名)。
|
||||
// 状态徽章(en locale;断言锚定在行内——主段文案与部分徽章同名)。
|
||||
for (final (filename, badge) in [
|
||||
('prep.jpg', 'Preparing photo'),
|
||||
('ok.jpg', 'Completed'),
|
||||
@@ -384,28 +411,56 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Needs attention chip → 仅失败行', (tester) async {
|
||||
testWidgets('主段筛选 Needs attention → 仅失败行 + 说明行 + 摘要仍全量(页面 15)',
|
||||
(tester) async {
|
||||
await seed('bad.jpg', state: UploadTaskState.preparationFailed);
|
||||
await seed('good.jpg', state: UploadTaskState.completed);
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 状态 chip 锚定 ChoiceChip(行内徽章可能与 chip 同文案)。
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Needs attention'));
|
||||
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('状态筛选:In progress chip → 活任务(含等待网络)',
|
||||
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.widgetWithText(ChoiceChip, 'In progress'));
|
||||
await tester.tap(find.text('Active'));
|
||||
await settleDebounce(tester);
|
||||
|
||||
// 排队 + 等待网络属「进行中」(自动恢复范畴);终态与需关注排除。
|
||||
@@ -415,13 +470,17 @@ void main() {
|
||||
expect(find.text('bad.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('状态筛选:Completed chip → 仅已完成行', (tester) async {
|
||||
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 tester.tap(find.widgetWithText(ChoiceChip, 'Completed'));
|
||||
await openFilterSheet(tester);
|
||||
// 行内徽章同名「Completed」——锚定筛选面板的 ListTile。
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Completed'));
|
||||
await tester.pumpAndSettle();
|
||||
await settleDebounce(tester);
|
||||
|
||||
// §4.4:已完成组不含 cancelled(取消仅在「全部」可见)。
|
||||
@@ -430,7 +489,7 @@ void main() {
|
||||
expect(find.text('q.jpg'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('时间筛选:Today → 昨日任务排除', (tester) async {
|
||||
testWidgets('次级筛选菜单:Today → 昨日任务排除', (tester) async {
|
||||
final now = DateTime.now();
|
||||
await seed('fresh.jpg', createdAt: now);
|
||||
await seed(
|
||||
@@ -440,22 +499,26 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
expect(find.text('old.jpg'), findsOneWidget, reason: '默认全部时间');
|
||||
|
||||
await tester.tap(find.text('Today'));
|
||||
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 → 仅该相册行 + 摘要范围带相册名',
|
||||
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 openFilterSheet(tester);
|
||||
// 任务行副标题同名相册名——锚定筛选面板的单选行。
|
||||
await tester.tap(
|
||||
find.widgetWithText(RadioListTile<String>, 'Wedding Day'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Wedding Day').last);
|
||||
await settleDebounce(tester);
|
||||
|
||||
expect(find.text('two.jpg'), findsOneWidget);
|
||||
@@ -626,13 +689,12 @@ void main() {
|
||||
await pumpQueue(tester);
|
||||
|
||||
// 批量条出现:标题计数与摘要「需关注」同口径(准备失败 + 上传失败),
|
||||
// 范围文案说明仅重试可自动重试的子集。
|
||||
expect(find.text('Retry all failed'), findsOneWidget);
|
||||
// 按钮文案「重试可恢复项」+ 范围说明(页面 15)。
|
||||
expect(find.text('Retry eligible'), findsOneWidget);
|
||||
expect(find.text('Needs attention · 2'), findsOneWidget);
|
||||
expect(find.textContaining('Only failed uploads on this device'),
|
||||
findsOneWidget);
|
||||
expect(find.textContaining('recover on their own'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry all failed'));
|
||||
await tester.tap(find.text('Retry eligible'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// §4.4:一键重试仅作用于 retryableFailed——等待网络自动恢复、准备
|
||||
@@ -654,7 +716,7 @@ void main() {
|
||||
await seed('q.jpg');
|
||||
await pumpQueue(tester);
|
||||
|
||||
expect(find.text('Retry all failed'), findsNothing);
|
||||
expect(find.text('Retry eligible'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('实时刷新:库内新增任务 → 防抖后列表出现(写通知驱动)',
|
||||
@@ -670,37 +732,45 @@ void main() {
|
||||
expect(find.text('second.jpg'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('行级进度:uploading 行订阅进度总线,分数 + 百分比文案更新',
|
||||
testWidgets('行级进度:进度事件只刷新订阅行,未订阅行不整行重建',
|
||||
(tester) async {
|
||||
final task = await seed('up.jpg', state: UploadTaskState.uploading);
|
||||
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() => find
|
||||
LinearProgressIndicator indicatorOf(String filename) => find
|
||||
.descendant(
|
||||
of: rowOf('up.jpg'),
|
||||
of: rowOf(filename),
|
||||
matching: find.byType(LinearProgressIndicator),
|
||||
)
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as LinearProgressIndicator;
|
||||
|
||||
expect(indicatorOf().value, isNull, reason: '无事件时不确定态进度条');
|
||||
expect(indicatorOf('up1.jpg').value, isNull, reason: '无事件时不确定态');
|
||||
|
||||
hub.emit(task.id, 50, 100);
|
||||
// 快照另一行的 widget 实例——行级订阅下它不应随 up1 的进度重建。
|
||||
final otherBefore = indicatorOf('up2.jpg');
|
||||
|
||||
hub.emit(task1.id, 50, 100);
|
||||
await tester.pump();
|
||||
|
||||
expect(indicatorOf().value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
// §4.4:上传中行展示「上传中 {percent}%」文案。
|
||||
expect(indicatorOf('up1.jpg').value, 0.5, reason: '进度事件行级刷新为 50%');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: rowOf('up.jpg'),
|
||||
of: rowOf('up1.jpg'),
|
||||
matching: find.text('Uploading 50%'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
identical(indicatorOf('up2.jpg'), otherBefore),
|
||||
isTrue,
|
||||
reason: '未收到进度事件的行不重建(行级进度,不整页刷新)',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user