feat(mobile): UI-03 capture and upload pages (13-16)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -346,8 +346,10 @@ void main() {
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||||
captureSourceFactoryProvider.overrideWith(
|
||||
(ref) => ({bool preferFront = false}) async =>
|
||||
_StaticCaptureSource(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
@@ -412,6 +414,12 @@ class _StaticCaptureSource implements CaptureSource {
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
bool get supportsFlash => false;
|
||||
|
||||
@override
|
||||
Future<void> setFlashEnabled(bool enabled) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
/// 连拍页 widget 测试(UI-03 页面 13 深色摄影界面;R17 连拍入队 / O01)。
|
||||
///
|
||||
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
|
||||
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
|
||||
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
|
||||
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
|
||||
/// 权限拒绝/初始化失败视图与重试。
|
||||
/// 平台通道;可注入闪光灯能力/记录翻转释放)+ 内存 TaskStore + 临时目录
|
||||
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
|
||||
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
|
||||
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
|
||||
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -26,6 +27,7 @@ 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 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
@@ -33,8 +35,9 @@ const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
|
||||
class FakeCaptureSource implements CaptureSource {
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数;
|
||||
/// 闪光灯能力可注入(记录开关调用);释放计数供前后切换断言。
|
||||
class FakeCaptureSource extends CaptureSource {
|
||||
FakeCaptureSource(this._files);
|
||||
|
||||
final List<File> _files;
|
||||
@@ -42,6 +45,13 @@ class FakeCaptureSource implements CaptureSource {
|
||||
int failNextInits = 0;
|
||||
Object? initError;
|
||||
|
||||
/// 闪光灯能力(默认不支持——UI 不显示开关)。
|
||||
bool flashSupported = false;
|
||||
final List<bool> flashCalls = <bool>[];
|
||||
|
||||
/// dispose 计数(前后摄切换释放断言)。
|
||||
int disposeCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
@@ -61,7 +71,14 @@ class FakeCaptureSource implements CaptureSource {
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
bool get supportsFlash => flashSupported;
|
||||
|
||||
@override
|
||||
Future<void> setFlashEnabled(bool enabled) async =>
|
||||
flashCalls.add(enabled);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async => disposeCalls++;
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -71,6 +88,9 @@ void main() {
|
||||
late FakeCaptureSource source;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
/// 工厂调用记录(前后摄切换断言)。
|
||||
late List<bool> factoryPreferFront;
|
||||
|
||||
final categories = <Category>[
|
||||
const Category(
|
||||
id: 'cat-1',
|
||||
@@ -106,11 +126,18 @@ void main() {
|
||||
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
source = FakeCaptureSource([shot1, shot2, shot3]);
|
||||
galleryPaths = <String>[];
|
||||
factoryPreferFront = <bool>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
try {
|
||||
await tmp.delete(recursive: true);
|
||||
} on Object {
|
||||
// Windows:底部缩略图 FileImage 的异步读取可能仍持有临时文件句柄
|
||||
//(fake-async 区内真实 IO 完成回调不被冲刷)——删除失败不判定
|
||||
// 业务断言失败,残留目录交由系统临时目录清理。
|
||||
}
|
||||
});
|
||||
|
||||
CaptureTarget target() => CaptureTarget(
|
||||
@@ -134,8 +161,12 @@ void main() {
|
||||
(_) => throw StateError('连拍页不应发起 API 请求')),
|
||||
),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => source),
|
||||
captureSourceFactoryProvider.overrideWith(
|
||||
(ref) => ({bool preferFront = false}) async {
|
||||
factoryPreferFront.add(preferFront);
|
||||
return source;
|
||||
},
|
||||
),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
store,
|
||||
@@ -188,10 +219,33 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
|
||||
testWidgets('深色摄影界面:页面背景 #10161E(不随主题)', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(
|
||||
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
|
||||
kColorPhotoBackdrop,
|
||||
reason: '§3 相机界面固定深色背景',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('快门 ≥72dp(§4 页面 13)+ 相机控制齐备', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
final shutterSize = tester.getSize(find.byKey(const ValueKey('capture-shutter')));
|
||||
expect(shutterSize.width, greaterThanOrEqualTo(72));
|
||||
expect(shutterSize.height, greaterThanOrEqualTo(72));
|
||||
|
||||
// 前后摄切换 + 相册入口(缩略图占位)常驻;闪光灯未注入能力不出现。
|
||||
expect(find.byIcon(Icons.flip_camera_ios_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.flash_off), findsNothing,
|
||||
reason: '源不支持闪光灯时不显示开关');
|
||||
});
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 顶栏待传徽章 1', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||||
expect(find.text('Queued 0'), findsOneWidget);
|
||||
expect(find.text('0 queued'), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
@@ -202,7 +256,21 @@ void main() {
|
||||
expect(tasks.single.albumId, 'alb-1');
|
||||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||||
expect(find.text('Queued 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
expect(find.text('1 queued'), findsOneWidget, reason: '徽章实时刷新');
|
||||
});
|
||||
|
||||
testWidgets('最近一张缩略图:拍摄前为相册占位,拍摄后显示照片',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.byType(Image), findsNothing, reason: '拍摄前无缩略图');
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
expect(
|
||||
find.byType(Image),
|
||||
findsOneWidget,
|
||||
reason: '拍摄后底部显示最近一张(缩略图位)',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
|
||||
@@ -247,7 +315,36 @@ void main() {
|
||||
'IMG_0001.jpg',
|
||||
'IMG_0002.jpg',
|
||||
]));
|
||||
expect(find.text('Queued 2'), findsOneWidget);
|
||||
expect(find.text('2 queued'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('闪光灯:源支持才显示;点击开→关记录调用', (tester) async {
|
||||
source.flashSupported = true;
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.byIcon(Icons.flash_off), findsOneWidget, reason: '默认关');
|
||||
await tester.tap(find.byIcon(Icons.flash_off));
|
||||
await tester.pump();
|
||||
|
||||
expect(source.flashCalls, [true]);
|
||||
expect(find.byIcon(Icons.flash_on), findsOneWidget, reason: '开启后图标翻转');
|
||||
});
|
||||
|
||||
testWidgets('前后摄切换:释放当前源 → 反向重建(工厂收 preferFront)',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(factoryPreferFront, [false]);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.flip_camera_ios_outlined));
|
||||
await tester.pump();
|
||||
var guard = 0;
|
||||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
|
||||
expect(source.disposeCalls, 1, reason: '旧源已释放(相机生命周期)');
|
||||
expect(factoryPreferFront, [false, true], reason: '反向重建');
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '预览恢复');
|
||||
});
|
||||
|
||||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||||
|
||||
@@ -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: '未收到进度事件的行不重建(行级进度,不整页刷新)',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/// 服务端上传历史页 widget 测试(P3 计划 Task 6,A19 语义)。
|
||||
/// 服务端上传历史页 widget 测试(UI-03 页面 16;A19 语义)。
|
||||
///
|
||||
/// 覆盖:A19 数据源区分横幅、行内容(clientTaskId 短码/KB/状态标签/
|
||||
/// 设备短码 + 「本设备」标记)、游标加载更多、错误重试。
|
||||
/// 覆盖:标题/副标题(跨设备口径)与 A19 数据源区分横幅、行内容
|
||||
/// (clientTaskId 短码/KB/状态徽章/时间 · 设备来源安全回退)、相册与
|
||||
/// 日期客户端筛选、下拉刷新、游标加载更多、加载更多失败页脚重试、
|
||||
/// 首屏错误重试。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -66,6 +68,19 @@ void main() {
|
||||
..session = Session(token: 'tok-1', account: 'alice');
|
||||
});
|
||||
|
||||
/// 页面 watch albumListProvider(相册筛选候选)——适配器统一供给
|
||||
/// GET /v1/albums;/v1/uploads 路由由各用例注入。
|
||||
FakeAdapter adapterOf(
|
||||
(int, Object?) Function(RequestOptions options) uploads, {
|
||||
List<Map<String, dynamic>> albums = const <Map<String, dynamic>>[],
|
||||
}) =>
|
||||
FakeAdapter((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/albums') {
|
||||
return (200, cursorPageBody(albums));
|
||||
}
|
||||
return uploads(o);
|
||||
});
|
||||
|
||||
Future<void> pumpPage(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
@@ -91,21 +106,34 @@ void main() {
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
testWidgets('A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)',
|
||||
/// 仅 /v1/uploads 请求(剥离相册列表请求)。
|
||||
Iterable<RecordedRequest> uploadsRequests(FakeAdapter adapter) =>
|
||||
adapter.requests.where((r) => r.path == '/v1/uploads');
|
||||
|
||||
testWidgets('标题/副标题(跨设备口径)+ A19 横幅 + 行内容与设备来源回退',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(clientTaskId: 'owndevice0000000000000000000001'),
|
||||
sessionJson(
|
||||
clientTaskId: 'otherdevice0000000000000000000002',
|
||||
uploadId: 'upl-0002',
|
||||
clientTaskId: 'otherdev000000000000000000000002',
|
||||
state: 'pending',
|
||||
processingState: 'processing',
|
||||
expectedBytes: 5 * 1024 * 1024,
|
||||
sourceDeviceId: _kOtherDevice,
|
||||
),
|
||||
sessionJson(
|
||||
uploadId: 'upl-0003',
|
||||
clientTaskId: 'nodevice0000000000000000000000003',
|
||||
state: 'expired',
|
||||
processingState: 'processing_failed',
|
||||
expectedBytes: 4096,
|
||||
sourceDeviceId: null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -114,29 +142,183 @@ void main() {
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
// A19 数据源区分横幅(UI-01:本机队列入口文案指向「上传」页)。
|
||||
expect(find.textContaining('cross-device'), findsOneWidget);
|
||||
// 页面 16:标题 + 跨设备副标题(与本地队列口径区分)。
|
||||
expect(find.text('Server records'), findsOneWidget);
|
||||
expect(find.text('All devices · Your account'), findsOneWidget);
|
||||
|
||||
// A19 数据源区分横幅(本机待传队列入口指向「上传」页)。
|
||||
expect(find.textContaining('all of your devices'), findsOneWidget);
|
||||
expect(find.textContaining('Uploads tab'), findsOneWidget);
|
||||
|
||||
// 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。
|
||||
expect(find.textContaining('owndevic'), findsOneWidget);
|
||||
expect(find.textContaining('owndevic ·'), findsOneWidget);
|
||||
expect(find.textContaining('2 KB'), findsOneWidget);
|
||||
expect(find.textContaining('otherdev'), findsOneWidget);
|
||||
expect(find.textContaining('otherdev ·'), findsOneWidget);
|
||||
expect(find.textContaining('5120 KB'), findsOneWidget);
|
||||
expect(find.textContaining('nodevice · 4 KB'), findsOneWidget);
|
||||
|
||||
// 状态标签(会话状态 + 处理状态)。
|
||||
// 状态徽章(会话状态 + 处理状态;页面 16 专用文案)。
|
||||
expect(find.text('Confirmed'), findsOneWidget);
|
||||
expect(find.text('Pending'), findsOneWidget);
|
||||
expect(find.text('Ready'), findsOneWidget);
|
||||
expect(find.text('Processing'), findsOneWidget);
|
||||
expect(find.text('Expired'), findsOneWidget);
|
||||
expect(find.text('Processing failed'), findsOneWidget);
|
||||
|
||||
// 设备短码(本机设备aaaaaaaa + 「本设备」;他机无标记)。
|
||||
expect(find.textContaining(_kOwnDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining(_kOtherDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining('(This device)'), findsOneWidget);
|
||||
// 设备来源安全回退:本机 → This device;他机/缺失 → Unknown device
|
||||
//(不展示裸设备 ID)。
|
||||
expect(find.textContaining('This device'), findsOneWidget);
|
||||
expect(find.textContaining('Unknown device'), findsNWidgets(2));
|
||||
expect(
|
||||
find.textContaining(_kOtherDevice.substring(0, 8)),
|
||||
findsNothing,
|
||||
reason: '契约无设备名——不展示裸设备 ID',
|
||||
);
|
||||
|
||||
// listUploads 请求带分页 limit=20。
|
||||
expect(adapter.requests.single.uri.queryParameters['limit'], '20');
|
||||
expect(
|
||||
uploadsRequests(adapter).single.uri.queryParameters['limit'],
|
||||
'20',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('相册筛选(候选来自相册列表;客户端过滤已加载记录)',
|
||||
(tester) async {
|
||||
final adapter = adapterOf(
|
||||
(o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
albumId: 'alb-1',
|
||||
clientTaskId: 'galarow00000000000000000000000001',
|
||||
),
|
||||
sessionJson(
|
||||
albumId: 'alb-2',
|
||||
clientTaskId: 'wedrow000000000000000000000000002',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
},
|
||||
albums: [
|
||||
albumJson(),
|
||||
albumJson(
|
||||
id: 'alb-2',
|
||||
publicToken: 'sharetoken000000000002',
|
||||
translations: [
|
||||
<String, dynamic>{
|
||||
'locale': 'zh-CN',
|
||||
'title': '婚礼当天',
|
||||
'description': null,
|
||||
'venue': null,
|
||||
'address': null,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'locale': 'en',
|
||||
'title': 'Wedding Day',
|
||||
'description': null,
|
||||
'venue': null,
|
||||
'address': null,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('galarow'), findsOneWidget);
|
||||
expect(find.textContaining('wedrow'), findsOneWidget);
|
||||
|
||||
// 打开相册筛选菜单(默认 All albums)→ 选择 Wedding Day。
|
||||
await tester.tap(find.text('All albums'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Wedding Day'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 仅保留 alb-2 记录;筛选入口标签随选择更新。
|
||||
expect(find.textContaining('wedrow'), findsOneWidget);
|
||||
expect(find.textContaining('galarow'), findsNothing);
|
||||
expect(find.text('Wedding Day'), findsOneWidget, reason: '药丸标签');
|
||||
});
|
||||
|
||||
testWidgets('日期筛选(Today:仅保留今天创建的记录)', (tester) async {
|
||||
final now = DateTime.now().toUtc().toIso8601String();
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'todayrow0000000000000000000000001',
|
||||
createdAt: now,
|
||||
),
|
||||
sessionJson(
|
||||
uploadId: 'upl-0002',
|
||||
clientTaskId: 'oldrow00000000000000000000000002',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('todayrow'), findsOneWidget);
|
||||
expect(find.textContaining('oldrow'), findsOneWidget);
|
||||
|
||||
// 打开日期筛选菜单(默认 Any time)→ 选择 Today。
|
||||
await tester.tap(find.text('Any time'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('todayrow'), findsOneWidget);
|
||||
expect(find.textContaining('oldrow'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('下拉刷新重取第一页', (tester) async {
|
||||
var first = true;
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return first
|
||||
? (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'firstrow000000000000000000000001',
|
||||
),
|
||||
]),
|
||||
)
|
||||
: (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(
|
||||
clientTaskId: 'refreshr000000000000000000000002',
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('firstrow'), findsOneWidget);
|
||||
|
||||
first = false;
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, 300),
|
||||
600,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('refreshr'), findsOneWidget);
|
||||
expect(find.textContaining('firstrow'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('游标加载更多(滚动触底 → 第二页带 cursor)', (tester) async {
|
||||
@@ -147,7 +329,7 @@ void main() {
|
||||
clientTaskId: 'page1row$i.padLeft-000000000000000000001',
|
||||
),
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
final adapter = adapterOf((o) {
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'hc1'));
|
||||
@@ -164,25 +346,77 @@ void main() {
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(adapter.requests, hasLength(1), reason: '首页无 cursor');
|
||||
expect(uploadsRequests(adapter), hasLength(1), reason: '首页无 cursor');
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.last.uri.queryParameters['cursor'], 'hc1');
|
||||
expect(
|
||||
uploadsRequests(adapter).last.uri.queryParameters['cursor'],
|
||||
'hc1',
|
||||
);
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('加载失败展示错误与重试;恢复后重试成功', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (failFirst) {
|
||||
testWidgets('加载更多失败 → 页脚重试行;重试恢复追加第二页', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => sessionJson(
|
||||
uploadId: 'upl-p1-$i',
|
||||
clientTaskId: 'page1row$i.padLeft-000000000000000000001',
|
||||
),
|
||||
);
|
||||
var failMore = true;
|
||||
final adapter = adapterOf((o) {
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'hc1'));
|
||||
}
|
||||
if (failMore) {
|
||||
failMore = false;
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: o.path),
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
}
|
||||
return (200, cursorPageBody([sessionJson()]));
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(
|
||||
[sessionJson(clientTaskId: 'page2item000000000000000000000001')],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 首屏成功不整体报错:仅页脚错误说明 + 重试(页面 16)。
|
||||
expect(find.textContaining('Network error'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Network error'), findsNothing);
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('首屏加载失败展示错误与重试;恢复后重试成功', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = adapterOf((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
if (failFirst) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: o.path),
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
}
|
||||
return (200, cursorPageBody([sessionJson()]));
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
Reference in New Issue
Block a user