Files
photo-live/apps/mobile/test/pages/album_detail_page_test.dart
T

426 lines
16 KiB
Dart
Raw Normal View History

/// 相册详情页 widget 测试(UI-03 §4 页面 06-08 重设计后)。
///
/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/
/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部
/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转
/// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。
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_categories_page.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('布局结构:封面 → 信息 → 四入口 → Recent photos → 底部上传栏',
(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);
// Recent photos 网格缩略图(签名 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);
// Recent photos:分区标题 + 「See all」入口 + 三列网格。
expect(find.text('Recent photos'), findsOneWidget);
expect(find.text('See all'), findsOneWidget);
// 次级四入口(管理者全量)。入口文字限定在四入口卡内(页面下方
// 「分类管理」分区标题同为 Categories 文案,按 Card 作用域区分)。
final tilesCard = find.byType(Card).first;
expect(find.descendant(of: tilesCard, matching: find.text('Photos')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Members')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Categories')),
findsOneWidget);
expect(find.descendant(of: tilesCard, matching: find.text('Share')),
findsOneWidget);
// 顶栏「⋯」操作菜单入口。
expect(find.byIcon(Icons.more_vert), findsOneWidget);
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery');
expect(capture, findsOneWidget);
expect(pick, findsOneWidget);
// 垂直顺序:标题 → 徽章 → 四入口 → Recent photos → 底部栏。全宽
// 16:9 封面较高,先滚动让 Recent photos 入屏再比较几何位置。
await reveal(tester, find.text('Recent photos'));
final titleTop = tester
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
.top;
final badgeTop = tester.getRect(find.text('Public')).top;
final tilesTop = tester
.getRect(find.descendant(of: tilesCard, matching: find.text('Photos')))
.top;
final recentTop = tester.getRect(find.text('Recent photos')).top;
final captureTop = tester.getRect(capture).top;
expect(titleTop, lessThan(badgeTop));
expect(badgeTop, lessThan(tilesTop));
expect(tilesTop, lessThan(recentTop));
expect(recentTop, lessThan(captureTop));
});
testWidgets('无照片:空相册安静占位「Ready for your first photo」',
(tester) async {
await pumpDetail(tester, clientOf(detailAdapter()));
expect(find.text('Ready for your first photo'), findsOneWidget);
expect(
find.text('Photos you take or pick will appear here'),
findsOneWidget,
);
// 空态无伪造计数/示例照片,上传入口收敛到底部栏(上传开启时可用)。
expect(find.text('No photos yet'), findsNothing);
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
expect(
tester.widget<FilledButton>(capture).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,
);
});
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');
// 管理入口仅创建者/管理员可见;Photos/Share 对全体成员开放。
expect(find.text('Members'), findsNothing);
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
expect(find.text('Photos'), findsOneWidget);
expect(find.text('Share'), findsOneWidget);
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
// 预览网格退化为自己的上传(1 张;封面占位另 1 张)。
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
});
testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async {
final adapter = detailAdapter();
await pumpDetail(tester, clientOf(adapter));
// 旧内嵌分类管理区块不再存在于详情页。
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
expect(find.text('仪式 · Ceremony'), findsNothing);
// Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动
// 可见再点击。
final tilesCard = find.byType(Card).first;
final categoriesTile =
find.descendant(of: tilesCard, matching: find.text('Categories'));
await reveal(tester, categoriesTile);
await tester.tap(categoriesTile);
await tester.pumpAndSettle();
expect(find.byType(AlbumCategoriesPage), findsOneWidget);
expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)');
// 返回后详情重载(分类随详情返回)。
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(AlbumCategoriesPage), findsNothing);
expect(
adapter.requests
.where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1')
.length,
greaterThanOrEqualTo(2),
);
});
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) => ({bool preferFront = false}) 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
bool get supportsFlash => false;
@override
Future<void> setFlashEnabled(bool enabled) async {}
@override
Future<void> dispose() async {}
}