Files
photo-live/apps/mobile/test/pages/album_workspace_ui03_test.dart
T
2026-09-22 00:57:59 +08:00

420 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.
/// UI-03 页面 05-08(相册工作台)重设计回归。
///
/// 覆盖:相册列表整图卡片(白字标题/时间场地叠底部暗色渐变、叠层状态
/// 小徽章、无封面同尺寸占位、标题副标题与右上新建入口);相册详情四入口
/// (≥44dp 触控)、Recent photos 三列网格与 See all 跳转、底部栏避让
/// 安全区;操作菜单按角色过滤(先关面板再导航、Cancel 仅关闭);空相册
/// 安静占位(协作者只读说明);上传关闭禁用双入口;处理中照片指示。
library;
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/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_detail_page.dart';
import 'package:photo_live/pages/album_form_page.dart';
import 'package:photo_live/pages/album_members_page.dart';
import 'package:photo_live/pages/album_photos_page.dart';
import 'package:photo_live/pages/albums_tab.dart';
import 'package:photo_live/ui/components/photo_thumbnail.dart';
import '../helpers/fakes.dart';
void main() {
late FakeSessionStore store;
setUp(() {
store = FakeSessionStore();
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
/// 固定逻辑宽度(dp=1)。
void setSurface(WidgetTester tester, Size size) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
}
/// 相册列表页签(默认 en)。
Future<void> pumpTab(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [...pageTestOverrides(client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumsTab(),
),
),
);
await tester.pumpAndSettle();
}
/// 相册详情页(默认管理者 + 空照片;可注入照片/协作者/上传关闭)。
Future<void> pumpDetail(
WidgetTester tester,
ApiClient client,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [...pageTestOverrides(session: store, client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AlbumDetailPage(albumId: 'alb-1'),
),
),
);
await tester.pumpAndSettle();
}
/// 详情页路由打桩:GET 详情 / GET 照片(scope 自适应)。
FakeAdapter detailAdapter({
List<Map<String, dynamic>>? photos,
bool collaborator = false,
bool uploadEnabled = true,
}) =>
FakeAdapter((o) {
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
return (200, albumJson(uploadEnabled: uploadEnabled));
}
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>>[]));
}
throw StateError('unexpected ${o.method} ${o.path}');
});
List<Map<String, dynamic>> photos(int count, {String processing = 'ready'}) =>
List<Map<String, dynamic>>.generate(
count,
(i) => adminPhotoJson(
photoId: 'ph-${(i + 1).toString().padLeft(2, '0')}',
processingState: processing,
preview: processing == 'ready' ? photoPreviewJson() : null,
),
);
testWidgets('页面 05:整图卡片——白字标题/时间地点叠底部暗色渐变', (tester) async {
setSurface(tester, const Size(390, 844)); // 手机单列
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
// 标题白字、时间场地 white70,叠加在图片上(非卡片外独立信息区)。
final title = tester.widget<Text>(find.text('Opening Gala'));
expect(title.style?.color, Colors.white);
final meta = tester.widget<Text>(
find.text('2026/9/18 14:30 ~ Not set · 大宴会厅'),
);
expect(meta.style?.color, Colors.white70);
// 底部暗色渐变叠层存在(LinearGradient 的 DecoratedBox)。
expect(
find.byWidgetPredicate(
(w) =>
w is DecoratedBox &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).gradient is LinearGradient,
),
findsOneWidget,
);
// 叠层状态小徽章:半透明深色底 + 白字(图标语义色提亮变体)。
final badge = tester.widget<Text>(find.text('Public'));
expect(badge.style?.color, Colors.white);
expect(find.text('Uploads on'), findsOneWidget);
// 单卡片 = 单 Card(无信息白卡分包)。
expect(find.byType(Card), findsOneWidget);
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('页面 05:无封面 → 同尺寸中性占位(整卡 16:9)', (tester) async {
setSurface(tester, const Size(390, 844)); // 手机单列
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
expect(find.byType(PhotoThumbnail), findsOneWidget);
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
// 整卡 16:9:缩略图(Positioned.fill 铺满卡片)宽 = 卡片宽、高 = 9/16。
final card = tester.getRect(find.byType(Card));
final thumb = tester.getRect(find.byType(PhotoThumbnail));
expect(card.width, closeTo(358, 0.5));
expect(thumb.width, closeTo(358, 0.5));
expect(thumb.height, closeTo(358 * 9 / 16, 0.5), reason: '16:9 基准高度');
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
testWidgets('页面 05:标题 + 副标题 + 右上「新建」入口', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody([albumJson()]))
: throw StateError('unexpected ${o.path}'),
);
await pumpTab(tester, clientOf(adapter));
expect(find.text('Albums'), findsOneWidget);
expect(find.text('Organize and manage event photos'), findsOneWidget);
// 右上「+」(圆形填充按钮)→ 创建表单。
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byType(AlbumFormPage), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget);
});
testWidgets('页面 06:四入口齐全且有效触控区 ≥44dp', (tester) async {
final adapter = detailAdapter(photos: photos(2));
await pumpDetail(tester, clientOf(adapter));
// 入口文字限定在四入口卡内(页面下方「分类管理」分区标题同为
// Categories 文案,需按 Card 作用域区分)。
final tilesCard = find.byType(Card);
expect(tilesCard, findsOneWidget);
for (final label in ['Photos', 'Members', 'Categories', 'Share']) {
final labelInCard = find.descendant(
of: tilesCard,
matching: find.text(label),
);
expect(labelInCard, findsOneWidget, reason: '$label 入口存在');
final tile = find.ancestor(
of: labelInCard,
matching: find.byType(InkWell),
);
expect(tile, findsOneWidget);
final rect = tester.getRect(tile);
expect(rect.width, greaterThanOrEqualTo(44), reason: '$label 宽 ≥44');
expect(rect.height, greaterThanOrEqualTo(44), reason: '$label 高 ≥44');
}
});
testWidgets('页面 06:Recent photos 三列网格(≤9)+ See all 跳转',
(tester) async {
final adapter = detailAdapter(photos: photos(9));
await pumpDetail(tester, clientOf(adapter));
await tester.ensureVisible(find.text('Recent photos'));
await tester.pumpAndSettle();
final grid = find.byType(GridView);
expect(grid, findsOneWidget);
// 9 张缩略图全部入格(签名 URL 测试环境失败 → 替代图,不破图)。
final thumbs = find
.descendant(of: grid, matching: find.byType(PhotoThumbnail))
.evaluate();
expect(thumbs, hasLength(9));
// 第一行 3 张:第 4 张位于第 1 张下方(三列)。
final first = tester.getRect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).first,
);
final fourth = tester.getRect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail)).at(3),
);
expect(fourth.top, greaterThan(first.top));
expect(
(fourth.left - first.left).abs(),
lessThan(1),
reason: '三列网格第 4 张与第 1 张同列',
);
// See all → 照片管理页。
await tester.tap(find.text('See all'));
await tester.pumpAndSettle();
expect(find.byType(AlbumPhotosPage), findsOneWidget);
});
testWidgets('页面 07:操作菜单(管理者)——先关面板再导航', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
// 管理者五项:Edit album / Manage members / Categories / Share album /
// Cancel。条目断言限定在面板内(页面下方「Categories」与四入口文案
// 同名)。
final sheet = find.byType(AlbumActionSheet);
expect(sheet, findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Edit album')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Manage members')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Categories')),
findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Share album')),
findsOneWidget);
expect(
find.descendant(of: sheet, matching: find.text('Cancel')),
findsOneWidget);
// 点击动作:面板先关闭,再进入目标页(不叠层路由错乱)。表单页标题
// 同为「Edit album」,以面板组件类型判定关闭。
expect(find.byType(AlbumFormPage), findsNothing);
await tester.tap(find.descendant(
of: sheet,
matching: find.text('Edit album'),
));
await tester.pumpAndSettle();
expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭');
expect(find.byType(AlbumFormPage), findsOneWidget);
});
testWidgets('页面 07:操作菜单按角色过滤(协作者仅分享)', (tester) async {
final adapter = detailAdapter(photos: photos(1), collaborator: true);
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
final sheet = find.byType(AlbumActionSheet);
expect(sheet, findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Edit album')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Manage members')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Categories')),
findsNothing);
expect(find.descendant(of: sheet, matching: find.text('Share album')),
findsOneWidget);
// Cancel 仅关闭面板,不导航。
await tester.tap(
find.descendant(of: sheet, matching: find.text('Cancel')),
);
await tester.pumpAndSettle();
expect(find.byType(AlbumActionSheet), findsNothing, reason: '面板已关闭');
expect(find.byType(AlbumMembersPage), findsNothing);
});
testWidgets('页面 07:菜单「Manage members」共享详情导航方法', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
final sheet = find.byType(AlbumActionSheet);
await tester.tap(find.descendant(
of: sheet,
matching: find.text('Manage members'),
));
await tester.pumpAndSettle();
expect(find.byType(AlbumMembersPage), findsOneWidget);
});
testWidgets('页面 08:空相册安静占位(管理者)+ 上传入口可用', (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.byType(GridView), findsNothing);
expect(find.textContaining('uploads'), findsNothing);
// 底部上传栏保留(上传开启)。
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Capture and upload'),
)
.onPressed,
isNotNull,
);
});
testWidgets('页面 08:空相册(协作者)显示只读说明', (tester) async {
final adapter = detailAdapter(collaborator: true);
await pumpDetail(tester, clientOf(adapter));
expect(find.text('Ready for your first photo'), findsOneWidget);
expect(
find.text('You can browse photos shared by others'),
findsOneWidget,
);
expect(
find.text('Photos you take or pick will appear here'),
findsNothing,
);
});
testWidgets('页面 06/08:上传关闭 → 双入口禁用 + 原因说明', (tester) async {
final adapter = detailAdapter(uploadEnabled: false);
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('页面 06:底部固定栏避让安全区(SafeArea 包裹)', (tester) async {
final adapter = detailAdapter(photos: photos(1));
await pumpDetail(tester, clientOf(adapter));
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
expect(
find.ancestor(of: capture, matching: find.byType(SafeArea)),
findsOneWidget,
reason: '底部栏按钮位于 SafeArea 内',
);
});
testWidgets('页面 08:有处理中照片 → 「Processing...」指示,非空白',
(tester) async {
final adapter = detailAdapter(
photos: [...photos(2), ...photos(1, processing: 'processing')],
);
await pumpDetail(tester, clientOf(adapter));
await tester.ensureVisible(find.text('Recent photos'));
await tester.pumpAndSettle();
// 处理中提示存在;网格照常展示(真实状态,不当绝对空白)。
expect(find.text('Processing...'), findsOneWidget);
expect(find.text('Ready for your first photo'), findsNothing);
final grid = find.byType(GridView);
expect(
find.descendant(of: grid, matching: find.byType(PhotoThumbnail))
.evaluate(),
hasLength(3),
);
});
}