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

420 lines
16 KiB
Dart
Raw Normal View History

/// 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 ~ 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),
);
});
}