f6929ae18f
Co-Authored-By: Claude Code <noreply@anthropic.com>
357 lines
13 KiB
Dart
357 lines
13 KiB
Dart
/// 照片大图查看器 widget 测试(UI-01 §4.3)。
|
||
///
|
||
/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、
|
||
/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、
|
||
/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次
|
||
/// 受控刷新、临近末尾加载更多。
|
||
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/core/session_store.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 '../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,
|
||
);
|
||
|
||
/// 网格 + 可选打开查看器;返回打开前的网格 Scrollable 像素偏移。
|
||
Future<double> pumpGridAndOpen(
|
||
WidgetTester tester,
|
||
ApiClient client, {
|
||
int tile = 0,
|
||
}) 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();
|
||
final gridScrollable = find.descendant(
|
||
of: find.byType(GridView),
|
||
matching: find.byType(Scrollable),
|
||
);
|
||
final before = tester
|
||
.state<ScrollableState>(gridScrollable)
|
||
.position
|
||
.pixels;
|
||
await tester.tap(find.byType(PhotoThumbnail).at(tile));
|
||
await tester.pumpAndSettle();
|
||
return before;
|
||
}
|
||
|
||
testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (tester) async {
|
||
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-info-1', preview: photoPreviewJson()),
|
||
]),
|
||
);
|
||
});
|
||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||
|
||
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||
expect(find.text('Staff Two'), findsOneWidget);
|
||
// uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。
|
||
expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget);
|
||
// ID 放诊断区域(弱化呈现)。
|
||
expect(find.text('ph-info-1'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('左右切换已加载照片 + 临近末尾加载更多', (tester) async {
|
||
final page1 = List<Map<String, dynamic>>.generate(
|
||
20,
|
||
(i) => adminPhotoJson(
|
||
photoId: 'ph-v${(i + 1).toString().padLeft(2, '0')}',
|
||
uploaderName: 'Uploader ${i + 1}',
|
||
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: 'vc1'));
|
||
}
|
||
if (cursor == 'vc1') {
|
||
return (
|
||
200,
|
||
cursorPageBody(<Map<String, dynamic>>[
|
||
adminPhotoJson(photoId: 'ph-vpage2', preview: photoPreviewJson()),
|
||
]),
|
||
);
|
||
}
|
||
throw StateError('unexpected cursor $cursor');
|
||
});
|
||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||
|
||
// 初始第一张。
|
||
expect(find.text('Uploader 1'), findsOneWidget);
|
||
// 左右切换:到第二张。
|
||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Uploader 2'), findsOneWidget);
|
||
|
||
// 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。
|
||
for (var i = 0; i < 20; i++) {
|
||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||
await tester.pumpAndSettle();
|
||
}
|
||
expect(
|
||
adapter.requests.any((r) => r.uri.queryParameters['cursor'] == 'vc1'),
|
||
isTrue,
|
||
reason: '临近末尾按游标加载第二页',
|
||
);
|
||
});
|
||
|
||
testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async {
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path == '/v1/albums/alb-1/photos') {
|
||
return (
|
||
200,
|
||
cursorPageBody(<Map<String, dynamic>>[
|
||
adminPhotoJson(photoId: 'ph-pub', preview: photoPreviewJson()),
|
||
adminPhotoJson(
|
||
photoId: 'ph-hid',
|
||
visibility: 'hidden',
|
||
preview: photoPreviewJson(),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') {
|
||
return (
|
||
200,
|
||
adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'),
|
||
);
|
||
}
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
});
|
||
final client = clientOf(adapter);
|
||
await pumpGridAndOpen(tester, client);
|
||
|
||
// 已发布:隐藏 + 删除(管理者,scope=all 成功)。
|
||
expect(find.text('Hide'), findsOneWidget);
|
||
expect(find.text('Delete'), findsOneWidget);
|
||
expect(find.text('Unhide'), findsNothing);
|
||
|
||
// 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。
|
||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('Unhide'), findsOneWidget);
|
||
expect(find.text('Hide'), findsNothing);
|
||
|
||
// 隐藏操作:PATCH visibility=hidden → 按钮翻转为恢复显示。
|
||
await tester.drag(find.byType(PageView), const Offset(700, 0));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Hide'));
|
||
await tester.pumpAndSettle();
|
||
final patch = adapter.requests.singleWhere(
|
||
(r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-pub',
|
||
);
|
||
expect(patch.body, <String, dynamic>{'visibility': 'hidden'});
|
||
expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复');
|
||
expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口');
|
||
});
|
||
|
||
testWidgets('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async {
|
||
store.session = Session(
|
||
token: 'tok-1',
|
||
expiresAt: DateTime.utc(2099, 1, 1),
|
||
account: 'staff02',
|
||
);
|
||
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-hidden',
|
||
visibility: 'hidden',
|
||
uploaderAccount: 'staff02',
|
||
preview: null,
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
throw StateError('unexpected scope $scope');
|
||
});
|
||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||
|
||
// 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。
|
||
expect(find.text('Unhide'), findsNothing);
|
||
expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除');
|
||
// 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。
|
||
expect(find.text('Image unavailable'), findsOneWidget);
|
||
expect(find.text('ph-own-hidden'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('删除:确认「云端照片」→ DELETE → 条目移除', (tester) async {
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path == '/v1/albums/alb-1/photos') {
|
||
return (
|
||
200,
|
||
cursorPageBody(<Map<String, dynamic>>[
|
||
adminPhotoJson(photoId: 'ph-del', preview: photoPreviewJson()),
|
||
]),
|
||
);
|
||
}
|
||
if (o.method == 'DELETE' && o.path == '/v1/photos/ph-del') {
|
||
return (204, null);
|
||
}
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
});
|
||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||
|
||
await tester.tap(find.text('Delete'));
|
||
await tester.pumpAndSettle();
|
||
// 确认弹窗:明确影响云端照片。
|
||
expect(find.text('Delete this photo?'), findsOneWidget);
|
||
expect(
|
||
find.text('The cloud photo will be deleted. This cannot be undone.'),
|
||
findsOneWidget,
|
||
);
|
||
// 取消不删。
|
||
await tester.tap(find.text('Cancel'));
|
||
await tester.pumpAndSettle();
|
||
expect(
|
||
adapter.requests.any((r) => r.method == 'DELETE'),
|
||
isFalse,
|
||
reason: '取消不触发删除',
|
||
);
|
||
|
||
await tester.tap(find.text('Delete'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
adapter.requests.any(
|
||
(r) => r.method == 'DELETE' && r.path == '/v1/photos/ph-del',
|
||
),
|
||
isTrue,
|
||
);
|
||
// 唯一照片删除后 → 查看器退回网格(空态)。
|
||
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
|
||
expect(find.text('No photos yet'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('关闭返回网格原滚动位置', (tester) async {
|
||
// 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。
|
||
final items = List<Map<String, dynamic>>.generate(
|
||
24,
|
||
(i) => adminPhotoJson(
|
||
photoId: 'ph-s${(i + 1).toString().padLeft(2, '0')}',
|
||
preview: photoPreviewJson(),
|
||
),
|
||
);
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path != '/v1/albums/alb-1/photos') {
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
}
|
||
return (200, cursorPageBody(items));
|
||
});
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [...pageTestOverrides(session: store, client: clientOf(adapter))],
|
||
child: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
// 先滚动网格(离开顶部)。
|
||
final gridScrollable = find.descendant(
|
||
of: find.byType(GridView),
|
||
matching: find.byType(Scrollable),
|
||
);
|
||
await tester.drag(gridScrollable, const Offset(0, -600));
|
||
await tester.pumpAndSettle();
|
||
final scrolled = tester
|
||
.state<ScrollableState>(gridScrollable)
|
||
.position
|
||
.pixels;
|
||
expect(scrolled, greaterThan(0));
|
||
|
||
// 打开当前视口内可见的一张(滚动后树序首项可能在缓存区/被遮挡)→
|
||
// 关闭 → 偏移保持。
|
||
await tester.tap(find.byType(PhotoThumbnail).hitTestable().first);
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||
await tester.tap(find.byIcon(Icons.close));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
tester.state<ScrollableState>(gridScrollable).position.pixels,
|
||
scrolled,
|
||
reason: '关闭大图后网格回到原滚动位置',
|
||
);
|
||
});
|
||
|
||
testWidgets('签名 URL 失败 → 一次受控刷新(不无限重试)', (tester) async {
|
||
var listCalls = 0;
|
||
final adapter = FakeAdapter((o) {
|
||
if (o.path != '/v1/albums/alb-1/photos') {
|
||
throw StateError('unexpected ${o.method} ${o.path}');
|
||
}
|
||
listCalls++;
|
||
// 刷新后返回新签名 URL(测试环境网络仍失败 → 验证不再重试)。
|
||
return (
|
||
200,
|
||
cursorPageBody(<Map<String, dynamic>>[
|
||
adminPhotoJson(
|
||
photoId: 'ph-url',
|
||
preview: photoPreviewJson(
|
||
browseUrl: 'http://test.local/browse-v$listCalls.jpg',
|
||
),
|
||
),
|
||
]),
|
||
);
|
||
});
|
||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||
|
||
// 初次列表 + 失败后自动刷新一次 = 2 次;再等待后不再增加。
|
||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||
expect(listCalls, 2, reason: '签名 URL 失败仅重取一次');
|
||
expect(find.text('Image unavailable'), findsOneWidget);
|
||
});
|
||
}
|