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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user