Files
photo-live/apps/mobile/test/pages/album_photo_viewer_test.dart
T
浅念 cf490c7892 feat(app): all album date displays date-only — formatAlbumDateTime drops HH:mm
相册列表卡/详情信息/分享页 meta/照片查看器日期一律纯日期(y/M/d)。
全量测试断言同步;397/397 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:20:47 +08:00

597 lines
21 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 页面 18/20 回归)。
///
/// 覆盖:摄影深色背景 + contain 展示、信息栏(上传者/相册时区时间·尺寸·
/// 大小/状态/ID 诊断区)、左右滑动与方向键切换、临近末尾加载更多、
/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、
/// 隐藏经确认 BottomSheet(标题/缩略图/影响说明/取消不请求/photoId 打开
/// 时捕获/成功回网格角标翻转)、删除确认(软删除文案,与隐藏确认分开)、
/// 「⋯」更多菜单、关闭返回网格原位置、签名 URL 失败一次受控刷新。
library;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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 'package:photo_live/ui/components/state_badge.dart';
import 'package:photo_live/ui/tokens.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;
}
/// 查看器子树内的查找(网格页在路由栈下方仍挂载,需按页面收窄)。
Finder inViewer(Finder finder) => find.descendant(
of: find.byType(AlbumPhotoViewerPage),
matching: finder,
);
testWidgets('深色摄影背景 + contain + 信息栏(上传者/时间·尺寸·大小/状态/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(
tester.widget<Scaffold>(inViewer(find.byType(Scaffold))).backgroundColor,
kColorPhotoBackdrop,
);
// contain 完整展示(不裁切)。
expect(
tester.widget<Image>(inViewer(find.byType(Image))).fit,
BoxFit.contain,
);
// 顶栏:关闭 × + 更多 ⋯。
expect(inViewer(find.byIcon(Icons.close)), findsOneWidget);
expect(inViewer(find.byIcon(Icons.more_vert)), findsOneWidget);
// 信息栏:上传者 / 相册时区墙钟·尺寸·大小 / 真实状态 / ID 诊断区。
expect(find.text('Uploaded by Staff Two'), findsOneWidget);
// uploadedAt 07:00Z → 相册时区上海 15:00;4000×3000 · 121 KB。
expect(find.text('2026/9/18 · 4000×3000 · 121 KB'), findsOneWidget);
expect(find.text('Status: Published'), findsOneWidget);
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('Uploaded by Uploader 1'), findsOneWidget);
// 左右切换:到第二张。
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
expect(find.text('Uploaded by 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') {
throw StateError('unexpected ${o.method} ${o.path}');
}
return (
200,
cursorPageBody(<Map<String, dynamic>>[
adminPhotoJson(
photoId: 'ph-k1',
uploaderName: 'Uploader 1',
preview: photoPreviewJson(),
),
adminPhotoJson(
photoId: 'ph-k2',
uploaderName: 'Uploader 2',
preview: photoPreviewJson(),
),
]),
);
});
await pumpGridAndOpen(tester, clientOf(adapter));
expect(find.text('Uploaded by Uploader 1'), findsOneWidget);
// → 右键下一张。
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(find.text('Uploaded by Uploader 2'), findsOneWidget);
// ← 左键回上一张;再左(已在首张)忽略不越界。
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pumpAndSettle();
expect(find.text('Uploaded by Uploader 1'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pumpAndSettle();
expect(find.text('Uploaded by Uploader 1'), findsOneWidget);
});
testWidgets('管理者操作行:已发布→Hide photo+Delete;已隐藏→Publish again', (
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(),
),
]),
);
}
throw StateError('unexpected ${o.method} ${o.path}');
});
await pumpGridAndOpen(tester, clientOf(adapter));
// 已发布:Hide photo + Delete(管理者,scope=all 成功)。
expect(find.text('Hide photo'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
expect(find.text('Publish again'), findsNothing);
// 切到第二张(隐藏照片):Publish again(仅管理者)+ Delete。
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
expect(find.text('Publish again'), findsOneWidget);
expect(find.text('Hide photo'), findsNothing);
expect(find.text('Delete'), findsOneWidget);
});
testWidgets('Publish again:PATCH published → 操作行翻转为 Hide photo', (
tester,
) async {
final adapter = FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1/photos') {
return (
200,
cursorPageBody(<Map<String, dynamic>>[
adminPhotoJson(
photoId: 'ph-hid',
visibility: 'hidden',
preview: photoPreviewJson(),
),
]),
);
}
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-hid') {
return (200, adminPhotoJson(photoId: 'ph-hid'));
}
throw StateError('unexpected ${o.method} ${o.path}');
});
await pumpGridAndOpen(tester, clientOf(adapter));
await tester.tap(find.text('Publish again'));
await tester.pumpAndSettle();
final patch = adapter.requests.singleWhere(
(r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-hid',
);
expect(patch.body, <String, dynamic>{'visibility': 'published'});
expect(find.text('Hide photo'), findsOneWidget, reason: '恢复后可再隐藏');
expect(find.text('Publish again'), findsNothing);
expect(find.text('Status: Published'), findsOneWidget);
});
testWidgets('隐藏确认 BottomSheet:标题/缩略图/影响说明;取消不请求', (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-sheet', preview: photoPreviewJson()),
]),
);
});
await pumpGridAndOpen(tester, clientOf(adapter));
// 操作行 Hide photo(OutlinedButton)打开确认面板。
await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo'));
await tester.pumpAndSettle();
final sheet = find.descendant(
of: find.byType(BottomSheet),
matching: find.byType(PhotoThumbnail),
);
expect(find.text('Hide this photo?'), findsOneWidget);
expect(
find.text(
'Viewers will no longer see this photo. '
'Authorized album managers can publish it again.',
),
findsOneWidget,
);
expect(sheet, findsOneWidget, reason: '有可见预览 → 面板内展示当前图缩略图');
// 主操作 FilledButton + 取消。
expect(find.widgetWithText(FilledButton, 'Hide photo'), findsOneWidget);
// 取消:不发 PATCH,查看器保持。
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(
adapter.requests.any((r) => r.method == 'PATCH'),
isFalse,
reason: '取消不请求接口',
);
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
});
testWidgets('确认隐藏:photoId 打开时捕获 → PATCH 正确对象 → 回网格角标翻转', (
tester,
) async {
final adapter = FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1/photos') {
return (
200,
cursorPageBody(<Map<String, dynamic>>[
adminPhotoJson(
photoId: 'ph-h1',
uploaderName: 'Uploader 1',
preview: photoPreviewJson(),
),
adminPhotoJson(
photoId: 'ph-h2',
uploaderName: 'Uploader 2',
preview: photoPreviewJson(),
),
]),
);
}
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-h2') {
return (200, adminPhotoJson(photoId: 'ph-h2', visibility: 'hidden'));
}
throw StateError('unexpected ${o.method} ${o.path}');
});
// 打开第二张(tile 1 = ph-h2)。
await pumpGridAndOpen(tester, clientOf(adapter), tile: 1);
expect(find.text('Uploaded by Uploader 2'), findsOneWidget);
// 操作行 Hide photo → 面板内 FilledButton Hide photo 确认。
await tester.tap(find.widgetWithText(OutlinedButton, 'Hide photo'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Hide photo'));
await tester.pumpAndSettle();
// 请求作用于打开面板时捕获的照片(ph-h2),不受其他条目影响。
final patch = adapter.requests.singleWhere((r) => r.method == 'PATCH');
expect(patch.path, '/v1/photos/ph-h2');
expect(patch.body, <String, dynamic>{'visibility': 'hidden'});
// 成功:关闭面板 + 关闭查看器回网格;条目状态合并,角标翻转 Hidden。
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
// SnackBar「Hidden」成功提示(chip 同名标签在外,按容器收窄)。
expect(
find.descendant(of: find.byType(SnackBar), matching: find.text('Hidden')),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('grid-photo-ph-h2')),
matching: find.byType(StateBadge),
),
findsOneWidget,
reason: '网格瓦片角标翻转为 Hidden',
);
});
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('Publish again'), 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(
'This will soft-delete the photo. It can be managed by admins. '
'Cloud storage is not immediately purged.',
),
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 {
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-menu', preview: photoPreviewJson()),
]),
);
});
await pumpGridAndOpen(tester, clientOf(adapter));
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
// 菜单项与操作行同一套权限裁决(已发布管理者:Hide photo + Delete)。
final sheetDelete = find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Delete'),
);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Hide photo'),
),
findsOneWidget,
);
expect(sheetDelete, findsOneWidget);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.text('Publish again'),
),
findsNothing,
);
// 菜单删除 → 独立删除确认弹窗;取消不删。
await tester.tap(sheetDelete);
await tester.pumpAndSettle();
expect(find.text('Delete this photo?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(adapter.requests.any((r) => r.method == 'DELETE'), isFalse);
expect(find.byType(AlbumPhotoViewerPage), 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);
});
}