Files
photo-live/apps/mobile/test/pages/capture_page_test.dart
T
2026-09-21 17:20:43 +08:00

369 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 连拍页 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('Queued 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('Queued 1'), findsOneWidget, reason: '徽章实时刷新');
});
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
(tester) async {
await pumpCapture(tester);
// 与 pressRealIo 同法但缩短真实等待(80ms < 200ms 反馈窗):入队链路
// 完成时浮层已翻转、计时器未到期,可在窗内断言不透明度 > 0。
final button = find
.ancestor(of: find.byIcon(Icons.camera), matching: find.byType(IconButton))
.evaluate()
.single
.widget as IconButton;
await tester.runAsync(() async {
button.onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 80));
});
await tester.pump(const Duration(milliseconds: 180));
// 反馈浮层淡入完成(AnimatedOpacity → 1),文案为「已加入队列」。
expect(
tester.widget<AnimatedOpacity>(find.byType(AnimatedOpacity).first).opacity,
greaterThan(0),
reason: '200ms 反馈窗内浮层可见',
);
expect(find.text('Added to queue'), findsOneWidget);
// 等待 200ms 反馈计时器在真实事件循环中到期,避免悬挂计时器;随后
// fake pump 冲刷淡出动画帧。
await tester.runAsync(
() async => Future<void>.delayed(const Duration(milliseconds: 250)),
);
await tester.pump(const Duration(milliseconds: 400));
});
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('Queued 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);
});
}