feat(mobile): upload queue ui and continuous capture page
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>
This commit is contained in:
@@ -4,16 +4,31 @@
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart' show XFile;
|
||||
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/notification_permission.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/task_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
|
||||
const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> categories;
|
||||
@@ -55,7 +70,11 @@ void main() {
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -79,7 +98,8 @@ void main() {
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||||
expect(find.text('Upload'), findsNWidgets(2));
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
@@ -266,4 +286,131 @@ void main() {
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('上传入口 FAB(Task 9)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
late TaskStore taskStore;
|
||||
|
||||
setUp(() async {
|
||||
store.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
tmp = await Directory.systemTemp.createTemp('detail-upload-');
|
||||
galleryPaths = <String>[];
|
||||
taskStore = TaskStore.memory();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await taskStore.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||||
/// 非平台相机源)。
|
||||
Future<void> pumpDetailForUpload(WidgetTester tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: store,
|
||||
taskStore: taskStore,
|
||||
client: clientOf(adapter),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => () async => galleryPaths),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
taskStore,
|
||||
baseDirProvider: () async => tmp,
|
||||
),
|
||||
),
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Capture and upload'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Capture and upload'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CapturePage),
|
||||
matching: find.text('开幕晚宴'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||||
await tester.runAsync(() async {
|
||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Added 2 photo(s) to the upload queue'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final tasks = await taskStore.listByAccount('alice');
|
||||
expect(tasks, hasLength(2));
|
||||
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
|
||||
class _StaticCaptureSource implements CaptureSource {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
||||
///
|
||||
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
|
||||
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
|
||||
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
|
||||
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
|
||||
/// 权限拒绝/初始化失败视图与重试。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart' show CameraException, XFile;
|
||||
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/notification_permission.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/albums/models.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/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
|
||||
class FakeCaptureSource implements CaptureSource {
|
||||
FakeCaptureSource(this._files);
|
||||
|
||||
final List<File> _files;
|
||||
int initializeCalls = 0;
|
||||
int failNextInits = 0;
|
||||
Object? initError;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls++;
|
||||
if (failNextInits > 0) {
|
||||
failNextInits--;
|
||||
throw initError ?? StateError('init failed');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() async => XFile(_files.removeAt(0).path);
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const ColoredBox(
|
||||
color: Color(0xFF101010),
|
||||
child: Center(child: Text('PREVIEW_MARK')),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore session;
|
||||
late TaskStore store;
|
||||
late Directory tmp;
|
||||
late FakeCaptureSource source;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
final categories = <Category>[
|
||||
const Category(
|
||||
id: 'cat-1',
|
||||
albumId: 'alb-1',
|
||||
sortOrder: 0,
|
||||
translations: [
|
||||
CategoryTranslation(locale: 'zh-CN', name: '仪式'),
|
||||
CategoryTranslation(locale: 'en', name: 'Ceremony'),
|
||||
],
|
||||
),
|
||||
const Category(
|
||||
id: 'cat-2',
|
||||
albumId: 'alb-1',
|
||||
sortOrder: 1,
|
||||
translations: [
|
||||
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
|
||||
CategoryTranslation(locale: 'en', name: 'Gala'),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
setUp(() async {
|
||||
session = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
store = TaskStore.memory();
|
||||
tmp = await Directory.systemTemp.createTemp('capture-test-');
|
||||
final shot1 = File('${tmp.path}/IMG_0001.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final shot2 = File('${tmp.path}/IMG_0002.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
source = FakeCaptureSource([shot1, shot2, shot3]);
|
||||
galleryPaths = <String>[];
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await store.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
CaptureTarget target() => CaptureTarget(
|
||||
accountId: 'alice',
|
||||
albumId: 'alb-1',
|
||||
albumTitle: 'Opening Gala',
|
||||
categories: categories,
|
||||
);
|
||||
|
||||
Future<void> pumpCapture(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: session,
|
||||
taskStore: store,
|
||||
client: ApiClient(
|
||||
sessionStore: session,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = FakeAdapter(
|
||||
(_) => throw StateError('连拍页不应发起 API 请求')),
|
||||
),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => source),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
store,
|
||||
baseDirProvider: () async => tmp,
|
||||
),
|
||||
),
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => () async => galleryPaths),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: CapturePage(target: target()),
|
||||
),
|
||||
),
|
||||
);
|
||||
// 相机初始化完成:帧级 pump 直至预览出现(首个测试字体/主题冷启动
|
||||
// 较慢,固定双 pump 不够稳)。
|
||||
await tester.pump();
|
||||
var guard = 0;
|
||||
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发一个会走真实文件 IO 的按钮:在 runAsync(真实事件循环)内直接
|
||||
/// 调用其回调——widget 测试的 fake-async 区里 dart:io 完成回调不会被
|
||||
/// pump 冲刷,入队链路必须整体跑在真实区;返回后 pump 刷新徽章防抖。
|
||||
Future<void> pressRealIo(WidgetTester tester, IconData icon) async {
|
||||
final button = find
|
||||
.ancestor(of: find.byIcon(icon), matching: find.byType(IconButton))
|
||||
.evaluate()
|
||||
.single
|
||||
.widget as IconButton;
|
||||
await tester.runAsync(() async {
|
||||
button.onPressed!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump(kQueueRefreshDebounce);
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
}
|
||||
|
||||
/// 快门(即拍即入队链路经 [pressRealIo])。
|
||||
Future<void> shoot(WidgetTester tester, {int times = 1}) async {
|
||||
for (var i = 0; i < times; i++) {
|
||||
await pressRealIo(tester, Icons.camera);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
|
||||
expect(find.text('Pending 0'), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
expect(tasks, hasLength(1));
|
||||
expect(tasks.single.state, UploadTaskState.queued);
|
||||
expect(tasks.single.accountId, 'alice');
|
||||
expect(tasks.single.albumId, 'alb-1');
|
||||
expect(tasks.single.categoryId, isNull, reason: '默认分类');
|
||||
expect(tasks.single.filename, 'IMG_0001.jpg');
|
||||
expect(find.text('Pending 1'), findsOneWidget, reason: '徽章实时刷新');
|
||||
});
|
||||
|
||||
testWidgets('连拍两张串行入队(不逐张确认)', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await shoot(tester, times: 2);
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
expect(tasks.map((t) => t.filename), unorderedEquals(<String>[
|
||||
'IMG_0001.jpg',
|
||||
'IMG_0002.jpg',
|
||||
]));
|
||||
expect(find.text('Pending 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
await shoot(tester); // 第一张:默认分类。
|
||||
|
||||
await tester.tap(find.text('Gala'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.text('Switching only affects photos captured afterwards'),
|
||||
findsOneWidget,
|
||||
reason: '切换归属不变提示',
|
||||
);
|
||||
await tester.pumpAndSettle(); // snackbar 退场。
|
||||
|
||||
await shoot(tester); // 第二张:新分类。
|
||||
|
||||
final tasks = await store.listByAccount('alice');
|
||||
final first =
|
||||
tasks.firstWhere((t) => t.filename == 'IMG_0001.jpg');
|
||||
final second =
|
||||
tasks.firstWhere((t) => t.filename == 'IMG_0002.jpg');
|
||||
expect(first.categoryId, isNull, reason: '既有任务归属不变');
|
||||
expect(second.categoryId, 'cat-2', reason: '切换后拍摄走新分类');
|
||||
});
|
||||
|
||||
testWidgets('相册多选补充入队 → 入队数提示', (tester) async {
|
||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||
await pumpCapture(tester);
|
||||
|
||||
// 多选入队走真实文件 IO(同快门路径)。
|
||||
await pressRealIo(tester, Icons.photo_library_outlined);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Added 2 photo(s) to the upload queue'), findsOneWidget);
|
||||
expect(await store.listByAccount('alice'), hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('退出确认:有待传任务 → 弹确认;保留继续拍 / 确认才退出',
|
||||
(tester) async {
|
||||
await pumpCapture(tester);
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
|
||||
await shoot(tester);
|
||||
|
||||
// home 路由无返回按钮——经 Navigator.maybePop 驱动系统返回路径
|
||||
//(PopScope.canPop=false → onPopInvokedWithResult 弹确认)。
|
||||
Future<void> requestBack() async {
|
||||
final navigator = tester.state<NavigatorState>(
|
||||
find.byType(Navigator).first,
|
||||
);
|
||||
await navigator.maybePop();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// 有待传 → 弹确认。
|
||||
await requestBack();
|
||||
expect(find.text('Leave shooting?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('still uploading'),
|
||||
findsOneWidget,
|
||||
reason: '正文含待传数与后台继续说明',
|
||||
);
|
||||
|
||||
// 保留继续拍 → 不退出。
|
||||
await tester.tap(find.text('Keep shooting'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
|
||||
// 再次退出 → 确认离开 → 出栈。
|
||||
await requestBack();
|
||||
await tester.tap(find.text('Leave'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(CapturePage), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('权限拒绝视图:文案 + 重试入口', (tester) async {
|
||||
source.initError = CameraException('CameraAccessDenied', 'denied');
|
||||
source.failNextInits = 1;
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Camera unavailable. Allow camera access in system settings'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 重试成功 → 预览就绪。
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('初始化失败视图:非权限异常文案 + 重试恢复', (tester) async {
|
||||
source.failNextInits = 1;
|
||||
source.initError = StateError('camera broken');
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.text('Camera failed to initialize'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('PREVIEW_MARK'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('常驻信息层:相册名 + 分类候选', (tester) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
expect(find.text('Opening Gala'), findsOneWidget);
|
||||
expect(find.text('Default category'), findsOneWidget);
|
||||
expect(find.text('Ceremony'), findsOneWidget);
|
||||
expect(find.text('Gala'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -37,7 +37,11 @@ void main() {
|
||||
Future<void> pumpLoginPage(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起登录成功会刷新 sessionAccountProvider——真实
|
||||
// flutter_secure_storage 平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
/// 上传队列页 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%');
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,11 @@ void main() {
|
||||
Future<void> pumpHome(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
||||
Reference in New Issue
Block a user