/// 相册详情页 widget 测试(UI-03 §4 页面 06-08 重设计后)。 /// /// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/ /// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部 /// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册 /// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转 /// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。 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/error_codes.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_categories_page.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 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认 /// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。 FakeAdapter detailAdapter({ List>? photos, bool collaborator = false, (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/photos' && o.method == 'GET') { final scope = o.uri.queryParameters['scope']; if (collaborator && scope == 'all') { return (403, errorBody(ErrorCodes.authForbidden)); } return (200, cursorPageBody(photos ?? >[])); } 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( // 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道 // 在测试环境挂起,必须注入替身。 overrides: [...pageTestOverrides(session: store, client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumDetailPage(albumId: 'alb-1'), ), ), ); await tester.pumpAndSettle(); } /// 新布局下分类区在折叠线以下:先滚动到位再交互。 Future reveal(WidgetTester tester, Finder finder) async { await tester.ensureVisible(finder); await tester.pumpAndSettle(); } testWidgets('布局结构:封面 → 信息 → 四入口 → Recent photos → 底部上传栏', (tester) async { final adapter = detailAdapter( photos: List>.generate( 3, (i) => adminPhotoJson( photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}', preview: photoPreviewJson(), ), ), ); await pumpDetail(tester, clientOf(adapter)); // 封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行时间/场地 // (相册时区墙钟 + 场地语言回退)。 final body = find.byType(SingleChildScrollView); expect( find.descendant(of: body, matching: find.text('Opening Gala')), findsOneWidget, ); expect( find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'), findsOneWidget, ); expect(find.byIcon(Icons.image_outlined), findsOneWidget); // Recent photos 网格缩略图(签名 URL 测试环境失败 → 替代图,不破图)。 expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3)); // 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。 expect(find.text('Public'), findsOneWidget); expect(find.text('Uploads on'), findsOneWidget); expect(find.text('Download'), findsNothing); expect(find.text('Original'), findsNothing); expect(find.text('sharetoken000000000001'), findsNothing); // Recent photos:分区标题 + 「See all」入口 + 三列网格。 expect(find.text('Recent photos'), findsOneWidget); expect(find.text('See all'), findsOneWidget); // 次级四入口(管理者全量)。入口文字限定在四入口卡内(页面下方 // 「分类管理」分区标题同为 Categories 文案,按 Card 作用域区分)。 final tilesCard = find.byType(Card).first; expect(find.descendant(of: tilesCard, matching: find.text('Photos')), findsOneWidget); expect(find.descendant(of: tilesCard, matching: find.text('Members')), findsOneWidget); expect(find.descendant(of: tilesCard, matching: find.text('Categories')), findsOneWidget); expect(find.descendant(of: tilesCard, matching: find.text('Share')), findsOneWidget); // 顶栏「⋯」操作菜单入口。 expect(find.byIcon(Icons.more_vert), findsOneWidget); // 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。 final capture = find.widgetWithText(FilledButton, 'Capture and upload'); final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery'); expect(capture, findsOneWidget); expect(pick, findsOneWidget); // 垂直顺序:标题 → 徽章 → 四入口 → Recent photos → 底部栏。全宽 // 16:9 封面较高,先滚动让 Recent photos 入屏再比较几何位置。 await reveal(tester, find.text('Recent photos')); final titleTop = tester .getRect(find.descendant(of: body, matching: find.text('Opening Gala'))) .top; final badgeTop = tester.getRect(find.text('Public')).top; final tilesTop = tester .getRect(find.descendant(of: tilesCard, matching: find.text('Photos'))) .top; final recentTop = tester.getRect(find.text('Recent photos')).top; final captureTop = tester.getRect(capture).top; expect(titleTop, lessThan(badgeTop)); expect(badgeTop, lessThan(tilesTop)); expect(tilesTop, lessThan(recentTop)); expect(recentTop, lessThan(captureTop)); }); testWidgets('无照片:空相册安静占位「Ready for your first photo」', (tester) async { await pumpDetail(tester, clientOf(detailAdapter())); expect(find.text('Ready for your first photo'), findsOneWidget); expect( find.text('Photos you take or pick will appear here'), findsOneWidget, ); // 空态无伪造计数/示例照片,上传入口收敛到底部栏(上传开启时可用)。 expect(find.text('No photos yet'), findsNothing); final capture = find.widgetWithText(FilledButton, 'Capture and upload'); expect( tester.widget(capture).onPressed, isNotNull, reason: '上传开启时底部栏可用', ); }); testWidgets('上传关闭:底部栏说明原因并禁用双入口', (tester) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { return (200, albumJson(categories: categories, uploadEnabled: false)); } if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') { return (200, cursorPageBody(>[])); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpDetail(tester, clientOf(adapter)); expect( find.text( 'Uploads are off for this album. New photos cannot be added right now', ), findsOneWidget, ); expect( tester .widget( find.widgetWithText(FilledButton, 'Capture and upload'), ) .onPressed, isNull, ); expect( tester .widget( find.widgetWithText(OutlinedButton, 'Pick from gallery'), ) .onPressed, isNull, ); }); testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览', (tester) async { final adapter = detailAdapter( photos: >[ adminPhotoJson( photoId: 'ph-own-1', uploaderAccount: 'staff02', preview: photoPreviewJson(), ), ], collaborator: true, ); await pumpDetail(tester, clientOf(adapter)); // scope 自适应:all(403)→ own(200)。 final photoRequests = adapter.requests .where((r) => r.path == '/v1/albums/alb-1/photos') .toList(); expect(photoRequests, hasLength(2)); expect(photoRequests.first.uri.queryParameters['scope'], 'all'); expect(photoRequests.last.uri.queryParameters['scope'], 'own'); // 管理入口仅创建者/管理员可见;Photos/Share 对全体成员开放。 expect(find.text('Members'), findsNothing); expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏'); expect(find.text('Photos'), findsOneWidget); expect(find.text('Share'), findsOneWidget); expect(find.byIcon(Icons.add_circle_outline), findsNothing); // 预览网格退化为自己的上传(1 张;封面占位另 1 张)。 expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); }); testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async { final adapter = detailAdapter(); await pumpDetail(tester, clientOf(adapter)); // 旧内嵌分类管理区块不再存在于详情页。 expect(find.byIcon(Icons.add_circle_outline), findsNothing); expect(find.text('仪式 · Ceremony'), findsNothing); // Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动 // 可见再点击。 final tilesCard = find.byType(Card).first; final categoriesTile = find.descendant(of: tilesCard, matching: find.text('Categories')); await reveal(tester, categoriesTile); await tester.tap(categoriesTile); await tester.pumpAndSettle(); expect(find.byType(AlbumCategoriesPage), findsOneWidget); expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)'); // 返回后详情重载(分类随详情返回)。 await tester.pageBack(); await tester.pumpAndSettle(); expect(find.byType(AlbumCategoriesPage), findsNothing); expect( adapter.requests .where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1') .length, greaterThanOrEqualTo(2), ); }); group('底部固定上传栏(原 Task 9 FAB 流迁移)', () { 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) => ({bool preferFront = false}) async => _StaticCaptureSource(), ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumDetailPage(albumId: 'alb-1'), ), ), ); await tester.pumpAndSettle(); } testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async { await pumpDetailForUpload(tester); await tester.tap(find.text('Capture and upload')); await tester.pumpAndSettle(); expect(find.byType(CapturePage), findsOneWidget); // 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。 expect( find.descendant( of: find.byType(CapturePage), matching: find.text('Opening Gala'), ), 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); // 入队链路含真实文件 IO:真实事件循环内触发行内动作。 final button = find.widgetWithText(OutlinedButton, 'Pick from gallery'); await tester.runAsync(() async { (button.evaluate().single.widget as OutlinedButton).onPressed!(); 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 bool get supportsFlash => false; @override Future setFlashEnabled(bool enabled) async {} @override Future dispose() async {} }