feat(mobile): UI-01 album cards, photo grid and full viewer
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
||||
/// 相册详情页 widget 测试(UI-01 §4.2 重构后)。
|
||||
///
|
||||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
/// 覆盖:紧凑头部(封面占位 + 标题 + 时区时间场地行)→ 状态徽章 →
|
||||
/// 照片入口(最近上传预览条 + 管理照片 / 空态「拍摄第一张照片」)→ 次级
|
||||
/// 管理入口(ListTile)→ 底部固定上传栏(拍摄上传主 / 从相册选择次,上
|
||||
/// 传关闭时禁用并说明);协作者(scope all 403 → own)隐藏管理入口;分类
|
||||
/// 管理三流(新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍
|
||||
/// 页与多选入队。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -12,6 +16,7 @@ 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';
|
||||
@@ -40,16 +45,27 @@ void main() {
|
||||
];
|
||||
});
|
||||
|
||||
/// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
||||
/// 路由: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'));
|
||||
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) ??
|
||||
@@ -70,11 +86,9 @@ void main() {
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
// 详情页触达 sessionStoreProvider(上传入口账号),真实平台通道
|
||||
// 在测试环境挂起,必须注入替身。
|
||||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -85,31 +99,165 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
/// 新布局下分类区在折叠线以下:先滚动到位再交互。
|
||||
Future<void> reveal(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('布局结构:头部 → 状态 → 照片入口 → 次级操作 → 底部上传栏',
|
||||
(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));
|
||||
|
||||
// 信息区(标题同时出现在 AppBar 与信息卡)。
|
||||
expect(find.text('开幕晚宴'), findsNWidgets(2));
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
// 紧凑头部:封面占位(无 cover → 中性占位,非转圈)+ 标题 + 单行
|
||||
// 时间/场地(相册时区墙钟 + 场地语言回退)。
|
||||
final body = find.byType(SingleChildScrollView);
|
||||
expect(
|
||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'),
|
||||
find.descendant(of: body, matching: find.text('Opening Gala')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||||
expect(find.text('Upload'), findsNWidgets(2));
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
expect(find.text('Members'), findsOneWidget);
|
||||
// 「Categories」同时出现在动作按钮与分区标题。
|
||||
expect(find.text('Categories'), findsNWidgets(2));
|
||||
expect(find.text('Share QR code'), findsOneWidget);
|
||||
expect(find.text('Photos'), findsOneWidget);
|
||||
// 分类条目(zh · en 名称连接)。
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
expect(
|
||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai) · 大宴会厅'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
// 预览条缩略图(签名 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);
|
||||
|
||||
// 照片入口:分区标题 + 「管理照片」入口 + 3 张预览缩略图。
|
||||
expect(find.text('Manage photos'), findsOneWidget);
|
||||
|
||||
// 次级操作(ListTile,不再与主操作同级)。
|
||||
expect(find.widgetWithText(ListTile, 'Edit'), findsOneWidget);
|
||||
expect(find.widgetWithText(ListTile, 'Members'), findsOneWidget);
|
||||
expect(find.widgetWithText(ListTile, 'Categories'), findsOneWidget);
|
||||
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
|
||||
|
||||
// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次)。
|
||||
final capture = find.widgetWithText(FilledButton, 'Capture and upload');
|
||||
final pick = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||||
expect(capture, findsOneWidget);
|
||||
expect(pick, findsOneWidget);
|
||||
|
||||
// 垂直顺序:标题 → 徽章 → 管理照片 → 底部栏(次级操作在 body 列内,
|
||||
// 其在树中先于 bottomNavigationBar,几何位置随滚动变化不作断言)。
|
||||
final titleTop = tester
|
||||
.getRect(find.descendant(of: body, matching: find.text('Opening Gala')))
|
||||
.top;
|
||||
final badgeTop = tester.getRect(find.text('Public')).top;
|
||||
final manageTop = tester.getRect(find.text('Manage photos')).top;
|
||||
final captureTop = tester.getRect(capture).top;
|
||||
expect(titleTop, lessThan(badgeTop));
|
||||
expect(badgeTop, lessThan(manageTop));
|
||||
expect(manageTop, lessThan(captureTop));
|
||||
});
|
||||
|
||||
testWidgets('无照片:空态引导「拍摄第一张照片」(上传开启时可用)',
|
||||
(tester) async {
|
||||
await pumpDetail(tester, clientOf(detailAdapter()));
|
||||
|
||||
expect(find.text('No photos yet'), findsOneWidget);
|
||||
final cta = find.widgetWithText(FilledButton, 'Take the first photo');
|
||||
expect(cta, findsOneWidget);
|
||||
expect(
|
||||
tester.widget<FilledButton>(cta).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,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Take the first photo'),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
reason: '空态引导与底部栏同一开关',
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// 管理入口仅创建者/管理员可见;分享对全体成员开放。
|
||||
expect(find.widgetWithText(ListTile, 'Edit'), findsNothing);
|
||||
expect(find.widgetWithText(ListTile, 'Members'), findsNothing);
|
||||
expect(find.text('Categories'), findsNothing, reason: '含分区一并隐藏');
|
||||
expect(find.widgetWithText(ListTile, 'Share QR code'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
|
||||
|
||||
// 预览条退化为自己的上传(1 张;头部封面占位另 1 张)。
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
|
||||
@@ -136,7 +284,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await reveal(tester, find.byIcon(Icons.add_circle_outline));
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
@@ -176,7 +325,8 @@ void main() {
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await reveal(tester, find.byIcon(Icons.add_circle_outline));
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
@@ -213,7 +363,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('仪式 · Ceremony'));
|
||||
await reveal(tester, find.text('仪式 · Ceremony'));
|
||||
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 预填检查。
|
||||
@@ -260,7 +411,8 @@ void main() {
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await reveal(tester, find.byIcon(Icons.delete_outline));
|
||||
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
@@ -272,7 +424,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await reveal(tester, find.byIcon(Icons.delete_outline));
|
||||
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -287,7 +440,7 @@ void main() {
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('上传入口 FAB(Task 9)', () {
|
||||
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
late TaskStore taskStore;
|
||||
@@ -344,47 +497,33 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||||
testWidgets('「拍摄上传」→ 直达连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Capture and upload'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Capture and upload'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:界面语言回退)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CapturePage),
|
||||
matching: find.text('开幕晚宴'),
|
||||
matching: find.text('Opening Gala'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||||
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);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||||
final button = find.widgetWithText(OutlinedButton, 'Pick from gallery');
|
||||
await tester.runAsync(() async {
|
||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||||
(button.evaluate().single.widget as OutlinedButton).onPressed!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/// 照片大图查看器 widget 测试(UI-01 §4.3)。
|
||||
///
|
||||
/// 覆盖:信息栏(上传者/相册时区时间/尺寸大小 + ID 诊断区)、左右切换、
|
||||
/// 权限(管理者隐藏/恢复/删除;协作者自己上传可隐藏删除、不可恢复)、
|
||||
/// 删除确认(云端照片将被删除)、关闭返回网格原位置、签名 URL 失败一次
|
||||
/// 受控刷新、临近末尾加载更多。
|
||||
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/core/session_store.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_photos_page.dart';
|
||||
import 'package:photo_live/pages/album_photo_viewer_page.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,
|
||||
);
|
||||
|
||||
/// 网格 + 可选打开查看器;返回打开前的网格 Scrollable 像素偏移。
|
||||
Future<double> pumpGridAndOpen(
|
||||
WidgetTester tester,
|
||||
ApiClient client, {
|
||||
int tile = 0,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final gridScrollable = find.descendant(
|
||||
of: find.byType(GridView),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
final before = tester
|
||||
.state<ScrollableState>(gridScrollable)
|
||||
.position
|
||||
.pixels;
|
||||
await tester.tap(find.byType(PhotoThumbnail).at(tile));
|
||||
await tester.pumpAndSettle();
|
||||
return before;
|
||||
}
|
||||
|
||||
testWidgets('信息栏:上传者 / 相册时区时间 / 尺寸大小 + ID 诊断区', (tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-info-1', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||
|
||||
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||
expect(find.text('Staff Two'), findsOneWidget);
|
||||
// uploadedAt 07:00Z → 相册时区上海 15:00;尺寸 + 大小。
|
||||
expect(find.text('2026/9/18 15:00 · 4000×3000 · 121 KB'), findsOneWidget);
|
||||
// ID 放诊断区域(弱化呈现)。
|
||||
expect(find.text('ph-info-1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('左右切换已加载照片 + 临近末尾加载更多', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => adminPhotoJson(
|
||||
photoId: 'ph-v${(i + 1).toString().padLeft(2, '0')}',
|
||||
uploaderName: 'Uploader ${i + 1}',
|
||||
preview: photoPreviewJson(),
|
||||
),
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'vc1'));
|
||||
}
|
||||
if (cursor == 'vc1') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-vpage2', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
});
|
||||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||
|
||||
// 初始第一张。
|
||||
expect(find.text('Uploader 1'), findsOneWidget);
|
||||
// 左右切换:到第二张。
|
||||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Uploader 2'), findsOneWidget);
|
||||
|
||||
// 连续滑到接近已加载末尾(page >= length-3 触发加载更多)。
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
expect(
|
||||
adapter.requests.any((r) => r.uri.queryParameters['cursor'] == 'vc1'),
|
||||
isTrue,
|
||||
reason: '临近末尾按游标加载第二页',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('管理者:已发布照片可隐藏/删除;隐藏照片可恢复', (tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path == '/v1/albums/alb-1/photos') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-pub', preview: photoPreviewJson()),
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-hid',
|
||||
visibility: 'hidden',
|
||||
preview: photoPreviewJson(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (o.method == 'PATCH' && o.path == '/v1/photos/ph-pub') {
|
||||
return (
|
||||
200,
|
||||
adminPhotoJson(photoId: 'ph-pub', visibility: 'hidden'),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
final client = clientOf(adapter);
|
||||
await pumpGridAndOpen(tester, client);
|
||||
|
||||
// 已发布:隐藏 + 删除(管理者,scope=all 成功)。
|
||||
expect(find.text('Hide'), findsOneWidget);
|
||||
expect(find.text('Delete'), findsOneWidget);
|
||||
expect(find.text('Unhide'), findsNothing);
|
||||
|
||||
// 切到第二张(隐藏照片):恢复显示(仅管理者)+ 删除。
|
||||
await tester.drag(find.byType(PageView), const Offset(-700, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Unhide'), findsOneWidget);
|
||||
expect(find.text('Hide'), findsNothing);
|
||||
|
||||
// 隐藏操作:PATCH visibility=hidden → 按钮翻转为恢复显示。
|
||||
await tester.drag(find.byType(PageView), const Offset(700, 0));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Hide'));
|
||||
await tester.pumpAndSettle();
|
||||
final patch = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'PATCH' && r.path == '/v1/photos/ph-pub',
|
||||
);
|
||||
expect(patch.body, <String, dynamic>{'visibility': 'hidden'});
|
||||
expect(find.text('Unhide'), findsOneWidget, reason: '隐藏后可恢复');
|
||||
expect(find.text('Hide'), findsNothing, reason: '已隐藏态不再显示隐藏入口');
|
||||
});
|
||||
|
||||
testWidgets('协作者(scope=own):自己上传可隐藏/删除,不可恢复', (tester) async {
|
||||
store.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'staff02',
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
final scope = o.uri.queryParameters['scope'];
|
||||
if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden));
|
||||
if (scope == 'own') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-own-hidden',
|
||||
visibility: 'hidden',
|
||||
uploaderAccount: 'staff02',
|
||||
preview: null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected scope $scope');
|
||||
});
|
||||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||
|
||||
// 自己上传的隐藏照片:恢复发布仅创建者/管理员 → 不显示恢复入口。
|
||||
expect(find.text('Unhide'), findsNothing);
|
||||
expect(find.text('Delete'), findsOneWidget, reason: '自己上传可删除');
|
||||
// 无预览(隐藏对协作者不签发)→ 状态说明 + 图片暂不可用。
|
||||
expect(find.text('Image unavailable'), findsOneWidget);
|
||||
expect(find.text('ph-own-hidden'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除:确认「云端照片」→ DELETE → 条目移除', (tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path == '/v1/albums/alb-1/photos') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-del', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (o.method == 'DELETE' && o.path == '/v1/photos/ph-del') {
|
||||
return (204, null);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
// 确认弹窗:明确影响云端照片。
|
||||
expect(find.text('Delete this photo?'), findsOneWidget);
|
||||
expect(
|
||||
find.text('The cloud photo will be deleted. This cannot be undone.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 取消不删。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
adapter.requests.any((r) => r.method == 'DELETE'),
|
||||
isFalse,
|
||||
reason: '取消不触发删除',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/photos/ph-del',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 唯一照片删除后 → 查看器退回网格(空态)。
|
||||
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
|
||||
expect(find.text('No photos yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('关闭返回网格原滚动位置', (tester) async {
|
||||
// 默认测试画布 800 宽 → 5 列;24 条 ≈ 5 行,超出视口可滚动。
|
||||
final items = List<Map<String, dynamic>>.generate(
|
||||
24,
|
||||
(i) => adminPhotoJson(
|
||||
photoId: 'ph-s${(i + 1).toString().padLeft(2, '0')}',
|
||||
preview: photoPreviewJson(),
|
||||
),
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (200, cursorPageBody(items));
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [...pageTestOverrides(session: store, client: clientOf(adapter))],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 先滚动网格(离开顶部)。
|
||||
final gridScrollable = find.descendant(
|
||||
of: find.byType(GridView),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
await tester.drag(gridScrollable, const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
final scrolled = tester
|
||||
.state<ScrollableState>(gridScrollable)
|
||||
.position
|
||||
.pixels;
|
||||
expect(scrolled, greaterThan(0));
|
||||
|
||||
// 打开当前视口内可见的一张(滚动后树序首项可能在缓存区/被遮挡)→
|
||||
// 关闭 → 偏移保持。
|
||||
await tester.tap(find.byType(PhotoThumbnail).hitTestable().first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.state<ScrollableState>(gridScrollable).position.pixels,
|
||||
scrolled,
|
||||
reason: '关闭大图后网格回到原滚动位置',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('签名 URL 失败 → 一次受控刷新(不无限重试)', (tester) async {
|
||||
var listCalls = 0;
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
listCalls++;
|
||||
// 刷新后返回新签名 URL(测试环境网络仍失败 → 验证不再重试)。
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-url',
|
||||
preview: photoPreviewJson(
|
||||
browseUrl: 'http://test.local/browse-v$listCalls.jpg',
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpGridAndOpen(tester, clientOf(adapter));
|
||||
|
||||
// 初次列表 + 失败后自动刷新一次 = 2 次;再等待后不再增加。
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
expect(listCalls, 2, reason: '签名 URL 失败仅重取一次');
|
||||
expect(find.text('Image unavailable'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/// 照片管理占位页 widget 测试(P3 计划 Task 4)。
|
||||
/// 照片管理网格 widget 测试(UI-01 §4.3)。
|
||||
///
|
||||
/// 覆盖:Task 9 占位横幅、基础列表(可见性/处理状态标签、时区时间)、
|
||||
/// 游标加载更多。完整交互由 Task 9 替换。
|
||||
/// 覆盖:网格列数(极窄 2 / 默认 3 / 宽屏 5)、状态角标(处理中/失败/
|
||||
/// 隐藏/删除)、scope 自适应(all 403 → own + 协作者提示)、游标加载第二
|
||||
/// 页、筛选 chips(服务端 visibility 重载 / 本地 processing 过滤)、空态。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -9,9 +10,13 @@ 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/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_photos_page.dart';
|
||||
import 'package:photo_live/pages/album_photo_viewer_page.dart';
|
||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||
import 'package:photo_live/ui/components/state_badge.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
@@ -31,7 +36,7 @@ void main() {
|
||||
Future<void> pumpPhotos(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
overrides: [...pageTestOverrides(session: store, client: client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -42,18 +47,134 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('占位横幅 + 列表标签 + 游标加载第二页', (tester) async {
|
||||
void setSurface(WidgetTester tester, Size size) {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
}
|
||||
|
||||
testWidgets('网格列数:350→2 / 390→3 / 800→5(平板按格宽扩列)', (tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
// 非空列表(空态不渲染 GridView,无法断言 delegate)。
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-c1', preview: photoPreviewJson()),
|
||||
adminPhotoJson(photoId: 'ph-c2', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
});
|
||||
final client = clientOf(adapter);
|
||||
|
||||
setSurface(tester, const Size(350, 800));
|
||||
await pumpPhotos(tester, client);
|
||||
expect(_columnCount(tester), 2, reason: '极窄屏两列');
|
||||
|
||||
setSurface(tester, const Size(390, 844));
|
||||
await pumpPhotos(tester, client);
|
||||
expect(_columnCount(tester), 3, reason: '默认三列');
|
||||
|
||||
setSurface(tester, const Size(800, 600));
|
||||
await pumpPhotos(tester, client);
|
||||
expect(_columnCount(tester), 5, reason: '800/150 向下取整 5 列');
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('缩略图 + 状态角标(处理中/失败/隐藏;就绪公开无角标)', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-ready', preview: photoPreviewJson()),
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-processing',
|
||||
processingState: 'processing',
|
||||
visibility: 'unpublished',
|
||||
),
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-failed',
|
||||
processingState: 'processing_failed',
|
||||
visibility: 'unpublished',
|
||||
),
|
||||
adminPhotoJson(photoId: 'ph-hidden', visibility: 'hidden'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
// 缩略图来自 preview.thumb(测试环境网络失败 → 替代图占位,不破图)。
|
||||
expect(find.byType(PhotoThumbnail), findsNWidgets(4));
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||
// 角标:处理中/失败/隐藏(图标 + 文案,不只靠颜色;文案限定在角标
|
||||
// 组件内——筛选 chips 有同名标签)。
|
||||
Finder badgeText(String label) => find.descendant(
|
||||
of: find.byType(StateBadge),
|
||||
matching: find.text(label),
|
||||
);
|
||||
expect(badgeText('Processing'), findsOneWidget);
|
||||
expect(badgeText('Processing failed'), findsOneWidget);
|
||||
expect(badgeText('Hidden'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.hourglass_top), findsOneWidget);
|
||||
expect(find.byIcon(Icons.error_outline), findsOneWidget);
|
||||
expect(find.byIcon(Icons.remove_circle_outline), findsOneWidget);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('scope 自适应:all 403 → own 重试 + 协作者提示', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
final scope = o.uri.queryParameters['scope'];
|
||||
if (scope == 'all') return (403, errorBody(ErrorCodes.authForbidden));
|
||||
if (scope == 'own') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-own-1'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected scope $scope');
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
// 两次请求:all(403)→ own(200,服务端过滤自己上传)。
|
||||
expect(adapter.requests, hasLength(2));
|
||||
expect(adapter.requests.first.uri.queryParameters['scope'], 'all');
|
||||
expect(adapter.requests.last.uri.queryParameters['scope'], 'own');
|
||||
// 协作者范围提示可见。
|
||||
expect(
|
||||
find.text('Collaborator view: only photos you uploaded are shown'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('游标加载第二页(携带 cursor)', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => adminPhotoJson(
|
||||
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
|
||||
visibility: i % 2 == 0 ? 'published' : 'unpublished',
|
||||
processingState: i % 2 == 0 ? 'ready' : 'processing',
|
||||
preview: photoPreviewJson(),
|
||||
),
|
||||
);
|
||||
final page2 = <Map<String, dynamic>>[
|
||||
// 区分度高的 ID:第二页条目渲染后可按标题前缀断言。
|
||||
adminPhotoJson(photoId: 'page2item'),
|
||||
adminPhotoJson(photoId: 'ph-page2item', preview: photoPreviewJson()),
|
||||
];
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
@@ -66,29 +187,143 @@ void main() {
|
||||
if (cursor == 'pc1') return (200, cursorPageBody(page2));
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
});
|
||||
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
// Task 9 占位横幅。
|
||||
expect(
|
||||
find.textContaining('the full experience ships in Task 9'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 标签与条目(ListView 懒构建,只断言在渲染的可见条目)。
|
||||
expect(find.text('Published'), findsWidgets);
|
||||
expect(find.text('Unpublished'), findsWidgets);
|
||||
expect(find.text('Ready'), findsWidgets);
|
||||
expect(find.text('Processing'), findsWidgets);
|
||||
expect(find.text('2026/9/18 15:00'), findsWidgets, reason: '相册时区展示');
|
||||
|
||||
// 滚动加载第二页。
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -4000));
|
||||
await tester.drag(find.byType(GridView), const Offset(0, -4000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
adapter.requests.last.uri.queryParameters['cursor'],
|
||||
'pc1',
|
||||
);
|
||||
// 第二页条目渲染(标题展示 photoId 前 8 位)。
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('筛选 chips:Published 走服务端 visibility 重载', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-1', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.widgetWithText(FilterChip, 'Published'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.last.uri.queryParameters['visibility'],
|
||||
'published',
|
||||
reason: 'Published 筛选下发服务端 visibility 参数',
|
||||
);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('本地筛选:Processing 只显示未就绪条目(不发新请求)', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-ready', preview: photoPreviewJson()),
|
||||
adminPhotoJson(
|
||||
photoId: 'ph-busy',
|
||||
processingState: 'processing',
|
||||
visibility: 'unpublished',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
final requestsBefore = adapter.requests.length;
|
||||
|
||||
// 瓦片角标文案与 chip 同名(「Processing」),按组件类型锁定 chip;
|
||||
// 390 宽下 Processing/Failed 在横向滚动区外,先滚动到位。
|
||||
final processingChip = find.widgetWithText(FilterChip, 'Processing');
|
||||
await tester.ensureVisible(processingChip);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(processingChip);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 本地过滤:无新请求,仅剩处理中条目(含其角标)。
|
||||
expect(adapter.requests, hasLength(requestsBefore));
|
||||
expect(
|
||||
find.descendant(of: find.byType(StateBadge), matching: find.text('Processing')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(PhotoThumbnail), findsOneWidget);
|
||||
|
||||
// 本地筛选无匹配 → 空筛选态。
|
||||
final failedChip = find.widgetWithText(FilterChip, 'Failed');
|
||||
await tester.ensureVisible(failedChip);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(failedChip);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No photos match this filter'), findsOneWidget);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('空列表 → 空态;点击瓦片进入大图查看器', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (200, cursorPageBody(<Map<String, dynamic>>[]));
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
expect(find.text('No photos yet'), findsOneWidget);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('点击瓦片 → 大图查看器(关闭返回网格)', (tester) async {
|
||||
setSurface(tester, const Size(390, 844));
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(<Map<String, dynamic>>[
|
||||
adminPhotoJson(photoId: 'ph-1', preview: photoPreviewJson()),
|
||||
]),
|
||||
);
|
||||
});
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byType(PhotoThumbnail).first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumPhotoViewerPage), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumPhotoViewerPage), findsNothing);
|
||||
expect(find.byType(AlbumPhotosPage), findsOneWidget);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
}
|
||||
|
||||
/// 网格列数(GridView delegate 断言用)。
|
||||
int _columnCount(WidgetTester tester) =>
|
||||
(tester.widget<GridView>(find.byType(GridView)).gridDelegate
|
||||
as SliverGridDelegateWithFixedCrossAxisCount)
|
||||
.crossAxisCount;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3 起覆盖封面卡片样板)。
|
||||
/// 相册列表页签 widget 测试(P3 Task 4;UI-01 Task 3/4 覆盖封面卡片与
|
||||
/// 响应式列表)。
|
||||
///
|
||||
/// 覆盖:封面卡片样板(封面占位/标题回退/时间场地行/公开与上传徽章/
|
||||
/// 令牌与四开关移除)、关闭状态徽章、封面加载失败替代图、游标加载更多
|
||||
/// (第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
|
||||
/// 覆盖:封面卡片(封面占位/标题回退/时间场地行/公开与上传徽章/令牌与
|
||||
/// 四开关移除)、关闭状态徽章、封面加载失败替代图、响应式单/双列、空态
|
||||
/// CTA、游标加载更多(第二页携带 cursor)、首屏错误 + 重试恢复。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/features/albums/providers.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/albums_tab.dart';
|
||||
import 'package:photo_live/pages/album_form_page.dart';
|
||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
@@ -45,6 +47,12 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 固定逻辑宽度(dp=1)。
|
||||
void setSurface(WidgetTester tester, Size size) {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
}
|
||||
|
||||
testWidgets('封面卡片样板:封面占位 + 标题回退 + 时间场地 + 公开/上传徽章', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
@@ -101,6 +109,67 @@ void main() {
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('响应式列数:390 宽单列 / 900 宽双列', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (
|
||||
200,
|
||||
cursorPageBody([
|
||||
albumJson(id: 'alb-1'),
|
||||
albumJson(id: 'alb-2'),
|
||||
]),
|
||||
)
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
final client = clientOf(adapter);
|
||||
|
||||
// 手机宽度(390):单列,两卡片同宽同行不可见。
|
||||
setSurface(tester, const Size(390, 844));
|
||||
await pumpTab(tester, client);
|
||||
final card1 = tester.getRect(find.byType(PhotoThumbnail).first);
|
||||
final card2 = tester.getRect(find.byType(PhotoThumbnail).last);
|
||||
expect(card1.width, closeTo(390 - 32, 0.5), reason: '单列占满(左右 16)');
|
||||
expect(
|
||||
card1.right == card2.right && card1.left == card2.left,
|
||||
isTrue,
|
||||
reason: '两卡片同列(垂直排布)',
|
||||
);
|
||||
|
||||
// 平板宽度(900):双列并排。
|
||||
setSurface(tester, const Size(900, 1200));
|
||||
await pumpTab(tester, client);
|
||||
final wide1 = tester.getRect(find.byType(PhotoThumbnail).first);
|
||||
final wide2 = tester.getRect(find.byType(PhotoThumbnail).last);
|
||||
expect(wide1.width, closeTo((900 - 32 - 12) / 2, 0.5), reason: '双列均分');
|
||||
expect(
|
||||
wide2.left > wide1.right,
|
||||
isTrue,
|
||||
reason: '第二张在第一张右侧(双列)',
|
||||
);
|
||||
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
testWidgets('空列表 → 空态文案 + 「新建相册」CTA 进入表单', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(
|
||||
find.text('No albums yet. Tap "Create album" to get started'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 空态 CTA(次强调按钮)→ 创建表单。
|
||||
expect(find.byType(AlbumFormPage), findsNothing);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumFormPage), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
@@ -146,20 +215,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('空列表 → 空态文案', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(
|
||||
find.text('No albums yet. Tap "Create album" to get started'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter((o) {
|
||||
|
||||
Reference in New Issue
Block a user