/// 相册详情页 widget 测试(P3 计划 Task 4)。 /// /// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行; /// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 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 kJpgBytes = [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, ]; void main() { late FakeSessionStore store; late List> categories; setUp(() { store = FakeSessionStore(); categories = >[ categoryJson(id: 'cat-1', sortOrder: 0), ]; }); /// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。 FakeAdapter detailAdapter({ (int, Object?)? Function(String method, String path)? onCategoryMutation, }) => FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { return (200, albumJson(categories: categories)); } if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') { return onCategoryMutation?.call('POST', o.path) ?? (201, categoryJson(id: 'cat-new')); } if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') { return onCategoryMutation?.call('PATCH', o.path) ?? (200, categoryJson(id: 'cat-1')); } if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') { return onCategoryMutation?.call('DELETE', o.path) ?? (204, null); } throw StateError('unexpected ${o.method} ${o.path}'); }); ApiClient clientOf(FakeAdapter adapter) => ApiClient( sessionStore: store, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, ); Future pumpDetail(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( // Task 9 起详情页有上传 FAB(触达 sessionAccountProvider), // 真实平台通道在测试环境挂起,必须注入替身。 overrides: [ ...pageTestOverrides(session: store, client: client), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumDetailPage(albumId: 'alb-1'), ), ), ); await tester.pumpAndSettle(); } testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async { final adapter = detailAdapter(); await pumpDetail(tester, clientOf(adapter)); // 信息区(标题同时出现在 AppBar 与信息卡)。 expect(find.text('开幕晚宴'), findsNWidgets(2)); expect(find.text('Active'), findsOneWidget); expect( find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'), findsOneWidget, ); expect(find.text('sharetoken000000000001'), findsOneWidget); expect(find.text('大宴会厅'), findsOneWidget); // 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。 expect(find.text('Upload'), findsNWidgets(2)); expect(find.text('Original'), findsOneWidget); // 动作行(5 项)。 expect(find.text('Edit'), findsOneWidget); expect(find.text('Members'), findsOneWidget); // 「Categories」同时出现在动作按钮与分区标题。 expect(find.text('Categories'), findsNWidgets(2)); expect(find.text('Share QR code'), findsOneWidget); expect(find.text('Photos'), findsOneWidget); // 分类条目(zh · en 名称连接)。 expect(find.text('仪式 · Ceremony'), findsOneWidget); }); testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async { var posted = false; final adapter = detailAdapter( onCategoryMutation: (method, path) { if (method == 'POST') { posted = true; categories = >[ ...categories, categoryJson( id: 'cat-new', sortOrder: 5, translations: >[ {'locale': 'zh-CN', 'name': '签到'}, {'locale': 'en', 'name': 'Check-in'}, ], ), ]; return (201, categoryJson(id: 'cat-new', sortOrder: 5)); } return null; }, ); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.byIcon(Icons.add_circle_outline)); await tester.pumpAndSettle(); await tester.enterText( find.widgetWithText(TextFormField, 'Chinese name'), '签到', ); await tester.enterText( find.widgetWithText(TextFormField, 'English name'), 'Check-in', ); await tester.enterText( find.widgetWithText(TextFormField, 'Sort order'), '5', ); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(posted, isTrue); // 成功后详情重载会追加 GET,故按方法+路径取 POST。 final request = adapter.requests.singleWhere( (r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories', ); expect(request.path, '/v1/albums/alb-1/categories'); expect(request.body, { 'sortOrder': 5, 'translations': >[ {'locale': 'zh-CN', 'name': '签到'}, {'locale': 'en', 'name': 'Check-in'}, ], }); // 详情重载后列表含新分类。 expect(find.text('签到 · Check-in'), findsOneWidget); }); testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async { final adapter = detailAdapter(); await pumpDetail(tester, clientOf(adapter)); final requestsBefore = adapter.requests.length; await tester.tap(find.byIcon(Icons.add_circle_outline)); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); await tester.pump(); expect( find.text('At least one language name is required'), findsOneWidget, ); expect(adapter.requests, hasLength(requestsBefore)); }); testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder', (tester) async { var patched = false; final adapter = detailAdapter( onCategoryMutation: (method, path) { if (method == 'PATCH') { patched = true; categories = >[ categoryJson( id: 'cat-1', sortOrder: 2, translations: >[ {'locale': 'zh-CN', 'name': '签到墙'}, {'locale': 'en', 'name': 'Ceremony'}, ], ), ]; return (200, categoryJson(id: 'cat-1')); } return null; }, ); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.text('仪式 · Ceremony')); await tester.pumpAndSettle(); // 预填检查。 final zhField = tester.widget( find.widgetWithText(TextFormField, 'Chinese name'), ); expect(zhField.controller!.text, '仪式'); await tester.enterText( find.widgetWithText(TextFormField, 'Chinese name'), '签到墙', ); await tester.enterText( find.widgetWithText(TextFormField, 'Sort order'), '2', ); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(patched, isTrue); // 成功后详情重载会追加 GET,故按方法+路径取 PATCH。 final request = adapter.requests.singleWhere( (r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1', ); expect(request.body, { 'sortOrder': 2, 'translations': >[ {'locale': 'zh-CN', 'name': '签到墙'}, {'locale': 'en', 'name': 'Ceremony'}, ], }); expect(find.text('签到墙 · Ceremony'), findsOneWidget); }); testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async { final adapter = detailAdapter( onCategoryMutation: (method, path) { if (method == 'DELETE') { categories = >[]; return (204, null); } return null; }, ); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.byIcon(Icons.delete_outline)); await tester.pumpAndSettle(); expect( find.text('Delete this category? Its photos will become uncategorized.'), findsOneWidget, ); // 取消可用:先取消再重删,验证确认门。 await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(find.text('仪式 · Ceremony'), findsOneWidget); await tester.tap(find.byIcon(Icons.delete_outline)); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); expect( adapter.requests.any( (r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1', ), isTrue, ); 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 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 = []; taskStore = TaskStore.memory(); }); tearDown(() async { await taskStore.close(); await tmp.delete(recursive: true); }); /// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/ /// 非平台相机源)。 Future 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.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 initialize() async {} @override Future capture() => throw UnimplementedError(); @override Widget buildPreview() => const SizedBox(); @override Future dispose() async {} }