/// UI-03 页面 05-08(相册工作台)重设计回归。 /// /// 覆盖:相册列表整图卡片(白字标题/时间场地叠底部暗色渐变、叠层状态 /// 小徽章、无封面同尺寸占位、标题副标题与右上新建入口);相册详情四入口 /// (≥44dp 触控)、Recent photos 三列网格与 See all 跳转、底部栏避让 /// 安全区;操作菜单按角色过滤(先关面板再导航、Cancel 仅关闭);空相册 /// 安静占位(协作者只读说明);上传关闭禁用双入口;处理中照片指示。 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/l10n/app_localizations.dart'; import 'package:photo_live/pages/album_detail_page.dart'; import 'package:photo_live/pages/album_form_page.dart'; import 'package:photo_live/pages/album_members_page.dart'; import 'package:photo_live/pages/album_photos_page.dart'; import 'package:photo_live/pages/albums_tab.dart'; import 'package:photo_live/ui/components/photo_thumbnail.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, ); /// 固定逻辑宽度(dp=1)。 void setSurface(WidgetTester tester, Size size) { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1.0; } /// 相册列表页签(默认 en)。 Future pumpTab(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( overrides: [...pageTestOverrides(client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumsTab(), ), ), ); await tester.pumpAndSettle(); } /// 相册详情页(默认管理者 + 空照片;可注入照片/协作者/上传关闭)。 Future pumpDetail( WidgetTester tester, ApiClient client, ) async { await tester.pumpWidget( ProviderScope( overrides: [...pageTestOverrides(session: store, client: client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumDetailPage(albumId: 'alb-1'), ), ), ); await tester.pumpAndSettle(); } /// 详情页路由打桩:GET 详情 / GET 照片(scope 自适应)。 FakeAdapter detailAdapter({ List>? photos, bool collaborator = false, bool uploadEnabled = true, }) => FakeAdapter((o) { if (o.path == '/v1/albums/alb-1' && o.method == 'GET') { return (200, albumJson(uploadEnabled: uploadEnabled)); } 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 ?? >[])); } throw StateError('unexpected ${o.method} ${o.path}'); }); List> photos(int count, {String processing = 'ready'}) => List>.generate( count, (i) => adminPhotoJson( photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}', processingState: processing, preview: processing == 'ready' ? photoPreviewJson() : null, ), ); testWidgets('页面 05:整图卡片——白字标题/时间地点叠底部暗色渐变', (tester) async { setSurface(tester, const Size(390, 844)); // 手机单列 final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody([albumJson()])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); // 标题白字、时间场地 white70,叠加在图片上(非卡片外独立信息区)。 final title = tester.widget(find.text('Opening Gala')); expect(title.style?.color, Colors.white); final meta = tester.widget( find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), ); expect(meta.style?.color, Colors.white70); // 底部暗色渐变叠层存在(LinearGradient 的 DecoratedBox)。 expect( find.byWidgetPredicate( (w) => w is DecoratedBox && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).gradient is LinearGradient, ), findsOneWidget, ); // 叠层状态小徽章:半透明深色底 + 白字(图标语义色提亮变体)。 final badge = tester.widget(find.text('Public')); expect(badge.style?.color, Colors.white); expect(find.text('Uploads on'), findsOneWidget); // 单卡片 = 单 Card(无信息白卡分包)。 expect(find.byType(Card), findsOneWidget); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('页面 05:无封面 → 同尺寸中性占位(整卡 16:9)', (tester) async { setSurface(tester, const Size(390, 844)); // 手机单列 final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody([albumJson()])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); expect(find.byType(PhotoThumbnail), findsOneWidget); expect(find.byIcon(Icons.image_outlined), findsOneWidget); // 整卡 16:9:缩略图(Positioned.fill 铺满卡片)宽 = 卡片宽、高 = 9/16。 final card = tester.getRect(find.byType(Card)); final thumb = tester.getRect(find.byType(PhotoThumbnail)); expect(card.width, closeTo(358, 0.5)); expect(thumb.width, closeTo(358, 0.5)); expect(thumb.height, closeTo(358 * 9 / 16, 0.5), reason: '16:9 基准高度'); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('页面 05:标题 + 副标题 + 右上「新建」入口', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody([albumJson()])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); expect(find.text('Albums'), findsOneWidget); expect(find.text('Organize and manage event photos'), findsOneWidget); // 右上「+」(圆形填充按钮)→ 创建表单。 expect(find.byIcon(Icons.add), findsOneWidget); expect(find.byType(AlbumFormPage), findsNothing); await tester.tap(find.byIcon(Icons.add)); await tester.pumpAndSettle(); expect(find.byType(AlbumFormPage), findsOneWidget); }); testWidgets('页面 06:四入口齐全且有效触控区 ≥44dp', (tester) async { final adapter = detailAdapter(photos: photos(2)); await pumpDetail(tester, clientOf(adapter)); // 入口文字限定在四入口卡内(页面下方「分类管理」分区标题同为 // Categories 文案,需按 Card 作用域区分)。 final tilesCard = find.byType(Card); expect(tilesCard, findsOneWidget); for (final label in ['Photos', 'Members', 'Categories', 'Share']) { final labelInCard = find.descendant( of: tilesCard, matching: find.text(label), ); expect(labelInCard, findsOneWidget, reason: '$label 入口存在'); final tile = find.ancestor( of: labelInCard, matching: find.byType(InkWell), ); expect(tile, findsOneWidget); final rect = tester.getRect(tile); expect(rect.width, greaterThanOrEqualTo(44), reason: '$label 宽 ≥44'); expect(rect.height, greaterThanOrEqualTo(44), reason: '$label 高 ≥44'); } }); testWidgets('页面 06:Recent photos 三列网格(≤9)+ See all 跳转', (tester) async { final adapter = detailAdapter(photos: photos(9)); await pumpDetail(tester, clientOf(adapter)); await tester.ensureVisible(find.text('Recent photos')); await tester.pumpAndSettle(); final grid = find.byType(GridView); expect(grid, findsOneWidget); // 9 张缩略图全部入格(签名 URL 测试环境失败 → 替代图,不破图)。 final thumbs = find .descendant(of: grid, matching: find.byType(PhotoThumbnail)) .evaluate(); expect(thumbs, hasLength(9)); // 第一行 3 张:第 4 张位于第 1 张下方(三列)。 final first = tester.getRect( find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).first, ); final fourth = tester.getRect( find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).at(3), ); expect(fourth.top, greaterThan(first.top)); expect( (fourth.left - first.left).abs(), lessThan(1), reason: '三列网格第 4 张与第 1 张同列', ); // See all → 照片管理页。 await tester.tap(find.text('See all')); await tester.pumpAndSettle(); expect(find.byType(AlbumPhotosPage), findsOneWidget); }); testWidgets('页面 07:操作菜单(管理者)——先关面板再导航', (tester) async { final adapter = detailAdapter(photos: photos(1)); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.byTooltip('More actions')); await tester.pumpAndSettle(); // 管理者五项:Edit album / Manage members / Categories / Share album / // Cancel。条目断言限定在面板内(页面下方「Categories」与四入口文案 // 同名)。 final sheet = find.byType(AlbumActionSheet); expect(sheet, findsOneWidget); expect(find.descendant(of: sheet, matching: find.text('Edit album')), findsOneWidget); expect(find.descendant(of: sheet, matching: find.text('Manage members')), findsOneWidget); expect(find.descendant(of: sheet, matching: find.text('Categories')), findsOneWidget); expect(find.descendant(of: sheet, matching: find.text('Share album')), findsOneWidget); expect( find.descendant(of: sheet, matching: find.text('Cancel')), findsOneWidget); // 点击动作:面板先关闭,再进入目标页(不叠层路由错乱)。表单页标题 // 同为「Edit album」,以面板组件类型判定关闭。 expect(find.byType(AlbumFormPage), findsNothing); await tester.tap(find.descendant( of: sheet, matching: find.text('Edit album'), )); await tester.pumpAndSettle(); expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭'); expect(find.byType(AlbumFormPage), findsOneWidget); }); testWidgets('页面 07:操作菜单按角色过滤(协作者仅分享)', (tester) async { final adapter = detailAdapter(photos: photos(1), collaborator: true); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.byTooltip('More actions')); await tester.pumpAndSettle(); final sheet = find.byType(AlbumActionSheet); expect(sheet, findsOneWidget); expect(find.descendant(of: sheet, matching: find.text('Edit album')), findsNothing); expect(find.descendant(of: sheet, matching: find.text('Manage members')), findsNothing); expect(find.descendant(of: sheet, matching: find.text('Categories')), findsNothing); expect(find.descendant(of: sheet, matching: find.text('Share album')), findsOneWidget); // Cancel 仅关闭面板,不导航。 await tester.tap( find.descendant(of: sheet, matching: find.text('Cancel')), ); await tester.pumpAndSettle(); expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭'); expect(find.byType(AlbumMembersPage), findsNothing); }); testWidgets('页面 07:菜单「Manage members」共享详情导航方法', (tester) async { final adapter = detailAdapter(photos: photos(1)); await pumpDetail(tester, clientOf(adapter)); await tester.tap(find.byTooltip('More actions')); await tester.pumpAndSettle(); final sheet = find.byType(AlbumActionSheet); await tester.tap(find.descendant( of: sheet, matching: find.text('Manage members'), )); await tester.pumpAndSettle(); expect(find.byType(AlbumMembersPage), findsOneWidget); }); testWidgets('页面 08:空相册安静占位(管理者)+ 上传入口可用', (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.byType(GridView), findsNothing); expect(find.textContaining('uploads'), findsNothing); // 底部上传栏保留(上传开启)。 expect( tester .widget( find.widgetWithText(FilledButton, 'Capture and upload'), ) .onPressed, isNotNull, ); }); testWidgets('页面 08:空相册(协作者)显示只读说明', (tester) async { final adapter = detailAdapter(collaborator: true); await pumpDetail(tester, clientOf(adapter)); expect(find.text('Ready for your first photo'), findsOneWidget); expect( find.text('You can browse photos shared by others'), findsOneWidget, ); expect( find.text('Photos you take or pick will appear here'), findsNothing, ); }); testWidgets('页面 06/08:上传关闭 → 双入口禁用 + 原因说明', (tester) async { final adapter = detailAdapter(uploadEnabled: false); 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('页面 06:底部固定栏避让安全区(SafeArea 包裹)', (tester) async { final adapter = detailAdapter(photos: photos(1)); await pumpDetail(tester, clientOf(adapter)); final capture = find.widgetWithText(FilledButton, 'Capture and upload'); expect( find.ancestor(of: capture, matching: find.byType(SafeArea)), findsOneWidget, reason: '底部栏按钮位于 SafeArea 内', ); }); testWidgets('页面 08:有处理中照片 → 「Processing...」指示,非空白', (tester) async { final adapter = detailAdapter( photos: [...photos(2), ...photos(1, processing: 'processing')], ); await pumpDetail(tester, clientOf(adapter)); await tester.ensureVisible(find.text('Recent photos')); await tester.pumpAndSettle(); // 处理中提示存在;网格照常展示(真实状态,不当绝对空白)。 expect(find.text('Processing...'), findsOneWidget); expect(find.text('Ready for your first photo'), findsNothing); final grid = find.byType(GridView); expect( find.descendant(of: grid, matching: find.byType(PhotoThumbnail)) .evaluate(), hasLength(3), ); }); }