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:
@@ -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