Files
photo-live/apps/mobile/test/pages/album_detail_page_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

426 lines
16 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-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 ~ 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 {}
}