feat(mobile): UI-03 photos and share pages (17-20)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 02:45:34 +08:00
parent ab51616c70
commit 8ee69598bf
14 changed files with 1338 additions and 379 deletions
@@ -1,13 +1,16 @@
/// 照片大图查看器 widget 测试(UI-01 §4.3)。
/// 照片大图查看器 widget 测试(UI-01 §4.3;UI-03 §4 页面 18/20 回归)。
///
/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、
/// 覆盖:摄影深色背景 + contain 展示、信息栏(上传者/相册时区时间·尺寸·
/// 大小/状态/ID 诊断区)、左右滑动与方向键切换、临近末尾加载更多、
/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、
/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次
/// 受控刷新、临近末尾加载更多。
/// 隐藏经确认 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';
@@ -18,6 +21,8 @@ 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';
@@ -64,7 +69,15 @@ void main() {
return before;
}
testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (tester) async {
/// 查看器子树内的查找(网格页在路由栈下方仍挂载,需按页面收窄)。
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}');
@@ -79,10 +92,24 @@ void main() {
await pumpGridAndOpen(tester, clientOf(adapter));
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
expect(find.text('Staff Two'), findsOneWidget);
// uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。
// 恒深色摄影背景(不随系统主题)。
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 15:00 · 4000×3000 · 121 KB'), findsOneWidget);
// ID 放诊断区域(弱化呈现)。
expect(find.text('Status: Published'), findsOneWidget);
expect(find.text('ph-info-1'), findsOneWidget);
});
@@ -116,11 +143,11 @@ void main() {
await pumpGridAndOpen(tester, clientOf(adapter));
// 初始第一张。
expect(find.text('Uploader 1'), findsOneWidget);
expect(find.text('Uploaded by Uploader 1'), findsOneWidget);
// 左右切换:到第二张。
await tester.drag(find.byType(PageView), const Offset(-700, 0));
await tester.pumpAndSettle();
expect(find.text('Uploader 2'), findsOneWidget);
expect(find.text('Uploaded by Uploader 2'), findsOneWidget);
// 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。
for (var i = 0; i < 20; i++) {
@@ -134,7 +161,46 @@ void main() {
);
});
testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async {
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 (
@@ -149,42 +215,163 @@ void main() {
]),
);
}
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') {
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,
adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'),
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}');
});
final client = clientOf(adapter);
await pumpGridAndOpen(tester, client);
await pumpGridAndOpen(tester, clientOf(adapter));
// 已发布:隐藏 + 删除(管理者,scope=all 成功)。
expect(find.text('Hide'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
expect(find.text('Unhide'), findsNothing);
await tester.tap(find.text('Publish again'));
await tester.pumpAndSettle();
// 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。
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',
(r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-hid',
);
expect(patch.body, <String, dynamic>{'visibility': 'hidden'});
expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复');
expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口');
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('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async {
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),
@@ -214,14 +401,16 @@ void main() {
await pumpGridAndOpen(tester, clientOf(adapter));
// 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。
expect(find.text('Unhide'), findsNothing);
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 {
testWidgets('删除确认:软删除文案 + 取消不删 + 确认 DELETE 移除条目', (
tester,
) async {
final adapter = FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1/photos') {
return (
@@ -240,10 +429,13 @@ void main() {
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.'),
find.text(
'This will soft-delete the photo. It can be managed by admins. '
'Cloud storage is not immediately purged.',
),
findsOneWidget,
);
// 取消不删。
@@ -271,6 +463,54 @@ void main() {
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(
@@ -1,8 +1,10 @@
/// 照片管理网格 widget 测试(UI-01 §4.3)。
/// 照片管理网格 widget 测试(UI-01 §4.3;UI-03 §4 页面 17 回归)。
///
/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/
/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二
/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。
/// 覆盖:标题(计数 + 相册副标题)、网格列数(极窄 2 / 默认 3 / 宽屏 5)、
/// 状态角标(处理中/失败/隐藏/删除,每张至多一个)、筛选 chips(选中=
/// 主色蓝;服务端 visibility 重载 / 本地 processing 过滤)、scope 自适应
/// (all 403 → own + 协作者提示)、游标加载第二页、尾格失败重试、空态、
/// 点开大图查看器。
library;
import 'package:dio/dio.dart';
@@ -52,6 +54,32 @@ void main() {
tester.view.devicePixelRatio = 1.0;
}
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-t1', preview: photoPreviewJson()),
adminPhotoJson(photoId: 'ph-t2', preview: photoPreviewJson()),
adminPhotoJson(photoId: 'ph-t3', preview: photoPreviewJson()),
]),
);
});
await pumpPhotos(tester, clientOf(adapter));
// 计数 = 当前筛选下已加载条目数(游标接口无总量,不伪造总数)。
expect(find.text('3 photos'), findsOneWidget);
// 副标题 = 相册展示名(en 回退)。
expect(find.text('Opening Gala'), findsOneWidget);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async {
final adapter = FakeAdapter((o) {
if (o.path != '/v1/albums/alb-1/photos') {
@@ -224,6 +252,63 @@ void main() {
'published',
reason: 'Published 筛选下发服务端 visibility 参数',
);
// 选中 chip = 主色蓝底(UI-03 页面 17)。
final chip = tester.widget<FilterChip>(
find.widgetWithText(FilterChip, 'Published'),
);
expect(chip.selected, isTrue);
expect(chip.selectedColor, Theme.of(tester.element(find.byType(FilterChip).first)).colorScheme.primary);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('尾格:加载更多失败 → 可点重试 → 补齐第二页', (tester) async {
setSurface(tester, const Size(390, 844));
final page1 = List<Map<String, dynamic>>.generate(
20,
(i) => adminPhotoJson(
photoId: 'ph-r${(i + 1).toString().padLeft(2, '0')}',
preview: photoPreviewJson(),
),
);
var cursorCalls = 0;
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: 'rc1'));
}
cursorCalls++;
// 第一次翻页失败(保留已加载条目),重试成功。
if (cursorCalls == 1) {
return (500, errorBody(ErrorCodes.internalError));
}
return (
200,
cursorPageBody(<Map<String, dynamic>>[
adminPhotoJson(photoId: 'ph-r-page2', preview: photoPreviewJson()),
]),
);
});
await pumpPhotos(tester, clientOf(adapter));
// 滚到底触发翻页 → 失败 → 已加载条目保留 + 尾格显示重试入口。
await tester.drag(find.byType(GridView), const Offset(0, -4000));
await tester.pumpAndSettle();
expect(find.text('Failed to load more. Tap to retry'), findsOneWidget);
expect(find.byKey(const ValueKey('grid-photo-ph-r20')), findsOneWidget);
expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsNothing);
// 点重试 → 第二页补齐(重试入口消失、第二页条目可滚动到位)。
await tester.tap(find.text('Failed to load more. Tap to retry'));
await tester.pumpAndSettle();
expect(find.text('Failed to load more. Tap to retry'), findsNothing);
await tester.drag(find.byType(GridView), const Offset(0, -4000));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('grid-photo-ph-r-page2')), findsOneWidget);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
@@ -1,112 +0,0 @@
/// 分享二维码对话框 widget 测试(UI-01 §4.5 分层重构)。
///
/// 覆盖:分层结构(相册标题 → 白底二维码 → URL 文本 → 复制为主/浏览器
/// 打开为次)、QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、
/// 复制按钮写入剪贴板并提示、「在浏览器中打开」经注入的替身启动器
/// 打开 URL 并关闭对话框。
library;
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/config.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/share_qr_dialog.dart';
import 'package:qr_flutter/qr_flutter.dart';
void main() {
const token = 'sharetoken000000000001';
Future<void> pumpDialog(
WidgetTester tester, {
String? albumTitle,
UrlLauncher? launcher,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
if (launcher != null)
urlLauncherProvider.overrideWith((ref) => launcher),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。
home: Scaffold(
body: Center(
child: ShareQrDialog(
publicToken: token,
albumTitle: albumTitle,
),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('分层渲染:相册标题 → 二维码(data 为观众链接)→ URL 文本',
(tester) async {
await pumpDialog(tester, albumTitle: 'Opening Gala');
final url = ApiConfig.viewerAlbumUrl(token);
// qr_flutter 4.1 未公开 QrImageView.data(私有 _data),以同源的
// SelectableText 文本断言 URL 构造(二维码与文本同用该局部变量)。
expect(find.byType(QrImageView), findsOneWidget);
expect(
tester.widget<SelectableText>(find.byType(SelectableText)).data,
url,
);
expect(url, 'http://10.0.2.2:5175/a/$token');
// 分层:主标题 = 相册名,副标题 = 通用标题,脚注 = 扫码/链接提示。
expect(find.text('Opening Gala'), findsOneWidget);
expect(find.text('Share QR code'), findsOneWidget);
expect(find.text('Scan the code or open the link to view'), findsOneWidget);
});
testWidgets('复制按钮(主操作)→ 剪贴板收到 URL + 已复制提示', (tester) async {
final clipboardCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
clipboardCalls.add(call);
return null;
},
);
await pumpDialog(tester);
await tester.tap(find.text('Copy link'));
await tester.pumpAndSettle();
final url = ApiConfig.viewerAlbumUrl(token);
expect(
clipboardCalls.any(
(c) =>
c.method == 'Clipboard.setData' &&
(c.arguments as Map<String, dynamic>)['text'] == url,
),
isTrue,
);
expect(find.text('Copied'), findsOneWidget);
});
testWidgets('在浏览器中打开(次操作)→ 启动器收到 URL + 关闭对话框',
(tester) async {
final launchedUrls = <String>[];
await pumpDialog(
tester,
launcher: (url) async {
launchedUrls.add(url);
return true;
},
);
await tester.tap(find.text('Open in browser'));
await tester.pumpAndSettle();
expect(launchedUrls, [ApiConfig.viewerAlbumUrl(token)]);
expect(find.byType(ShareQrDialog), findsNothing, reason: '打开成功后关闭');
});
}
@@ -0,0 +1,142 @@
/// 相册分享页 widget 测试(UI-03 §4 页面 19)。
///
/// 覆盖:全屏页结构(标题/真实封面占位/名称/日期地点/二维码/扫码说明/
/// 可选中链接)、链接携带正确相册 token 与 `?lang=` 语言参数、Copy link
/// 主操作写剪贴板 + 已复制提示、Open in browser 次操作经注入替身启动器
/// 打开并退出页面、未公开相册显示「该链接目前无法公开浏览」琥珀提示
/// (公开相册不显示)。
library;
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/features/albums/models.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/share_qr_dialog.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../helpers/fakes.dart';
void main() {
const token = 'sharetoken000000000001';
Future<void> pumpPage(
WidgetTester tester, {
bool publicEnabled = true,
UrlLauncher? launcher,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
if (launcher != null)
urlLauncherProvider.overrideWith((ref) => launcher),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: ShareQrPage(
album: Album.fromJson(
albumJson(publicToken: token, publicEnabled: publicEnabled),
),
),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('全屏页结构:标题/封面/名称/日期地点/二维码/扫码说明/链接', (
tester,
) async {
await pumpPage(tester);
// 全屏页 AppBar 标题(不再是 Dialog)。
expect(find.byType(AppBar), findsOneWidget);
expect(find.text('Share album'), findsOneWidget);
// 真实封面位(无封面 → 同尺寸中性占位,不破图不空行)。
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 名称与日期地点(en 回退:Opening Gala;上海墙钟 14:30)。
expect(find.text('Opening Gala'), findsOneWidget);
expect(
find.text('2026/9/18 14:30 · 大宴会厅'),
findsOneWidget,
reason: '开始时间(相册时区;结束未定不伪造区间)+ 场地(展示回退链)',
);
// 二维码真实生成 + 扫码说明 + 可选中链接。
expect(find.byType(QrImageView), findsOneWidget);
expect(find.text('Scan to view photos'), findsOneWidget);
// 测试环境 URL:默认 VIEWER_BASE + 正确 token + en 语言参数。
final url = 'http://10.0.2.2:5175/a/$token?lang=en';
expect(
tester.widget<SelectableText>(find.byType(SelectableText)).data,
url,
reason: '二维码与复制共用同一 URL(?lang= 随 App 语言)',
);
});
testWidgets('Copy link(主操作)→ 剪贴板收到带 lang 的 URL + 已复制提示',
(tester) async {
final clipboardCalls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
clipboardCalls.add(call);
return null;
},
);
await pumpPage(tester);
await tester.ensureVisible(find.text('Copy link'));
await tester.pumpAndSettle();
await tester.tap(find.text('Copy link'));
await tester.pumpAndSettle();
expect(
clipboardCalls.any(
(c) =>
c.method == 'Clipboard.setData' &&
(c.arguments as Map<String, dynamic>)['text'] ==
'http://10.0.2.2:5175/a/$token?lang=en',
),
isTrue,
);
expect(find.text('Copied'), findsOneWidget);
});
testWidgets('Open in browser(次操作)→ 启动器收到 URL + 退出分享页',
(tester) async {
final launchedUrls = <String>[];
await pumpPage(
tester,
launcher: (url) async {
launchedUrls.add(url);
return true;
},
);
await tester.ensureVisible(find.text('Open in browser'));
await tester.pumpAndSettle();
await tester.tap(find.text('Open in browser'));
await tester.pumpAndSettle();
expect(launchedUrls, ['http://10.0.2.2:5175/a/$token?lang=en']);
expect(find.byType(ShareQrPage), findsNothing, reason: '打开成功后退出');
});
testWidgets('未公开相册:琥珀提示链接不可公开浏览;公开相册不提示',
(tester) async {
await pumpPage(tester, publicEnabled: false);
expect(
find.text('This link is not publicly accessible yet'),
findsOneWidget,
);
await pumpPage(tester, publicEnabled: true);
expect(
find.text('This link is not publicly accessible yet'),
findsNothing,
reason: '公开相册不得显示不可浏览提示',
);
});
}