/// 连拍页 widget 测试(UI-03 页面 13 深色摄影界面;R17 连拍入队 / O01)。 /// /// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机 /// 平台通道;可注入闪光灯能力/记录翻转释放)+ 内存 TaskStore + 临时目录 /// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类 /// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务 /// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门 /// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。 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 'package:photo_live/ui/tokens.dart'; import '../helpers/fakes.dart'; const List kJpgBytes = [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, ]; /// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数; /// 闪光灯能力可注入(记录开关调用);释放计数供前后切换断言。 class FakeCaptureSource extends CaptureSource { FakeCaptureSource(this._files); final List _files; int initializeCalls = 0; int failNextInits = 0; Object? initError; /// 闪光灯能力(默认不支持——UI 不显示开关)。 bool flashSupported = false; final List flashCalls = []; /// dispose 计数(前后摄切换释放断言)。 int disposeCalls = 0; @override Future initialize() async { initializeCalls++; if (failNextInits > 0) { failNextInits--; throw initError ?? StateError('init failed'); } } @override Future capture() async => XFile(_files.removeAt(0).path); @override Widget buildPreview() => const ColoredBox( color: Color(0xFF101010), child: Center(child: Text('PREVIEW_MARK')), ); @override bool get supportsFlash => flashSupported; @override Future setFlashEnabled(bool enabled) async => flashCalls.add(enabled); @override Future dispose() async => disposeCalls++; } void main() { late FakeSessionStore session; late TaskStore store; late Directory tmp; late FakeCaptureSource source; late List galleryPaths; /// 工厂调用记录(前后摄切换断言)。 late List factoryPreferFront; final categories = [ 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 = []; factoryPreferFront = []; }); tearDown(() async { await store.close(); try { await tmp.delete(recursive: true); } on Object { // Windows:底部缩略图 FileImage 的异步读取可能仍持有临时文件句柄 //(fake-async 区内真实 IO 完成回调不被冲刷)——删除失败不判定 // 业务断言失败,残留目录交由系统临时目录清理。 } }); CaptureTarget target() => CaptureTarget( accountId: 'alice', albumId: 'alb-1', albumTitle: 'Opening Gala', categories: categories, ); Future 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) => ({bool preferFront = false}) async { factoryPreferFront.add(preferFront); return 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 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.delayed(const Duration(milliseconds: 300)); }); await tester.pump(kQueueRefreshDebounce); await tester.pump(const Duration(milliseconds: 60)); } /// 快门(即拍即入队链路经 [pressRealIo])。 Future shoot(WidgetTester tester, {int times = 1}) async { for (var i = 0; i < times; i++) { await pressRealIo(tester, Icons.camera); } } testWidgets('深色摄影界面:页面背景 #10161E(不随主题)', (tester) async { await pumpCapture(tester); expect( tester.widget(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('0 queued'), 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('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)', (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.delayed(const Duration(milliseconds: 80)); }); await tester.pump(const Duration(milliseconds: 180)); // 反馈浮层淡入完成(AnimatedOpacity → 1),文案为「已加入队列」。 expect( tester.widget(find.byType(AnimatedOpacity).first).opacity, greaterThan(0), reason: '200ms 反馈窗内浮层可见', ); expect(find.text('Added to queue'), findsOneWidget); // 等待 200ms 反馈计时器在真实事件循环中到期,避免悬挂计时器;随后 // fake pump 冲刷淡出动画帧。 await tester.runAsync( () async => Future.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([ 'IMG_0001.jpg', 'IMG_0002.jpg', ])); 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 归属不变)', (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 requestBack() async { final navigator = tester.state( 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); }); }