feat(mobile): UI-03 forms and collaboration pages (09-12)
- Page 09 create: grouped form (Event details / Description & languages), required default-language selector, zh-CN/en tabs with per-locale title/description/venue/address, explicit timezone field, bottom save button kept keyboard-reachable, create mode has no Access tab - Page 10 edit: Details/Access tabs sharing one draft; Access has four switches with explanations; original downloads constrained by downloads (disabled + forced off in PATCH); unsaved-changes PopScope confirm; save commits all changes in one minimal PATCH - Page 11 members: Owner group + Collaborators list with initial avatars and per-row remove; full-width Invite colleague opens account-search bottom sheet (debounced prefix search, race guard, already-added state); remove confirm names the member - Page 12 categories: extracted to standalone page with per-row edit/ delete menu and New category sheet (language tabs + name + sort order); delete confirm mentions photo impact; collaborator browse-only; old inline section removed from album detail - l10n: new form/members/categories keys both locales; regenerated - tests: form 14, members 6, categories 7, detail nav 1; full suite green Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
/// 相册分类独立管理页 widget 测试(UI-03 §4 页面 12,自详情页提取)。
|
||||
///
|
||||
/// 覆盖:列表(默认语言名 + 排序序号 + 行尾「⋯」)与管理者新增入口;
|
||||
/// BottomSheet 编辑器(语言页签 + 名称 + 排序序号)新增载荷/空名校验/
|
||||
/// 编辑 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/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_categories_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),
|
||||
categoryJson(
|
||||
id: 'cat-2',
|
||||
sortOrder: 1,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '展厅'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Exhibition Hall'},
|
||||
],
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
/// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
||||
FakeAdapter categoriesAdapter({
|
||||
(int, Object?)? Function(String method, String path)? onMutation,
|
||||
}) =>
|
||||
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 onMutation?.call('POST', o.path) ??
|
||||
(201, categoryJson(id: 'cat-new'));
|
||||
}
|
||||
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
|
||||
return onMutation?.call('PATCH', o.path) ??
|
||||
(200, categoryJson(id: 'cat-1'));
|
||||
}
|
||||
if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') {
|
||||
return onMutation?.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> pumpCategories(
|
||||
WidgetTester tester,
|
||||
ApiClient client, {
|
||||
bool isManager = true,
|
||||
List<Map<String, dynamic>>? initial,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
apiClientProvider.overrideWith((ref) => client),
|
||||
sessionStoreProvider.overrideWith((ref) => store),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumCategoriesPage(
|
||||
album: Album.fromJson(albumJson(categories: initial ?? categories)),
|
||||
isManager: isManager,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('列表:默认语言名 + 排序序号 + 行尾「⋯」+ 管理者新增入口',
|
||||
(tester) async {
|
||||
await pumpCategories(tester, clientOf(categoriesAdapter()));
|
||||
|
||||
expect(find.text('Categories'), findsOneWidget);
|
||||
// 默认语言(相册 defaultLocale=zh-CN)名称。
|
||||
expect(find.text('仪式'), findsOneWidget);
|
||||
expect(find.text('展厅'), findsOneWidget);
|
||||
expect(find.text('Ceremony'), findsNothing, reason: '非默认语言不占主行');
|
||||
// 排序序号 + 每行「⋯」。
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.more_horiz), findsNWidgets(2));
|
||||
expect(find.widgetWithText(FilledButton, 'New category'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增:语言页签 + 名称 + 排序 → POST 载荷 → 列表刷新',
|
||||
(tester) async {
|
||||
var posted = false;
|
||||
final adapter = categoriesAdapter(
|
||||
onMutation: (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 pumpCategories(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('New category'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 编辑器:语言页签(默认 zh,与相册 defaultLocale 一致)。
|
||||
expect(find.text('New category'), findsNWidgets(2), reason: '按钮与面板标题');
|
||||
final sheet = find.byType(CategoryEditSheet);
|
||||
expect(sheet, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: sheet, matching: find.byType(TabBar)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// zh 页签名称 + 排序;切 en 页签填英文名。
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
'签到',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Sort order'),
|
||||
'5',
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(of: sheet, matching: find.text('English')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'English name'),
|
||||
'Check-in',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(posted, isTrue);
|
||||
final request = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'POST' && r.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'},
|
||||
],
|
||||
});
|
||||
// 成功后重载详情(GET /albums/alb-1),列表含新分类。
|
||||
expect(find.text('签到'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增:名称全空 → 客户端拦截,不发请求', (tester) async {
|
||||
final adapter = categoriesAdapter();
|
||||
await pumpCategories(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
await tester.tap(find.text('New category'));
|
||||
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('编辑:「⋯」→ Edit category → 预填改名 → PATCH 全量 translations',
|
||||
(tester) async {
|
||||
var patched = false;
|
||||
final adapter = categoriesAdapter(
|
||||
onMutation: (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'},
|
||||
],
|
||||
),
|
||||
categories[1],
|
||||
];
|
||||
return (200, categoryJson(id: 'cat-1'));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpCategories(tester, clientOf(adapter));
|
||||
|
||||
// 第一行「⋯」→ Edit category。
|
||||
await tester.tap(find.byIcon(Icons.more_horiz).first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Edit category'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 预填检查(zh 名称)。
|
||||
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);
|
||||
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('签到墙'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除:确认提及名称与照片影响 → DELETE → 列表刷新 + 空态',
|
||||
(tester) async {
|
||||
final adapter = categoriesAdapter(
|
||||
onMutation: (method, path) {
|
||||
if (method == 'DELETE') {
|
||||
categories = categories.where((c) => c['id'] != 'cat-1').toList();
|
||||
return (204, null);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpCategories(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz).first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 确认文案:默认语言名 + 照片影响说明。
|
||||
expect(
|
||||
find.text('Delete "仪式"? Its photos will become uncategorized.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 取消可用:先取消再重删,验证确认门。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('仪式'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz).first);
|
||||
await tester.pumpAndSettle();
|
||||
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/categories/cat-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('仪式'), findsNothing);
|
||||
expect(find.text('展厅'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('空分类:安静空态 + 新增入口', (tester) async {
|
||||
await pumpCategories(
|
||||
tester,
|
||||
clientOf(categoriesAdapter()),
|
||||
initial: <Map<String, dynamic>>[],
|
||||
);
|
||||
|
||||
expect(find.text('No categories yet'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.category_outlined), findsOneWidget);
|
||||
expect(find.widgetWithText(FilledButton, 'New category'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('协作者(isManager=false):仅浏览,无管理入口', (tester) async {
|
||||
await pumpCategories(
|
||||
tester,
|
||||
clientOf(categoriesAdapter()),
|
||||
isManager: false,
|
||||
);
|
||||
|
||||
expect(find.text('仪式'), findsOneWidget);
|
||||
expect(find.text('展厅'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.more_horiz), findsNothing);
|
||||
expect(find.text('New category'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
/// 覆盖:封面 → 标题/时区时间场地 → 状态徽章 → 次级四入口(Photos/
|
||||
/// Members/Categories/Share)→ Recent photos 三列网格 + See all → 底部
|
||||
/// 固定上传栏(拍摄上传主 / 从相册选择次,上传关闭时禁用并说明);空相册
|
||||
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类管理三流
|
||||
/// (新增载荷、空名校验、编辑 PATCH、删除确认);底部栏直达连拍页与多选
|
||||
/// 入队。
|
||||
/// 安静占位;协作者(scope all 403 → own)隐藏管理入口;分类入口跳转
|
||||
/// 独立分类页(页面 12,旧内嵌区块已移除);底部栏直达连拍页与多选入队。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -24,6 +23,7 @@ import 'package:photo_live/features/uploads/preparer.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_categories_page.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
@@ -270,186 +270,38 @@ void main() {
|
||||
expect(find.byIcon(Icons.broken_image_outlined), 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 reveal(tester, find.byIcon(Icons.add_circle_outline));
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline).hitTestable());
|
||||
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 {
|
||||
testWidgets('分类入口:跳转独立分类页(旧内嵌区块已移除)', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
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'));
|
||||
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 reveal(tester, find.text('仪式 · Ceremony'));
|
||||
await tester.tap(find.text('仪式 · Ceremony').hitTestable());
|
||||
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 reveal(tester, find.byIcon(Icons.delete_outline));
|
||||
await tester.tap(find.byIcon(Icons.delete_outline).hitTestable());
|
||||
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 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();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 旧内嵌分类管理区块不再存在于详情页。
|
||||
expect(find.byIcon(Icons.add_circle_outline), findsNothing);
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
|
||||
// Categories 入口 → 独立分类页(页面 12)。入口在折叠线下,先滚动
|
||||
// 可见再点击。
|
||||
final tilesCard = find.byType(Card).first;
|
||||
final categoriesTile =
|
||||
find.descendant(of: tilesCard, matching: find.text('Categories'));
|
||||
await reveal(tester, categoriesTile);
|
||||
await tester.tap(categoriesTile);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumCategoriesPage), findsOneWidget);
|
||||
expect(find.text('仪式'), findsOneWidget, reason: '分类列表(默认语言名)');
|
||||
|
||||
// 返回后详情重载(分类随详情返回)。
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumCategoriesPage), findsNothing);
|
||||
expect(
|
||||
adapter.requests
|
||||
.where((r) => r.method == 'GET' && r.path == '/v1/albums/alb-1')
|
||||
.length,
|
||||
greaterThanOrEqualTo(2),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
group('底部固定上传栏(原 Task 9 FAB 流迁移)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/// 相册创建/编辑表单 widget 测试(P3 计划 Task 4)。
|
||||
/// 相册创建/编辑表单 widget 测试(UI-03 §4 页面 09-10 重设计后)。
|
||||
///
|
||||
/// 覆盖:校验矩阵(默认语言标题缺失/未选开始时间/endAt 早于 startAt)、
|
||||
/// 创建成功 POST 载荷形状、编辑预加载(字段与四开关初值、创建仅两开关)、
|
||||
/// PATCH 最小化(仅拨动的开关出现在请求体)、endAt 显式清除、
|
||||
/// 标题修改仅发全量 translations。
|
||||
/// 覆盖:创建两分组结构(Event details / Description & languages)与默认
|
||||
/// 语言必选、无 Access 页签;校验矩阵(默认语言标题缺失/未选开始时间/
|
||||
/// endAt 早于 startAt);创建 POST 载荷形状;编辑 Details/Access 页签共享
|
||||
/// 草稿(切页签不丢输入)、四开关与说明、原图下载受总下载约束(禁用 +
|
||||
/// PATCH 联动关断)、PATCH 最小化、endAt 显式清除、标题修改仅发全量
|
||||
/// translations;未保存退出确认;保存失败保留输入。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -11,6 +13,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/features/albums/album_form.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
@@ -78,43 +81,43 @@ void main() {
|
||||
await tester.tap(finder);
|
||||
}
|
||||
|
||||
testWidgets('三分组结构(UI-01 §4.5):活动信息/多语言内容/访问设置 + 双语 tab',
|
||||
/// 切到 Access 页签(编辑模式顶栏 TabBar)。
|
||||
Future<void> openAccessTab(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Access'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('页面 09 创建:两分组 + 默认语言必选 + 双语 tab + 无 Access 页签',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(tester, clientOf(adapter));
|
||||
|
||||
// 三个分组标题(SectionHeader)。
|
||||
expect(find.text('Event info'), findsOneWidget);
|
||||
expect(find.text('Multilingual content'), findsOneWidget);
|
||||
expect(find.text('Access settings'), findsOneWidget);
|
||||
|
||||
// 多语言内容:zh-CN/en 双语 tab(当前 zh tab → 标题已填默认语言星标)。
|
||||
// 「中文/English」同时出现在内容 tab 与默认语言分段选择中,需锚定 TabBar。
|
||||
final tabBar = find.byType(TabBar);
|
||||
expect(find.descendant(of: tabBar, matching: find.text('中文')), findsOneWidget);
|
||||
expect(find.text('Create album'), findsNWidgets(2), reason: '标题与底部按钮');
|
||||
// 两个分组标题(SectionHeader)。
|
||||
expect(find.text('Event details'), findsOneWidget);
|
||||
expect(find.text('Description & languages'), findsOneWidget);
|
||||
// 默认语言选择器(必选标记)+ 语言 tab(创建模式仅一个 TabBar)。
|
||||
expect(find.text('Default language *'), findsOneWidget);
|
||||
expect(find.byType(TabBar), findsOneWidget);
|
||||
expect(find.descendant(of: find.byType(TabBar), matching: find.text('中文')),
|
||||
findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: tabBar, matching: find.text('English')), findsOneWidget);
|
||||
expect(find.text('Title *'), findsOneWidget, reason: '默认语言标题必填星标');
|
||||
|
||||
// 切到 en tab → 英文字段(非默认语言,无星标)。
|
||||
await tester.tap(find.descendant(of: tabBar, matching: find.text('English')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Title'), findsOneWidget);
|
||||
expect(find.text('Title *'), findsNothing);
|
||||
|
||||
// 访问设置:默认语言分段选择(两项)。
|
||||
find.descendant(of: find.byType(TabBar), matching: find.text('English')),
|
||||
findsOneWidget);
|
||||
expect(find.byType(SegmentedButton<String>), findsOneWidget);
|
||||
// 创建模式:无 Access 页签、无开关(契约缺省)。
|
||||
expect(find.text('Access'), findsNothing);
|
||||
expect(find.byType(SwitchListTile), findsNothing);
|
||||
// 默认时区显式可见(不静默取设备时区)。
|
||||
expect(find.text('Asia/Shanghai'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
|
||||
testWidgets('页面 09 创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(tester, clientOf(adapter));
|
||||
|
||||
// 创建模式仅两开关(download/original)。
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Default locale must have a title'), findsOneWidget);
|
||||
@@ -123,7 +126,7 @@ void main() {
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误',
|
||||
testWidgets('页面 09 创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(
|
||||
@@ -132,14 +135,14 @@ void main() {
|
||||
initialDraft: createDraft(defaultLocale: 'en', enTitle: ''),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Default locale must have a title'), findsOneWidget);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:endAt 早于 startAt → 时间序错误', (tester) async {
|
||||
testWidgets('页面 09 创建:endAt 早于 startAt → 时间序错误', (tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(
|
||||
tester,
|
||||
@@ -149,14 +152,14 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('End time must be after start time'), findsOneWidget);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间/开关)',
|
||||
testWidgets('页面 09 创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
@@ -168,12 +171,10 @@ void main() {
|
||||
clientOf(adapter),
|
||||
initialDraft: createDraft(
|
||||
endAt: DateTime.utc(2026, 9, 19, 10, 0),
|
||||
downloadEnabled: false,
|
||||
originalDownloadEnabled: true,
|
||||
),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final request = adapter.requests.single;
|
||||
@@ -200,12 +201,37 @@ void main() {
|
||||
'startAt': '2026-09-18T06:30:00.000Z',
|
||||
'endAt': '2026-09-19T10:00:00.000Z',
|
||||
'timezone': 'Asia/Shanghai',
|
||||
'downloadEnabled': false,
|
||||
'originalDownloadEnabled': true,
|
||||
// 创建无 Access 页签:download/original 走契约缺省。
|
||||
'downloadEnabled': true,
|
||||
'originalDownloadEnabled': false,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('编辑:预加载既有值(字段、四开关独立初值、无变更时保存禁用)',
|
||||
testWidgets('页面 09 创建:保存失败 → 提示并保留输入(不弹回)', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (500, errorBody(ErrorCodes.internalError))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpForm(tester, clientOf(adapter), initialDraft: createDraft());
|
||||
|
||||
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlbumFormPage), findsOneWidget, reason: '失败不弹回');
|
||||
expect(
|
||||
find.text('Service temporarily unavailable. Please retry later'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 输入保留。
|
||||
expect(
|
||||
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
|
||||
.text,
|
||||
'开幕晚宴',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:Details/Access 页签 + 预填 + 无变更保存禁用',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('编辑预加载不应发起请求:${o.path}'),
|
||||
@@ -219,14 +245,98 @@ void main() {
|
||||
);
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
// 编辑模式四开关。
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(4));
|
||||
// 字段预填(当前 zh tab;UI-01 分组后 TextFormField 顺序 = 时区 →
|
||||
// 标题(zh tab),标题取第 2 个)。
|
||||
// 顶栏 TabBar(Details/Access)+ 内容区语言 TabBar。
|
||||
expect(find.text('Edit album'), findsOneWidget);
|
||||
expect(find.text('Details'), findsOneWidget);
|
||||
expect(find.text('Access'), findsOneWidget);
|
||||
expect(find.byType(TabBar), findsNWidgets(2));
|
||||
expect(find.text('Save changes'), findsOneWidget);
|
||||
// 字段预填(zh tab;TextFormField 顺序 = 时区 → 标题(zh tab))。
|
||||
final titleField = tester.widget<TextFormField>(
|
||||
find.byType(TextFormField).at(1),
|
||||
);
|
||||
expect(titleField.controller!.text, '开幕晚宴');
|
||||
// 时间回显按相册时区。
|
||||
expect(find.text('2026/9/18 14:30'), findsOneWidget);
|
||||
expect(find.text('2026/9/19 18:00'), findsOneWidget);
|
||||
// 无任何变更 → 保存禁用(PATCH minProperties 1)。
|
||||
expect(
|
||||
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:切页签共享草稿(不丢输入、不丢开关)', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('不应发起请求:${o.path}'),
|
||||
);
|
||||
await pumpForm(
|
||||
tester,
|
||||
clientOf(adapter),
|
||||
existing: Album.fromJson(albumJson()),
|
||||
);
|
||||
|
||||
// Details 页签输入标题。
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
|
||||
await tester.pump();
|
||||
// 切 Access → 切回 Details:输入保留(共享草稿)。
|
||||
await openAccessTab(tester);
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(4));
|
||||
await tester.tap(find.text('Details'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
|
||||
.text,
|
||||
'闭幕演出',
|
||||
);
|
||||
// 未保存 → 不发请求。
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:Access 四开关各配说明', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('不应发起请求:${o.path}'),
|
||||
);
|
||||
await pumpForm(
|
||||
tester,
|
||||
clientOf(adapter),
|
||||
existing: Album.fromJson(albumJson()),
|
||||
);
|
||||
|
||||
await openAccessTab(tester);
|
||||
|
||||
expect(
|
||||
find.widgetWithText(SwitchListTile, 'Allow uploads'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Allow staff to upload photos to this album'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(SwitchListTile, 'Public access'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Anyone with the link can view photos'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(SwitchListTile, 'Allow downloads'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Viewers can download browse-quality photos'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(SwitchListTile, 'Original downloads'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Viewers can download original photos'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 开关初值与原相册一致。
|
||||
expect(
|
||||
tester
|
||||
@@ -239,30 +349,47 @@ void main() {
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Publicly visible'),
|
||||
)
|
||||
.value,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Allow original downloads'),
|
||||
find.widgetWithText(SwitchListTile, 'Public access'),
|
||||
)
|
||||
.value,
|
||||
true,
|
||||
);
|
||||
// 时间回显按相册时区。
|
||||
expect(find.text('2026/9/18 14:30'), findsOneWidget);
|
||||
expect(find.text('2026/9/19 18:00'), findsOneWidget);
|
||||
// 无任何变更 → 保存禁用(PATCH minProperties 1)。
|
||||
expect(
|
||||
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷',
|
||||
testWidgets('页面 10 编辑:原图下载受总下载约束(禁用 + PATCH 联动关断)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
? (200, albumJson(downloadEnabled: false))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
// 原相册:下载开、原图开。
|
||||
final existing =
|
||||
Album.fromJson(albumJson(originalDownloadEnabled: true));
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await openAccessTab(tester);
|
||||
|
||||
// 关闭总下载 → 原图开关禁用且显示为关。
|
||||
await tester.tap(find.widgetWithText(SwitchListTile, 'Allow downloads'));
|
||||
await tester.pump();
|
||||
final original = find.widgetWithText(SwitchListTile, 'Original downloads');
|
||||
expect(tester.widget<SwitchListTile>(original).onChanged, isNull,
|
||||
reason: '总下载关闭 → 原图开关禁用');
|
||||
expect(tester.widget<SwitchListTile>(original).value, isFalse,
|
||||
reason: '生效值恒为 false');
|
||||
|
||||
// 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。
|
||||
await tester.tap(find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.single.body, <String, dynamic>{
|
||||
'downloadEnabled': false,
|
||||
'originalDownloadEnabled': false,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
@@ -272,13 +399,14 @@ void main() {
|
||||
final existing = Album.fromJson(albumJson());
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await tapVisible(
|
||||
tester,
|
||||
find.widgetWithText(SwitchListTile, 'Allow original downloads'),
|
||||
);
|
||||
await openAccessTab(tester);
|
||||
await tester
|
||||
.tap(find.widgetWithText(SwitchListTile, 'Original downloads'));
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tester.ensureVisible(find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final request = adapter.requests.single;
|
||||
@@ -289,7 +417,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async {
|
||||
testWidgets('页面 10 编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
? (200, albumJson(endAt: null))
|
||||
@@ -303,13 +431,13 @@ void main() {
|
||||
await tapVisible(tester, find.byTooltip('Clear end time'));
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tapVisible(tester, find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.single.body, <String, dynamic>{'endAt': null});
|
||||
});
|
||||
|
||||
testWidgets('编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)',
|
||||
testWidgets('页面 10 编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
@@ -325,7 +453,7 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tapVisible(tester, find.text('Save changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final body = adapter.requests.single.body as Map<String, dynamic>;
|
||||
@@ -347,4 +475,81 @@ void main() {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('页面 10 编辑:未保存变更退出 → 放弃确认(继续编辑/放弃两路)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('不应发起请求:${o.path}'),
|
||||
);
|
||||
final client = clientOf(adapter);
|
||||
|
||||
/// 以父路由承载(表单作为可弹出路由,AppBar 才有返回键)。
|
||||
Future<void> pumpPushedForm() async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Center(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AlbumFormPage(
|
||||
existing: Album.fromJson(albumJson()),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open form'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('open form'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await pumpPushedForm();
|
||||
|
||||
// 无变更:系统返回直接弹出(无确认框)。
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumFormPage), findsNothing);
|
||||
|
||||
// 重新进入并制造变更。
|
||||
await pumpPushedForm();
|
||||
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
|
||||
await tester.pump();
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Discard changes?'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Your edits are not saved yet. Leaving now will discard them.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// 继续编辑 → 留在页面,输入保留。
|
||||
await tester.tap(find.text('Keep editing'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumFormPage), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
|
||||
.text,
|
||||
'闭幕演出',
|
||||
);
|
||||
|
||||
// 放弃 → 弹出页面。
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Discard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumFormPage), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/// 成员管理页 widget 测试(P3 计划 Task 4)。
|
||||
/// 成员管理页 widget 测试(UI-03 §4 页面 11 重设计后)。
|
||||
///
|
||||
/// 覆盖:列表角色标签/owner 徽标/本人与创建者不可移除;账号前缀搜索
|
||||
/// 防抖(300ms、≥2 字符、连续输入合并);邀请载荷与 MEMBER_CONFLICT
|
||||
/// 本地化;移除确认流。
|
||||
/// 覆盖:Owner 单独分组(无移除)与 Collaborators 列表(首字母头像/
|
||||
/// 显示名/账号·角色/行尾「⋯」)、标题计数、空协作者态;Invite colleague
|
||||
/// BottomSheet 搜索(防抖 300ms、≥2 字符、连续输入合并)→ Add 载荷 →
|
||||
/// 已添加态;MEMBER_CONFLICT 本地化;移除确认(含成员名)→ DELETE。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -21,6 +22,7 @@ import '../helpers/fakes.dart';
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> members;
|
||||
late List<Map<String, dynamic>> lookups;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore()
|
||||
@@ -39,6 +41,7 @@ void main() {
|
||||
memberJson(userId: 'u-1', account: 'staff01', displayName: 'Staff One'),
|
||||
memberJson(userId: 'u-2', account: 'staff02', displayName: 'Staff Two'),
|
||||
];
|
||||
lookups = <Map<String, dynamic>>[lookupUserJson()];
|
||||
});
|
||||
|
||||
FakeAdapter membersAdapter({
|
||||
@@ -66,9 +69,7 @@ void main() {
|
||||
return (204, null);
|
||||
}
|
||||
if (o.path == '/v1/admin/users' && o.method == 'GET') {
|
||||
return (200, <String, dynamic>{
|
||||
'items': <Map<String, dynamic>>[lookupUserJson()],
|
||||
});
|
||||
return (200, <String, dynamic>{'items': lookups});
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
@@ -99,25 +100,58 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮',
|
||||
/// 打开邀请面板(底部全宽按钮)。
|
||||
Future<void> openInviteSheet(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Invite colleague'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(AlbumInviteSheet), findsOneWidget);
|
||||
}
|
||||
|
||||
testWidgets('分组渲染:Owner 单独组(无「⋯」)、协作者行尾「⋯」、标题计数',
|
||||
(tester) async {
|
||||
await pumpMembers(tester, clientOf(membersAdapter()));
|
||||
|
||||
expect(find.text('Members (3)'), findsOneWidget);
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
expect(find.text('Collaborator'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.star), findsOneWidget, reason: 'owner 徽标');
|
||||
// 本人标记。
|
||||
expect(find.text('staff01 (you)'), findsOneWidget);
|
||||
// 仅 staff02(非 owner、非本人)有移除按钮。
|
||||
expect(find.byIcon(Icons.person_remove_outlined), findsOneWidget);
|
||||
expect(find.text('Collaborators'), findsOneWidget);
|
||||
// Owner 行:显示名 + 账号·角色,无移除入口。
|
||||
expect(find.text('The Owner'), findsOneWidget);
|
||||
expect(find.text('boss01 · Owner'), findsOneWidget);
|
||||
// 协作者行:staff01 为本人(带标记),staff02 可移除(行尾「⋯」)。
|
||||
expect(find.text('Staff One (you)'), findsOneWidget);
|
||||
expect(find.text('staff01 · Collaborator'), findsOneWidget);
|
||||
expect(find.text('Staff Two'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.more_horiz), findsOneWidget,
|
||||
reason: '仅非本人协作者有「⋯」');
|
||||
// 首字母头像。
|
||||
expect(find.text('T'), findsOneWidget);
|
||||
expect(find.text('S'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
|
||||
testWidgets('空协作者:占位文案 + 底部邀请入口', (tester) async {
|
||||
members = <Map<String, dynamic>>[
|
||||
memberJson(
|
||||
userId: 'u-owner',
|
||||
account: 'boss01',
|
||||
displayName: 'The Owner',
|
||||
role: 'owner',
|
||||
),
|
||||
];
|
||||
await pumpMembers(tester, clientOf(membersAdapter()));
|
||||
|
||||
expect(find.text('Members (1)'), findsOneWidget);
|
||||
expect(find.text('No collaborators yet'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person_add_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('邀请搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await openInviteSheet(tester);
|
||||
final field = find.byType(TextField);
|
||||
|
||||
// 1 个字符:无请求。
|
||||
await tester.enterText(field, 's');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
@@ -132,25 +166,42 @@ void main() {
|
||||
await tester.enterText(field, 'sta');
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
final lookups = adapter.requests
|
||||
final lookupsRequests = adapter.requests
|
||||
.where((r) => r.path == '/v1/admin/users')
|
||||
.toList();
|
||||
expect(lookups, hasLength(1));
|
||||
expect(lookups.single.uri.queryParameters['accountPrefix'], 'sta');
|
||||
expect(lookupsRequests, hasLength(1));
|
||||
expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta');
|
||||
});
|
||||
|
||||
testWidgets('搜索命中 → 点击邀请 → POST userId 载荷 → 列表刷新 + 提示',
|
||||
testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新',
|
||||
(tester) async {
|
||||
// 命中结果同时含已成员(staff02)与新用户(staff09)。
|
||||
lookups = <Map<String, dynamic>>[
|
||||
lookupUserJson(),
|
||||
lookupUserJson(
|
||||
id: 'u-2',
|
||||
account: 'staff02',
|
||||
displayName: 'Staff Two',
|
||||
),
|
||||
];
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await openInviteSheet(tester);
|
||||
await tester.enterText(find.byType(TextField), 'staff');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// 搜索框 EditableText 与结果条目同名,按 ListTile 定位结果。
|
||||
expect(find.widgetWithText(ListTile, 'staff09'), findsOneWidget);
|
||||
// 已成员(staff02):显示 Already added,无 Add 按钮(结果行限定在
|
||||
// 面板内——页面成员列表同名行在面板后面)。
|
||||
final sheet = find.byType(AlbumInviteSheet);
|
||||
expect(find.text('Already added'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: sheet, matching: find.text('Staff Two')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Invite'));
|
||||
// 新用户:Add → POST {'userId': 'u-9'} → 面板内即时变为已添加。
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final invite = adapter.requests.singleWhere(
|
||||
@@ -158,8 +209,10 @@ void main() {
|
||||
);
|
||||
expect(invite.body, <String, dynamic>{'userId': 'u-9'});
|
||||
expect(find.text('Invited'), findsOneWidget);
|
||||
// 邀请成功后列表重载(staff09 入列)。
|
||||
expect(find.text('Collaborator'), findsNWidgets(3));
|
||||
expect(find.text('Already added'), findsNWidgets(2),
|
||||
reason: '邀请成功后该行即时变为已添加');
|
||||
// 上级列表已刷新(staff09 入列)。
|
||||
expect(find.text('staff09 · Collaborator'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async {
|
||||
@@ -169,22 +222,25 @@ void main() {
|
||||
);
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await openInviteSheet(tester);
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.tap(find.text('Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('User is already a member'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('移除成员:确认对话框门控 → DELETE → 列表刷新', (tester) async {
|
||||
testWidgets('移除成员:「⋯」→ 确认(含成员名)→ DELETE → 列表刷新',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Remove this member?'), findsOneWidget);
|
||||
// 确认文案带成员名。
|
||||
expect(find.text('Remove Staff Two from this album?'), findsOneWidget);
|
||||
// 取消不发请求。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -193,7 +249,7 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -204,6 +260,7 @@ void main() {
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('staff02'), findsNothing);
|
||||
expect(find.text('Staff Two'), findsNothing);
|
||||
expect(find.text('Members (2)'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user