Files
photo-live/apps/mobile/test/pages/album_photos_page_test.dart
T
2026-09-22 02:45:34 +08:00

415 lines
15 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 测试(UI-01 §4.3;UI-03 §4 页面 17 回归)。
///
/// 覆盖:标题(计数 + 相册副标题)、网格列数(极窄 2 / 默认 3 / 宽屏 5)、
/// 状态角标(处理中/失败/隐藏/删除,每张至多一个)、筛选 chips(选中=
/// 主色蓝;服务端 visibility 重载 / 本地 processing 过滤)、scope 自适应
/// (all 403 → own + 协作者提示)、游标加载第二页、尾格失败重试、空态、
/// 点开大图查看器。
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<void> 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('标题:计数随筛选条目数 + 相册名做副标题', (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(<Map<String, dynamic>>[
adminPhotoJson(photoId: 'ph-t1', preview: photoPreviewJson()),
adminPhotoJson(photoId: 'ph-t2', preview: photoPreviewJson()),
adminPhotoJson(photoId: 'ph-t3', preview: photoPreviewJson()),
]),
);
});
await pumpPhotos(tester, clientOf(adapter));
// 计数 = 当前筛选下已加载条目数(游标接口无总量,不伪造总数)。
expect(find.text('3 photos'), findsOneWidget);
// 副标题 = 相册展示名(en 回退)。
expect(find.text('Opening Gala'), findsOneWidget);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
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(<Map<String, dynamic>>[
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(<Map<String, dynamic>>[
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(<Map<String, dynamic>>[
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<Map<String, dynamic>>.generate(
20,
(i) => adminPhotoJson(
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
preview: photoPreviewJson(),
),
);
final page2 = <Map<String, dynamic>>[
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(<Map<String, dynamic>>[
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 参数',
);
// 选中 chip = 主色蓝底(UI-03 页面 17)。
final chip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, 'Published'),
);
expect(chip.selected, isTrue);
expect(chip.selectedColor, Theme.of(tester.element(find.byType(FilterChip).first)).colorScheme.primary);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('尾格:加载更多失败 → 可点重试 → 补齐第二页', (tester) async {
setSurface(tester, const Size(390, 844));
final page1 = List<Map<String, dynamic>>.generate(
20,
(i) => adminPhotoJson(
photoId: 'ph-r${(i + 1).toString().padLeft(2, '0')}',
preview: photoPreviewJson(),
),
);
var cursorCalls = 0;
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: 'rc1'));
}
cursorCalls++;
// 第一次翻页失败(保留已加载条目),重试成功。
if (cursorCalls == 1) {
return (500, errorBody(ErrorCodes.internalError));
}
return (
200,
cursorPageBody(<Map<String, dynamic>>[
adminPhotoJson(photoId: 'ph-r-page2', preview: photoPreviewJson()),
]),
);
});
await pumpPhotos(tester, clientOf(adapter));
// 滚到底触发翻页 → 失败 → 已加载条目保留 + 尾格显示重试入口。
await tester.drag(find.byType(GridView), const Offset(0, -4000));
await tester.pumpAndSettle();
expect(find.text('Failed to load more. Tap to retry'), findsOneWidget);
expect(find.byKey(const ValueKey('grid-photo-ph-r20')), findsOneWidget);
expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsNothing);
// 点重试 → 第二页补齐(重试入口消失、第二页条目可滚动到位)。
await tester.tap(find.text('Failed to load more. Tap to retry'));
await tester.pumpAndSettle();
expect(find.text('Failed to load more. Tap to retry'), findsNothing);
await tester.drag(find.byType(GridView), const Offset(0, -4000));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsOneWidget);
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(<Map<String, dynamic>>[
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(<Map<String, dynamic>>[]));
});
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(<Map<String, dynamic>>[
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<GridView>(find.byType(GridView)).gridDelegate
as SliverGridDelegateWithFixedCrossAxisCount)
.crossAxisCount;