/// 照片管理网格 widget 测试(UI-01 §4.3)。 /// /// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/ /// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二 /// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。 library; 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/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 '../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, ); Future pumpPhotos(WidgetTester tester, ApiClient client) 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(); } void setSurface(WidgetTester tester, Size size) { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1.0; } testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async { final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } // 非空列表(空态不渲染 GridView,无法断言 delegate)。 return ( 200, cursorPageBody(>[ adminPhotoJson(photoId: 'ph-c1', preview: photoPreviewJson()), adminPhotoJson(photoId: 'ph-c2', preview: photoPreviewJson()), ]), ); }); final client = clientOf(adapter); setSurface(tester, const Size(350, 800)); await pumpPhotos(tester, client); expect(_columnCount(tester), 2, reason: '极窄屏两列'); setSurface(tester, const Size(390, 844)); await pumpPhotos(tester, client); expect(_columnCount(tester), 3, reason: '默认三列'); setSurface(tester, const Size(800, 600)); await pumpPhotos(tester, client); expect(_columnCount(tester), 5, reason: '800/150 向下取整 5 列'); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('缩略图 + 状态角标(处理中/失败/隐藏;就绪公开无角标)', (tester) async { setSurface(tester, const Size(390, 844)); 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-ready', preview: photoPreviewJson()), adminPhotoJson( photoId: 'ph-processing', processingState: 'processing', visibility: 'unpublished', ), adminPhotoJson( photoId: 'ph-failed', processingState: 'processing_failed', visibility: 'unpublished', ), adminPhotoJson(photoId: 'ph-hidden', visibility: 'hidden'), ]), ); }); await pumpPhotos(tester, clientOf(adapter)); // 缩略图来自 preview.thumb(测试环境网络失败 → 替代图占位,不破图)。 expect(find.byType(PhotoThumbnail), findsNWidgets(4)); expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); // 角标:处理中/失败/隐藏(图标 + 文案,不只靠颜色;文案限定在角标 // 组件内——筛选 chips 有同名标签)。 Finder badgeText(String label) => find.descendant( of: find.byType(StateBadge), matching: find.text(label), ); expect(badgeText('Processing'), findsOneWidget); expect(badgeText('Processing failed'), findsOneWidget); expect(badgeText('Hidden'), findsOneWidget); expect(find.byIcon(Icons.hourglass_top), findsOneWidget); expect(find.byIcon(Icons.error_outline), findsOneWidget); expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('scope 自适应:all 403 → own 重试 + 协作者提示', (tester) async { setSurface(tester, const Size(390, 844)); 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-1'), ]), ); } throw StateError('unexpected scope $scope'); }); await pumpPhotos(tester, clientOf(adapter)); // 两次请求:all(403)→ own(200,服务端过滤自己上传)。 expect(adapter.requests, hasLength(2)); expect(adapter.requests.first.uri.queryParameters['scope'], 'all'); expect(adapter.requests.last.uri.queryParameters['scope'], 'own'); // 协作者范围提示可见。 expect( find.text('Collaborator view: only photos you uploaded are shown'), findsOneWidget, ); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('游标加载第二页(携带 cursor)', (tester) async { setSurface(tester, const Size(390, 844)); final page1 = List>.generate( 20, (i) => adminPhotoJson( photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}', preview: photoPreviewJson(), ), ); final page2 = >[ adminPhotoJson(photoId: 'ph-page2item', 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: 'pc1')); } if (cursor == 'pc1') return (200, cursorPageBody(page2)); throw StateError('unexpected cursor $cursor'); }); await pumpPhotos(tester, clientOf(adapter)); // 滚动加载第二页。 await tester.drag(find.byType(GridView), const Offset(0, -4000)); await tester.pumpAndSettle(); expect( adapter.requests.last.uri.queryParameters['cursor'], 'pc1', ); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('筛选 chips:Published 走服务端 visibility 重载', (tester) async { setSurface(tester, const Size(390, 844)); 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-1', preview: photoPreviewJson()), ]), ); }); await pumpPhotos(tester, clientOf(adapter)); await tester.tap(find.widgetWithText(FilterChip, 'Published')); await tester.pumpAndSettle(); expect( adapter.requests.last.uri.queryParameters['visibility'], 'published', reason: 'Published 筛选下发服务端 visibility 参数', ); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('本地筛选:Processing 只显示未就绪条目(不发新请求)', (tester) async { setSurface(tester, const Size(390, 844)); 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-ready', preview: photoPreviewJson()), adminPhotoJson( photoId: 'ph-busy', processingState: 'processing', visibility: 'unpublished', ), ]), ); }); await pumpPhotos(tester, clientOf(adapter)); final requestsBefore = adapter.requests.length; // 瓦片角标文案与 chip 同名(「Processing」),按组件类型锁定 chip; // 390 宽下 Processing/Failed 在横向滚动区外,先滚动到位。 final processingChip = find.widgetWithText(FilterChip, 'Processing'); await tester.ensureVisible(processingChip); await tester.pumpAndSettle(); await tester.tap(processingChip); await tester.pumpAndSettle(); // 本地过滤:无新请求,仅剩处理中条目(含其角标)。 expect(adapter.requests, hasLength(requestsBefore)); expect( find.descendant(of: find.byType(StateBadge), matching: find.text('Processing')), findsOneWidget, ); expect(find.byType(PhotoThumbnail), findsOneWidget); // 本地筛选无匹配 → 空筛选态。 final failedChip = find.widgetWithText(FilterChip, 'Failed'); await tester.ensureVisible(failedChip); await tester.pumpAndSettle(); await tester.tap(failedChip); await tester.pumpAndSettle(); expect(find.text('No photos match this filter'), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('空列表 → 空态;点击瓦片进入大图查看器', (tester) async { setSurface(tester, const Size(390, 844)); final adapter = FakeAdapter((o) { if (o.path != '/v1/albums/alb-1/photos') { throw StateError('unexpected ${o.method} ${o.path}'); } return (200, cursorPageBody(>[])); }); await pumpPhotos(tester, clientOf(adapter)); expect(find.text('No photos yet'), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('点击瓦片 → 大图查看器(关闭返回网格)', (tester) async { setSurface(tester, const Size(390, 844)); 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-1', preview: photoPreviewJson()), ]), ); }); await pumpPhotos(tester, clientOf(adapter)); await tester.tap(find.byType(PhotoThumbnail).first); await tester.pumpAndSettle(); expect(find.byType(AlbumPhotoViewerPage), findsOneWidget); await tester.tap(find.byIcon(Icons.close)); await tester.pumpAndSettle(); expect(find.byType(AlbumPhotoViewerPage), findsNothing); expect(find.byType(AlbumPhotosPage), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); } /// 网格列数(GridView delegate 断言用)。 int _columnCount(WidgetTester tester) => (tester.widget(find.byType(GridView)).gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) .crossAxisCount;