feat(mobile): UI-01 album cards, photo grid and full viewer
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
||||
/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。
|
||||
///
|
||||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 →
|
||||
/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级
|
||||
/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上
|
||||
/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类
|
||||
/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍
|
||||
/// 页与多选入队。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -12,6 +16,7 @@ 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';
|
||||
@@ -40,16 +45,27 @@ void main() {
|
||||
];
|
||||
});
|
||||
|
||||
/// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
||||
/// 路由: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'));
|
||||
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) ??
|
||||
@@ -70,11 +86,9 @@ void main() {
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
// 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道
|
||||
// 在测试环境挂起,必须注入替身。
|
||||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -85,31 +99,165 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
/// 新布局下分类区在折叠线以下:先滚动到位再交互。
|
||||
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));
|
||||
|
||||
// 信息区(标题同时出现在 AppBar 与信息卡)。
|
||||
expect(find.text('开幕晚宴'), findsNWidgets(2));
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
// 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行
|
||||
// 时间/场地(相册时区墙钟 + 场地语言回退)。
|
||||
final body = find.byType(SingleChildScrollView);
|
||||
expect(
|
||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'),
|
||||
find.descendant(of: body, matching: find.text('Opening Gala')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||||
expect(find.text('Upload'), findsNWidgets(2));
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
expect(find.text('Members'), findsOneWidget);
|
||||
// 「Categories」同时出现在动作按钮与分区标题。
|
||||
expect(find.text('Categories'), findsNWidgets(2));
|
||||
expect(find.text('Share QR code'), findsOneWidget);
|
||||
expect(find.text('Photos'), findsOneWidget);
|
||||
// 分类条目(zh · en 名称连接)。
|
||||
expect(find.text('仪式 · Ceremony'), 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 {
|
||||
@@ -136,7 +284,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
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(
|
||||
@@ -176,7 +325,8 @@ void main() {
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
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'));
|
||||
@@ -213,7 +363,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('仪式 · Ceremony'));
|
||||
await reveal(tester, find.text('仪式 · Ceremony'));
|
||||
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 预填检查。
|
||||
@@ -260,7 +411,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await reveal(tester, find.byIcon(Icons.delete_outline));
|
||||
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
@@ -272,7 +424,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
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();
|
||||
@@ -287,7 +440,7 @@ void main() {
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('上传入口 FAB(Task 9)', () {
|
||||
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
late TaskStore taskStore;
|
||||
@@ -344,47 +497,33 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||||
testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Capture and upload'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Capture and upload'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CapturePage),
|
||||
matching: find.text('开幕晚宴'),
|
||||
matching: find.text('Opening Gala'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||||
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);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||||
await tester.runAsync(() async {
|
||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Reference in New Issue
Block a user