Files
photo-live/apps/mobile/test/pages/albums_tab_test.dart
T
浅念 cf490c7892 feat(app): all album date displays date-only — formatAlbumDateTime drops HH:mm
相册列表卡/详情信息/分享页 meta/照片查看器日期一律纯日期(y/M/d)。
全量测试断言同步;397/397 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:20:47 +08:00

241 lines
8.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册列表页签 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<void> 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 ~ 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(<Map<String, dynamic>>[]))
: 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<Map<String, dynamic>>.generate(
20,
(i) => albumJson(id: 'alb-${i + 1}'),
);
final page2 = List<Map<String, dynamic>>.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);
});
}