5715c608d8
Co-Authored-By: Claude Code <noreply@anthropic.com>
420 lines
16 KiB
Dart
420 lines
16 KiB
Dart
/// 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),
|
||
);
|
||
});
|
||
}
|