feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
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/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
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 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
||||
FakeAdapter detailAdapter({
|
||||
(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/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(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
// 信息区(标题同时出现在 AppBar 与信息卡)。
|
||||
expect(find.text('开幕晚宴'), findsNWidgets(2));
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
expect(
|
||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
|
||||
var posted = false;
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'POST') {
|
||||
posted = true;
|
||||
categories = <Map<String, dynamic>>[
|
||||
...categories,
|
||||
categoryJson(
|
||||
id: 'cat-new',
|
||||
sortOrder: 5,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||
],
|
||||
),
|
||||
];
|
||||
return (201, categoryJson(id: 'cat-new', sortOrder: 5));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
'签到',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'English name'),
|
||||
'Check-in',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Sort order'),
|
||||
'5',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(posted, isTrue);
|
||||
// 成功后详情重载会追加 GET,故按方法+路径取 POST。
|
||||
final request = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories',
|
||||
);
|
||||
expect(request.path, '/v1/albums/alb-1/categories');
|
||||
expect(request.body, <String, dynamic>{
|
||||
'sortOrder': 5,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||
],
|
||||
});
|
||||
// 详情重载后列表含新分类。
|
||||
expect(find.text('签到 · Check-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text('At least one language name is required'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(adapter.requests, hasLength(requestsBefore));
|
||||
});
|
||||
|
||||
testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder',
|
||||
(tester) async {
|
||||
var patched = false;
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'PATCH') {
|
||||
patched = true;
|
||||
categories = <Map<String, dynamic>>[
|
||||
categoryJson(
|
||||
id: 'cat-1',
|
||||
sortOrder: 2,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
),
|
||||
];
|
||||
return (200, categoryJson(id: 'cat-1'));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('仪式 · Ceremony'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 预填检查。
|
||||
final zhField = tester.widget<TextFormField>(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
);
|
||||
expect(zhField.controller!.text, '仪式');
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
'签到墙',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Sort order'),
|
||||
'2',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(patched, isTrue);
|
||||
// 成功后详情重载会追加 GET,故按方法+路径取 PATCH。
|
||||
final request = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1',
|
||||
);
|
||||
expect(request.body, <String, dynamic>{
|
||||
'sortOrder': 2,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
});
|
||||
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'DELETE') {
|
||||
categories = <Map<String, dynamic>>[];
|
||||
return (204, null);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Delete this category? Its photos will become uncategorized.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 取消可用:先取消再重删,验证确认门。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user