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

556 lines
20 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.2 重构后)。
///
/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 →
/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级
/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上
/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类
/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍
/// 页与多选入队。
library;
import 'dart:io';
import 'package:camera/camera.dart' show XFile;
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/notification_permission.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/enqueue.dart';
import 'package:photo_live/features/uploads/preparer.dart';
import 'package:photo_live/features/uploads/providers.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_detail_page.dart';
import 'package:photo_live/pages/capture_page.dart';
import '../helpers/fakes.dart';
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
void main() {
late FakeSessionStore store;
late List<Map<String, dynamic>> categories;
setUp(() {
store = FakeSessionStore();
categories = <Map<String, dynamic>>[
categoryJson(id: 'cat-1', sortOrder: 0),
];
});
/// 路由:GET 详情(反映可变分类表)/ GET 照片预览(scope 自适应;默认
/// 管理者空列表)/ 分类 CRUD;其余路径直接抛错。
FakeAdapter detailAdapter({
List<Map<String, dynamic>>? photos,
bool collaborator = false,
(int, Object?)? Function(String method, String path)? onCategoryMutation,
}) =>
FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
return (200, albumJson(categories: categories));
}
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
final scope = o.uri.queryParameters['scope'];
if (collaborator && scope == 'all') {
return (403, errorBody(ErrorCodes.authForbidden));
}
return (200, cursorPageBody(photos ?? <Map<String, dynamic>>[]));
}
if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') {
return onCategoryMutation?.call('POST', o.path) ??
(201, categoryJson(id: 'cat-new'));
}
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
return onCategoryMutation?.call('PATCH', o.path) ??
(200, categoryJson(id: 'cat-1'));
}
if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') {
return onCategoryMutation?.call('DELETE', o.path) ?? (204, null);
}
throw StateError('unexpected ${o.method} ${o.path}');
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
// 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道
// 在测试环境挂起,必须注入替身。
overrides: [...pageTestOverrides(session: store, client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumDetailPage(albumId: 'alb-1'),
),
),
);
await tester.pumpAndSettle();
}
/// 新布局下分类区在折叠线以下:先滚动到位再交互。
Future<void> reveal(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
}
testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏',
(tester) async {
final adapter = detailAdapter(
photos: List<Map<String, dynamic>>.generate(
3,
(i) => adminPhotoJson(
photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}',
preview: photoPreviewJson(),
),
),
);
await pumpDetail(tester, clientOf(adapter));
// 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行
// 时间/场地(相册时区墙钟 + 场地语言回退)。
final body = find.byType(SingleChildScrollView);
expect(
find.descendant(of: body, matching: find.text('Opening Gala')),
findsOneWidget,
);
expect(
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'),
findsOneWidget,
);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 预览条缩略图(签名 URL 测试环境失败 → 替代图,不破图)。
expect(find.byIcon(Icons.broken_image_outlined), findsNWidgets(3));
// 状态区:公开/上传徽章(关闭不等于不公开,分别呈现)。
expect(find.text('Public'), findsOneWidget);
expect(find.text('Uploads on'), findsOneWidget);
expect(find.text('Download'), findsNothing);
expect(find.text('Original'), findsNothing);
expect(find.text('sharetoken000000000001'), findsNothing);
// 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。
expect(find.text('Manage photos'), findsOneWidget);
// 次级操作(ListTile,不再与主操作同级)。
expect(find.widgetWithText(ListTile, 'Edit'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Members'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Categories'), findsOneWidget);
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery');
expect(capture, findsOneWidget);
expect(pick, findsOneWidget);
// 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内,
// 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。
final titleTop = tester
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
.top;
final badgeTop = tester.getRect(find.text('Public')).top;
final manageTop = tester.getRect(find.text('Manage photos')).top;
final captureTop = tester.getRect(capture).top;
expect(titleTop, lessThan(badgeTop));
expect(badgeTop, lessThan(manageTop));
expect(manageTop, lessThan(captureTop));
});
testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)',
(tester) async {
await pumpDetail(tester, clientOf(detailAdapter()));
expect(find.text('No photos yet'), findsOneWidget);
final cta = find.widgetWithText(FilledButton, 'Take the first photo');
expect(cta, findsOneWidget);
expect(
tester.widget<FilledButton>(cta).onPressed,
isNotNull,
reason: '上传开启时引导可用',
);
});
testWidgets('上传关闭:底部栏说明原因并禁用双入口 + 引导禁用', (tester) async {
final adapter = FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
return (200, albumJson(categories: categories, uploadEnabled: false));
}
if (o.path == '/v1/albums/alb-1/photos' && o.method == 'GET') {
return (200, cursorPageBody(<Map<String, dynamic>>[]));
}
throw StateError('unexpected ${o.method} ${o.path}');
});
await pumpDetail(tester, clientOf(adapter));
expect(
find.text(
'Uploads are off for this album. New photos cannot be added right now',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Capture and upload'),
)
.onPressed,
isNull,
);
expect(
tester
.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Pick from gallery'),
)
.onPressed,
isNull,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Take the first photo'),
)
.onPressed,
isNull,
reason: '空态引导与底部栏同一开关',
);
});
testWidgets('协作者(all 403 → own):隐藏管理入口,仅见自己上传预览',
(tester) async {
final adapter = detailAdapter(
photos: <Map<String, dynamic>>[
adminPhotoJson(
photoId: 'ph-own-1',
uploaderAccount: 'staff02',
preview: photoPreviewJson(),
),
],
collaborator: true,
);
await pumpDetail(tester, clientOf(adapter));
// scope 自适应:all(403)→ own(200)。
final photoRequests = adapter.requests
.where((r) => r.path == '/v1/albums/alb-1/photos')
.toList();
expect(photoRequests, hasLength(2));
expect(photoRequests.first.uri.queryParameters['scope'], 'all');
expect(photoRequests.last.uri.queryParameters['scope'], 'own');
// 管理入口仅创建者/管理员可见;分享对全体成员开放。
expect(find.widgetWithText(ListTile, 'Edit'), findsNothing);
expect(find.widgetWithText(ListTile, 'Members'), findsNothing);
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
// 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
});
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
var posted = false;
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'POST') {
posted = true;
categories = <Map<String, dynamic>>[
...categories,
categoryJson(
id: 'cat-new',
sortOrder: 5,
translations: <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
],
),
];
return (201, categoryJson(id: 'cat-new', sortOrder: 5));
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.byIcon(Icons.add_circle_outline));
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Chinese name'),
'签到',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'English name'),
'Check-in',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Sort order'),
'5',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(posted, isTrue);
// 成功后详情重载会追加 GET,故按方法+路径取 POST。
final request = adapter.requests.singleWhere(
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories',
);
expect(request.path, '/v1/albums/alb-1/categories');
expect(request.body, <String, dynamic>{
'sortOrder': 5,
'translations': <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
],
});
// 详情重载后列表含新分类。
expect(find.text('签到 · Check-in'), findsOneWidget);
});
testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async {
final adapter = detailAdapter();
await pumpDetail(tester, clientOf(adapter));
final requestsBefore = adapter.requests.length;
await reveal(tester, find.byIcon(Icons.add_circle_outline));
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pump();
expect(
find.text('At least one language name is required'),
findsOneWidget,
);
expect(adapter.requests, hasLength(requestsBefore));
});
testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder',
(tester) async {
var patched = false;
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'PATCH') {
patched = true;
categories = <Map<String, dynamic>>[
categoryJson(
id: 'cat-1',
sortOrder: 2,
translations: <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
),
];
return (200, categoryJson(id: 'cat-1'));
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.text('仪式 · Ceremony'));
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
await tester.pumpAndSettle();
// 预填检查。
final zhField = tester.widget<TextFormField>(
find.widgetWithText(TextFormField, 'Chinese name'),
);
expect(zhField.controller!.text, '仪式');
await tester.enterText(
find.widgetWithText(TextFormField, 'Chinese name'),
'签到墙',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Sort order'),
'2',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(patched, isTrue);
// 成功后详情重载会追加 GET,故按方法+路径取 PATCH。
final request = adapter.requests.singleWhere(
(r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1',
);
expect(request.body, <String, dynamic>{
'sortOrder': 2,
'translations': <Map<String, dynamic>>[
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
],
});
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
});
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
final adapter = detailAdapter(
onCategoryMutation: (method, path) {
if (method == 'DELETE') {
categories = <Map<String, dynamic>>[];
return (204, null);
}
return null;
},
);
await pumpDetail(tester, clientOf(adapter));
await reveal(tester, find.byIcon(Icons.delete_outline));
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
await tester.pumpAndSettle();
expect(
find.text('Delete this category? Its photos will become uncategorized.'),
findsOneWidget,
);
// 取消可用:先取消再重删,验证确认门。
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('仪式 · Ceremony'), findsOneWidget);
await reveal(tester, find.byIcon(Icons.delete_outline));
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(
adapter.requests.any(
(r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1',
),
isTrue,
);
expect(find.text('仪式 · Ceremony'), findsNothing);
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
});
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
late Directory tmp;
late List<String> galleryPaths;
late TaskStore taskStore;
setUp(() async {
store.session = Session(
token: 'tok-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'alice',
);
tmp = await Directory.systemTemp.createTemp('detail-upload-');
galleryPaths = <String>[];
taskStore = TaskStore.memory();
});
tearDown(() async {
await taskStore.close();
await tmp.delete(recursive: true);
});
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
/// 非平台相机源)。
Future<void> pumpDetailForUpload(WidgetTester tester) async {
final adapter = detailAdapter();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: store,
taskStore: taskStore,
client: clientOf(adapter),
),
multiImagePickerProvider
.overrideWith((ref) => () async => galleryPaths),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
taskStore,
baseDirProvider: () async => tmp,
),
),
notificationPermissionProvider.overrideWith(
(ref) => NotificationPermission(isAndroid: false),
),
captureSourceFactoryProvider
.overrideWith((ref) => () async => _StaticCaptureSource()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumDetailPage(albumId: 'alb-1'),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async {
await pumpDetailForUpload(tester);
await tester.tap(find.text('Capture and upload'));
await tester.pumpAndSettle();
expect(find.byType(CapturePage), findsOneWidget);
// 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。
expect(
find.descendant(
of: find.byType(CapturePage),
matching: find.text('Opening Gala'),
),
findsOneWidget,
);
});
testWidgets('「从相册选择」→ 多选入队 + 入队数提示', (tester) async {
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
galleryPaths.addAll([pick1.path, pick2.path]);
await pumpDetailForUpload(tester);
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
await tester.runAsync(() async {
(button.evaluate().single.widget as OutlinedButton).onPressed!();
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pumpAndSettle();
expect(
find.text('Added 2 photo(s) to the upload queue'),
findsOneWidget,
);
final tasks = await taskStore.listByAccount('alice');
expect(tasks, hasLength(2));
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
});
});
}
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
class _StaticCaptureSource implements CaptureSource {
@override
Future<void> initialize() async {}
@override
Future<XFile> capture() => throw UnimplementedError();
@override
Widget buildPreview() => const SizedBox();
@override
Future<void> dispose() async {}
}