2026-09-21 15:55:44 +08:00
|
|
|
|
/// 照片管理网格 widget 测试(UI-01 §4.3)。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
///
|
2026-09-21 15:55:44 +08:00
|
|
|
|
/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/
|
|
|
|
|
|
/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二
|
|
|
|
|
|
/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
import 'package:photo_live/core/error_codes.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
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';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-09-21 15:55:44 +08:00
|
|
|
|
overrides: [...pageTestOverrides(session: store, client: client)],
|
2026-09-19 01:56:38 +08:00
|
|
|
|
child: MaterialApp(
|
|
|
|
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
|
|
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
|
|
|
|
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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));
|
2026-09-19 01:56:38 +08:00
|
|
|
|
final page1 = List<Map<String, dynamic>>.generate(
|
|
|
|
|
|
20,
|
|
|
|
|
|
(i) => adminPhotoJson(
|
|
|
|
|
|
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
|
2026-09-21 15:55:44 +08:00
|
|
|
|
preview: photoPreviewJson(),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
final page2 = <Map<String, dynamic>>[
|
2026-09-21 15:55:44 +08:00
|
|
|
|
adminPhotoJson(photoId: 'ph-page2item', preview: photoPreviewJson()),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
];
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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()),
|
|
|
|
|
|
]),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
await pumpPhotos(tester, clientOf(adapter));
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
await tester.tap(find.widgetWithText(FilterChip, 'Published'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
expect(
|
2026-09-21 15:55:44 +08:00
|
|
|
|
adapter.requests.last.uri.queryParameters['visibility'],
|
|
|
|
|
|
'published',
|
|
|
|
|
|
reason: 'Published 筛选下发服务端 visibility 参数',
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
await tester.pumpAndSettle();
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 本地过滤:无新请求,仅剩处理中条目(含其角标)。
|
|
|
|
|
|
expect(adapter.requests, hasLength(requestsBefore));
|
2026-09-19 01:56:38 +08:00
|
|
|
|
expect(
|
2026-09-21 15:55:44 +08:00
|
|
|
|
find.descendant(of: find.byType(StateBadge), matching: find.text('Processing')),
|
|
|
|
|
|
findsOneWidget,
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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();
|
2026-09-19 01:56:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
/// 网格列数(GridView delegate 断言用)。
|
|
|
|
|
|
int _columnCount(WidgetTester tester) =>
|
|
|
|
|
|
(tester.widget<GridView>(find.byType(GridView)).gridDelegate
|
|
|
|
|
|
as SliverGridDelegateWithFixedCrossAxisCount)
|
|
|
|
|
|
.crossAxisCount;
|