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

357 lines
13 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)。
///
/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + 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);
});
}