/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3/4 覆盖封面卡片与 /// 响应式列表)。 /// /// 覆盖:封面卡片(封面占位/标题回退/时间场地行/公开与上传徽章/令牌与 /// 四开关移除)、关闭状态徽章、封面加载失败替代图、响应式单/双列、空态 /// CTA、游标加载更多(第二页携带 cursor)、首屏错误 + 重试恢复。 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/providers.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/albums_tab.dart'; import 'package:photo_live/pages/album_form_page.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, ); Future pumpTab(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( overrides: [apiClientProvider.overrideWith((ref) => client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const AlbumsTab(), ), ), ); await tester.pumpAndSettle(); } /// 固定逻辑宽度(dp=1)。 void setSurface(WidgetTester tester, Size size) { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1.0; } testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody([albumJson()])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); // 标题按界面语言回退(en → en 翻译),最多两行。 expect(find.text('Opening Gala'), findsOneWidget); // 时间用相册时区(上海墙钟 14:30)+ 场地(en 未填场地回退 zh-CN)。 expect(find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'), findsOneWidget); // 封面未配置 → 固定 16:9 中性占位(非转圈)。 expect(find.byType(PhotoThumbnail), findsOneWidget); expect(find.byIcon(Icons.image_outlined), findsOneWidget); // 公开/上传状态徽章(图标 + 文案,不只靠颜色)。 expect(find.text('Public'), findsOneWidget); expect(find.byIcon(Icons.info_outline), findsOneWidget); expect(find.text('Uploads on'), findsOneWidget); expect(find.byIcon(Icons.check_circle), findsOneWidget); // publicToken 与四开关堆叠已从主卡片移除(完整配置在详情设置)。 expect(find.text('sharetoken…'), findsNothing); expect(find.text('Download'), findsNothing); expect(find.text('Original'), findsNothing); expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口'); }); testWidgets('关闭状态徽章与封面加载失败替代图', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? ( 200, cursorPageBody([ albumJson( publicEnabled: false, uploadEnabled: false, cover: { 'url': 'http://test.local/cover.jpg', 'expiresAt': '2026-09-22T00:00:00Z', }, ), ]), ) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); // 公开关闭 / 上传关闭 → 中性徽章。 expect(find.text('Not public'), findsOneWidget); expect(find.text('Uploads off'), findsOneWidget); // 封面 URL 加载失败(测试环境网络请求 400)→ 中性块 + 小图标,不显示破图。 expect(find.byType(PhotoThumbnail), findsOneWidget); expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget); expect(find.byIcon(Icons.image_outlined), findsNothing); }); testWidgets('响应式列数:390 宽单列 / 900 宽双列', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? ( 200, cursorPageBody([ albumJson(id: 'alb-1'), albumJson(id: 'alb-2'), ]), ) : throw StateError('unexpected ${o.path}'), ); final client = clientOf(adapter); // 手机宽度(390):单列,两卡片同宽同行不可见。 setSurface(tester, const Size(390, 844)); await pumpTab(tester, client); final card1 = tester.getRect(find.byType(PhotoThumbnail).first); final card2 = tester.getRect(find.byType(PhotoThumbnail).last); expect(card1.width, closeTo(390 - 32, 0.5), reason: '单列占满(左右 16)'); expect( card1.right == card2.right && card1.left == card2.left, isTrue, reason: '两卡片同列(垂直排布)', ); // 平板宽度(900):双列并排。 setSurface(tester, const Size(900, 1200)); await pumpTab(tester, client); final wide1 = tester.getRect(find.byType(PhotoThumbnail).first); final wide2 = tester.getRect(find.byType(PhotoThumbnail).last); expect(wide1.width, closeTo((900 - 32 - 12) / 2, 0.5), reason: '双列均分'); expect( wide2.left > wide1.right, isTrue, reason: '第二张在第一张右侧(双列)', ); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); }); testWidgets('空列表 → 空态文案 + 「新建相册」CTA 进入表单', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/albums' ? (200, cursorPageBody(>[])) : throw StateError('unexpected ${o.path}'), ); await pumpTab(tester, clientOf(adapter)); expect( find.text('No albums yet. Tap "Create album" to get started'), findsOneWidget, ); // 空态 CTA(次强调按钮)→ 创建表单。 expect(find.byType(AlbumFormPage), findsNothing); await tester.tap(find.widgetWithText(FilledButton, 'Create album')); await tester.pumpAndSettle(); expect(find.byType(AlbumFormPage), findsOneWidget); }); testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async { final page1 = List>.generate( 20, (i) => albumJson(id: 'alb-${i + 1}'), ); final page2 = List>.generate( 10, (i) => albumJson(id: 'alb-${i + 21}'), ); final adapter = FakeAdapter((o) { if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); final cursor = o.uri.queryParameters['cursor']; if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1')); if (cursor == 'c1') return (200, cursorPageBody(page2)); throw StateError('unexpected cursor $cursor'); }); await pumpTab(tester, clientOf(adapter)); // 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。 expect(adapter.requests, hasLength(1)); final container = ProviderScope.containerOf( tester.element(find.byType(AlbumsTab)), ); expect(container.read(albumListProvider).items, hasLength(20)); // 上滑触发加载更多(封面卡片较高,分段滚动直至触发第二页请求)。 for (var i = 0; i < 10 && adapter.requests.length < 2; i++) { await tester.drag(find.byType(ListView), const Offset(0, -1200)); await tester.pump(); } await tester.pumpAndSettle(); expect(adapter.requests, hasLength(2)); expect( adapter.requests.last.uri.queryParameters['cursor'], 'c1', reason: '第二页携带游标 c1', ); expect( container.read(albumListProvider).items, hasLength(30), reason: '两页累积', ); }); testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async { var failFirst = true; final adapter = FakeAdapter((o) { if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}'); if (failFirst) { failFirst = false; return (500, errorBody(ErrorCodes.internalError)); } return (200, cursorPageBody([albumJson()])); }); await pumpTab(tester, clientOf(adapter)); expect( find.text('Service temporarily unavailable. Please retry later'), findsOneWidget, ); await tester.tap(find.text('Retry')); await tester.pumpAndSettle(); expect(find.text('Opening Gala'), findsOneWidget); }); }