Files
photo-live/apps/mobile/test/pages/album_photos_page_test.dart
T
2026-09-21 15:55:44 +08:00

330 lines
12 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)。
///
/// 覆盖:网格列数(极窄 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<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('网格列数: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 参数',
);
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;