/// 照片大图查看器 widget 测试(UI-01 §4.3;UI-03 §4 页面 18/20 回归)。 /// /// 覆盖:摄影深色背景 + contain 展示、信息栏(上传者/相册时区时间·尺寸· /// 大小/状态/ID 诊断区)、左右滑动与方向键切换、临近末尾加载更多、 /// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、 /// 隐藏经确认 BottomSheet(标题/缩略图/影响说明/取消不请求/photoId 打开 /// 时捕获/成功回网格角标翻转)、删除确认(软删除文案,与隐藏确认分开)、 /// 「⋯」更多菜单、关闭返回网格原位置、签名 URL 失败一次受控刷新。 library; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/session_store.dart'; import 'package:photo_live/features/albums/models.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_photos_page.dart'; import 'package:photo_live/pages/album_photo_viewer_page.dart'; import 'package:photo_live/ui/components/photo_thumbnail.dart'; import 'package:photo_live/ui/components/state_badge.dart'; import 'package:photo_live/ui/tokens.dart'; import '../helpers/fakes.dart'; void main() { late FakeSessionStore store; setUp(() { store = FakeSessionStore(); }); ApiClient clientOf(FakeAdapter adapter) => ApiClient( sessionStore: store, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, ); /// 网格 + 可选打开查看器;返回打开前的网格 Scrollable 像素偏移。 Future pumpGridAndOpen( WidgetTester tester, ApiClient client, { int tile = 0, }) async { await tester.pumpWidget( ProviderScope( overrides: [...pageTestOverrides(session: store, client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: AlbumPhotosPage(album: Album.fromJson(albumJson())), ), ), ); await tester.pumpAndSettle(); final gridScrollable = find.descendant( of: find.byType(GridView), matching: find.byType(Scrollable), ); final before = tester .state(gridScrollable) .position .pixels; await tester.tap(find.byType(PhotoThumbnail).at(tile)); await tester.pumpAndSettle(); return before; } /// 查看器子树内的查找(网格页在路由栈下方仍挂载,需按页面收窄)。 Finder inViewer(Finder finder) => find.descendant( of: find.byType(AlbumPhotoViewerPage), matching: finder, ); testWidgets('深色摄影背景 + contain + 信息栏(上传者/时间·尺寸·大小/状态/ID)', ( tester, ) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-info-1', preview: photoPreviewJson()), ]), ); }); await pumpGridAndOpen(tester, clientOf(adapter)); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); // 恒深色摄影背景(不随系统主题)。 expect( tester.widget(inViewer(find.byType(Scaffold))).backgroundColor, kColorPhotoBackdrop, ); // contain 完整展示(不裁切)。 expect( tester.widget(inViewer(find.byType(Image))).fit, BoxFit.contain, ); // 顶栏:关闭 × + 更多 ⋯。 expect(inViewer(find.byIcon(Icons.close)), findsOneWidget); expect(inViewer(find.byIcon(Icons.more_vert)), findsOneWidget); // 信息栏:上传者 / 相册时区墙钟·尺寸·大小 / 真实状态 / ID 诊断区。 expect(find.text('Uploaded by Staff Two'), findsOneWidget); // uploadedAt 07:00Z → 相册时区上海 15:00;4000×3000 · 121 KB。 expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget); expect(find.text('Status: Published'), findsOneWidget); expect(find.text('ph-info-1'), findsOneWidget); }); testWidgets('左右切换已加载照片 + 临近末尾加载更多', (tester) async { final page1 = List>.generate( 20, (i) => adminPhotoJson( photoId: 'ph-v${(i + 1).toString().padLeft(2, '0')}', uploaderName: 'Uploader ${i + 1}', preview: photoPreviewJson(), ), ); final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } final cursor = o.uri.queryParameters['cursor']; if (cursor == null) { return (200, cursorPageBody(page1, nextCursor: 'vc1')); } if (cursor == 'vc1') { return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-vpage2', preview: photoPreviewJson()), ]), ); } throw StateError('unexpected cursor $cursor'); }); await pumpGridAndOpen(tester, clientOf(adapter)); // 初始第一张。 expect(find.text('Uploaded by Uploader 1'), findsOneWidget); // 左右切换:到第二张。 await tester.drag(find.byType(PageView), const Offset(-700, 0)); await tester.pumpAndSettle(); expect(find.text('Uploaded by Uploader 2'), findsOneWidget); // 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。 for (var i = 0; i < 20; i++) { await tester.drag(find.byType(PageView), const Offset(-700, 0)); await tester.pumpAndSettle(); } expect( adapter.requests.any((r) => r.uri.queryParameters['cursor'] == 'vc1'), isTrue, reason: '临近末尾按游标加载第二页', ); }); testWidgets('方向键左右切换(键盘可达性,与滑动同序列)', (tester) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return ( 200, cursorPageBody(>[ adminPhotoJson( photoId: 'ph-k1', uploaderName: 'Uploader 1', preview: photoPreviewJson(), ), adminPhotoJson( photoId: 'ph-k2', uploaderName: 'Uploader 2', preview: photoPreviewJson(), ), ]), ); }); await pumpGridAndOpen(tester, clientOf(adapter)); expect(find.text('Uploaded by Uploader 1'), findsOneWidget); // → 右键下一张。 await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pumpAndSettle(); expect(find.text('Uploaded by Uploader 2'), findsOneWidget); // ← 左键回上一张;再左(已在首张)忽略不越界。 await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); expect(find.text('Uploaded by Uploader 1'), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pumpAndSettle(); expect(find.text('Uploaded by Uploader 1'), findsOneWidget); }); testWidgets('管理者操作行:已发布→Hide photo+Delete;已隐藏→Publish again', ( tester, ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-pub', preview: photoPreviewJson()), adminPhotoJson( photoId: 'ph-hid', visibility: 'hidden', preview: photoPreviewJson(), ), ]), ); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpGridAndOpen(tester, clientOf(adapter)); // 已发布:Hide photo + Delete(管理者,scope=all 成功)。 expect(find.text('Hide photo'), findsOneWidget); expect(find.text('Delete'), findsOneWidget); expect(find.text('Publish again'), findsNothing); // 切到第二张(隐藏照片):Publish again(仅管理者)+ Delete。 await tester.drag(find.byType(PageView), const Offset(-700, 0)); await tester.pumpAndSettle(); expect(find.text('Publish again'), findsOneWidget); expect(find.text('Hide photo'), findsNothing); expect(find.text('Delete'), findsOneWidget); }); testWidgets('Publish again:PATCH published → 操作行翻转为 Hide photo', ( tester, ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( 200, cursorPageBody(>[ adminPhotoJson( photoId: 'ph-hid', visibility: 'hidden', preview: photoPreviewJson(), ), ]), ); } if (o.method == 'PATCH' && o.path == '/v1/photos/ph-hid') { return (200, adminPhotoJson(photoId: 'ph-hid')); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpGridAndOpen(tester, clientOf(adapter)); await tester.tap(find.text('Publish again')); await tester.pumpAndSettle(); final patch = adapter.requests.singleWhere( (r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-hid', ); expect(patch.body, {'visibility': 'published'}); expect(find.text('Hide photo'), findsOneWidget, reason: '恢复后可再隐藏'); expect(find.text('Publish again'), findsNothing); expect(find.text('Status: Published'), findsOneWidget); }); testWidgets('隐藏确认 BottomSheet:标题/缩略图/影响说明;取消不请求', (tester) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-sheet', preview: photoPreviewJson()), ]), ); }); await pumpGridAndOpen(tester, clientOf(adapter)); // 操作行 Hide photo(OutlinedButton)打开确认面板。 await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo')); await tester.pumpAndSettle(); final sheet = find.descendant( of: find.byType(BottomSheet), matching: find.byType(PhotoThumbnail), ); expect(find.text('Hide this photo?'), findsOneWidget); expect( find.text( 'Viewers will no longer see this photo. ' 'Authorized album managers can publish it again.', ), findsOneWidget, ); expect(sheet, findsOneWidget, reason: '有可见预览 → 面板内展示当前图缩略图'); // 主操作 FilledButton + 取消。 expect(find.widgetWithText(FilledButton, 'Hide photo'), findsOneWidget); // 取消:不发 PATCH,查看器保持。 await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect( adapter.requests.any((r) => r.method == 'PATCH'), isFalse, reason: '取消不请求接口', ); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); }); testWidgets('确认隐藏:photoId 打开时捕获 → PATCH 正确对象 → 回网格角标翻转', ( tester, ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( 200, cursorPageBody(>[ adminPhotoJson( photoId: 'ph-h1', uploaderName: 'Uploader 1', preview: photoPreviewJson(), ), adminPhotoJson( photoId: 'ph-h2', uploaderName: 'Uploader 2', preview: photoPreviewJson(), ), ]), ); } if (o.method == 'PATCH' && o.path == '/v1/photos/ph-h2') { return (200, adminPhotoJson(photoId: 'ph-h2', visibility: 'hidden')); } throw StateError('unexpected ${o.method} ${o.path}'); }); // 打开第二张(tile 1 = ph-h2)。 await pumpGridAndOpen(tester, clientOf(adapter), tile: 1); expect(find.text('Uploaded by Uploader 2'), findsOneWidget); // 操作行 Hide photo → 面板内 FilledButton Hide photo 确认。 await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Hide photo')); await tester.pumpAndSettle(); // 请求作用于打开面板时捕获的照片(ph-h2),不受其他条目影响。 final patch = adapter.requests.singleWhere((r) => r.method == 'PATCH'); expect(patch.path, '/v1/photos/ph-h2'); expect(patch.body, {'visibility': 'hidden'}); // 成功:关闭面板 + 关闭查看器回网格;条目状态合并,角标翻转 Hidden。 expect(find.byType(AlbumPhotoViewerPage), findsNothing); // SnackBar「Hidden」成功提示(chip 同名标签在外,按容器收窄)。 expect( find.descendant(of: find.byType(SnackBar), matching: find.text('Hidden')), findsOneWidget, ); expect( find.descendant( of: find.byKey(const ValueKey('grid-photo-ph-h2')), matching: find.byType(StateBadge), ), findsOneWidget, reason: '网格瓦片角标翻转为 Hidden', ); }); testWidgets('协作者(scope=own):自己上传可删除,无恢复入口;无预览占位', ( tester, ) async { store.session = Session( token: 'tok-1', expiresAt: DateTime.utc(2099, 1, 1), account: 'staff02', ); final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } final scope = o.uri.queryParameters['scope']; if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden)); if (scope == 'own') { return ( 200, cursorPageBody(>[ adminPhotoJson( photoId: 'ph-own-hidden', visibility: 'hidden', uploaderAccount: 'staff02', preview: null, ), ]), ); } throw StateError('unexpected scope $scope'); }); await pumpGridAndOpen(tester, clientOf(adapter)); // 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。 expect(find.text('Publish again'), findsNothing); expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除'); // 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。 expect(find.text('Image unavailable'), findsOneWidget); expect(find.text('ph-own-hidden'), findsOneWidget); }); testWidgets('删除确认:软删除文案 + 取消不删 + 确认 DELETE 移除条目', ( tester, ) async { final adapter = FakeAdapter((o) { if (o.path == '/v1/albums/alb-1/photos') { return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-del', preview: photoPreviewJson()), ]), ); } if (o.method == 'DELETE' && o.path == '/v1/photos/ph-del') { return (204, null); } throw StateError('unexpected ${o.method} ${o.path}'); }); await pumpGridAndOpen(tester, clientOf(adapter)); await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); // 确认弹窗:与隐藏确认分开,说明当前实际软删除语义。 expect(find.text('Delete this photo?'), findsOneWidget); expect( find.text( 'This will soft-delete the photo. It can be managed by admins. ' 'Cloud storage is not immediately purged.', ), findsOneWidget, ); // 取消不删。 await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect( adapter.requests.any((r) => r.method == 'DELETE'), isFalse, reason: '取消不触发删除', ); await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); expect( adapter.requests.any( (r) => r.method == 'DELETE' && r.path == '/v1/photos/ph-del', ), isTrue, ); // 唯一照片删除后 → 查看器退回网格(空态)。 expect(find.byType(AlbumPhotoViewerPage), findsNothing); expect(find.text('No photos yet'), findsOneWidget); }); testWidgets('「⋯」更多菜单:按权限出菜单项,删除项可走确认弹窗', (tester) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-menu', preview: photoPreviewJson()), ]), ); }); await pumpGridAndOpen(tester, clientOf(adapter)); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); // 菜单项与操作行同一套权限裁决(已发布管理者:Hide photo + Delete)。 final sheetDelete = find.descendant( of: find.byType(BottomSheet), matching: find.text('Delete'), ); expect( find.descendant( of: find.byType(BottomSheet), matching: find.text('Hide photo'), ), findsOneWidget, ); expect(sheetDelete, findsOneWidget); expect( find.descendant( of: find.byType(BottomSheet), matching: find.text('Publish again'), ), findsNothing, ); // 菜单删除 → 独立删除确认弹窗;取消不删。 await tester.tap(sheetDelete); await tester.pumpAndSettle(); expect(find.text('Delete this photo?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(adapter.requests.any((r) => r.method == 'DELETE'), isFalse); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); }); testWidgets('关闭返回网格原滚动位置', (tester) async { // 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。 final items = List>.generate( 24, (i) => adminPhotoJson( photoId: 'ph-s${(i + 1).toString().padLeft(2, '0')}', preview: photoPreviewJson(), ), ); final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return (200, cursorPageBody(items)); }); await tester.pumpWidget( ProviderScope( overrides: [...pageTestOverrides(session: store, client: clientOf(adapter))], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: AlbumPhotosPage(album: Album.fromJson(albumJson())), ), ), ); await tester.pumpAndSettle(); // 先滚动网格(离开顶部)。 final gridScrollable = find.descendant( of: find.byType(GridView), matching: find.byType(Scrollable), ); await tester.drag(gridScrollable, const Offset(0, -600)); await tester.pumpAndSettle(); final scrolled = tester .state(gridScrollable) .position .pixels; expect(scrolled, greaterThan(0)); // 打开当前视口内可见的一张(滚动后树序首项可能在缓存区/被遮挡)→ // 关闭 → 偏移保持。 await tester.tap(find.byType(PhotoThumbnail).hitTestable().first); await tester.pumpAndSettle(); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); await tester.tap(find.byIcon(Icons.close)); await tester.pumpAndSettle(); expect( tester.state(gridScrollable).position.pixels, scrolled, reason: '关闭大图后网格回到原滚动位置', ); }); testWidgets('签名 URL 失败 → 一次受控刷新(不无限重试)', (tester) async { var listCalls = 0; final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } listCalls++; // 刷新后返回新签名 URL(测试环境网络仍失败 → 验证不再重试)。 return ( 200, cursorPageBody(>[ adminPhotoJson( photoId: 'ph-url', preview: photoPreviewJson( browseUrl: 'http://test.local/browse-v$listCalls.jpg', ), ), ]), ); }); await pumpGridAndOpen(tester, clientOf(adapter)); // 初次列表 + 失败后自动刷新一次 = 2 次;再等待后不再增加。 await tester.pumpAndSettle(const Duration(seconds: 1)); expect(listCalls, 2, reason: '签名 URL 失败仅重取一次'); expect(find.text('Image unavailable'), findsOneWidget); }); }