53c25697a6
契约 minLength 2→1 + 服务端 bounds 1..64 + admin 两处/App 一处前端门槛 同步放宽;防抖测试基线更新('s' 与合并后 'sta' 共两条)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
271 lines
10 KiB
Dart
271 lines
10 KiB
Dart
/// 成员管理页 widget 测试(UI-03 §4 页面 11 重设计后)。
|
|
///
|
|
/// 覆盖:Owner 单独分组(无移除)与 Collaborators 列表(首字母头像/
|
|
/// 显示名/账号·角色/行尾「⋯」)、标题计数、空协作者态;Invite colleague
|
|
/// BottomSheet 搜索(防抖 300ms、≥2 字符、连续输入合并)→ Add 载荷 →
|
|
/// 已添加态;MEMBER_CONFLICT 本地化;移除确认(含成员名)→ DELETE。
|
|
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_members_page.dart';
|
|
|
|
import '../helpers/fakes.dart';
|
|
|
|
void main() {
|
|
late FakeSessionStore store;
|
|
late List<Map<String, dynamic>> members;
|
|
late List<Map<String, dynamic>> lookups;
|
|
|
|
setUp(() {
|
|
store = FakeSessionStore()
|
|
..session = Session(
|
|
token: 'tok-live',
|
|
expiresAt: DateTime.utc(2099, 1, 1),
|
|
account: 'staff01',
|
|
);
|
|
members = <Map<String, dynamic>>[
|
|
memberJson(
|
|
userId: 'u-owner',
|
|
account: 'boss01',
|
|
displayName: 'The Owner',
|
|
role: 'owner',
|
|
),
|
|
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({
|
|
(int, Object?)? Function(String method, String path)? onMutation,
|
|
}) =>
|
|
FakeAdapter((o) {
|
|
if (o.path == '/v1/albums/alb-1/members' && o.method == 'GET') {
|
|
return (200, cursorPageBody(members));
|
|
}
|
|
if (o.path == '/v1/albums/alb-1/members' && o.method == 'POST') {
|
|
final override = onMutation?.call('POST', o.path);
|
|
if (override != null) return override;
|
|
members = <Map<String, dynamic>>[
|
|
...members,
|
|
memberJson(userId: 'u-9', account: 'staff09'),
|
|
];
|
|
return (201, memberJson(userId: 'u-9', account: 'staff09'));
|
|
}
|
|
if (o.path == '/v1/albums/alb-1/members/u-2' && o.method == 'DELETE') {
|
|
final override = onMutation?.call('DELETE', o.path);
|
|
if (override != null) return override;
|
|
members = members
|
|
.where((m) => m['userId'] != 'u-2')
|
|
.toList();
|
|
return (204, null);
|
|
}
|
|
if (o.path == '/v1/admin/users' && o.method == 'GET') {
|
|
return (200, <String, dynamic>{'items': lookups});
|
|
}
|
|
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> pumpMembers(WidgetTester tester, ApiClient client) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
apiClientProvider.overrideWith((ref) => client),
|
|
// 本人识别走会话存储:覆写为内存假件(secure storage 插件缺失)。
|
|
sessionStoreProvider.overrideWith((ref) => store),
|
|
],
|
|
child: MaterialApp(
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: AlbumMembersPage(
|
|
album: Album.fromJson(albumJson()),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// 打开邀请面板(底部全宽按钮)。
|
|
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('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('空协作者:占位文案 + 底部邀请入口', (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('邀请搜索防抖:单字符即请求;连续输入合并为最后一次(300ms)',
|
|
(tester) async {
|
|
final adapter = membersAdapter();
|
|
await pumpMembers(tester, clientOf(adapter));
|
|
|
|
await openInviteSheet(tester);
|
|
final field = find.byType(TextField);
|
|
|
|
// 单字符即发请求(2026-09-29 与服务端 1..64 bounds 对齐——中文姓氏单字)。
|
|
await tester.enterText(field, 's');
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
expect(
|
|
adapter.requests.where((r) => r.path == '/v1/admin/users'),
|
|
isNotEmpty,
|
|
);
|
|
|
|
// 连续输入两次,防抖合并为最后一次:'st' 不产生独立请求,只有 'sta'。
|
|
await tester.enterText(field, 'st');
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.enterText(field, 'sta');
|
|
await tester.pump(const Duration(milliseconds: 350));
|
|
|
|
final lookupsRequests = adapter.requests
|
|
.where((r) => r.path == '/v1/admin/users')
|
|
.toList();
|
|
// 前段 's' 一条 + 合并后的 'sta' 一条(单字符即搜后基线为 2)。
|
|
expect(lookupsRequests, hasLength(2));
|
|
expect(
|
|
lookupsRequests.map((r) => r.uri.queryParameters['accountPrefix']).toList(),
|
|
['s', 'sta'],
|
|
);
|
|
});
|
|
|
|
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 openInviteSheet(tester);
|
|
await tester.enterText(find.byType(TextField), 'staff');
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
|
|
// 已成员(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,
|
|
);
|
|
|
|
// 新用户:Add → POST {'userId': 'u-9'} → 面板内即时变为已添加。
|
|
await tester.tap(find.text('Add'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final invite = adapter.requests.singleWhere(
|
|
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/members',
|
|
);
|
|
expect(invite.body, <String, dynamic>{'userId': 'u-9'});
|
|
expect(find.text('Invited'), findsOneWidget);
|
|
expect(find.text('Already added'), findsNWidgets(2),
|
|
reason: '邀请成功后该行即时变为已添加');
|
|
// 上级列表已刷新(staff09 入列)。
|
|
expect(find.text('staff09 · Collaborator'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async {
|
|
final adapter = membersAdapter(
|
|
onMutation: (method, path) =>
|
|
method == 'POST' ? (409, errorBody(ErrorCodes.memberConflict)) : null,
|
|
);
|
|
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('Add'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('User is already a member'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('移除成员:「⋯」→ 确认(含成员名)→ DELETE → 列表刷新',
|
|
(tester) async {
|
|
final adapter = membersAdapter();
|
|
await pumpMembers(tester, clientOf(adapter));
|
|
|
|
await tester.tap(find.byIcon(Icons.more_horiz));
|
|
await tester.pumpAndSettle();
|
|
|
|
// 确认文案带成员名。
|
|
expect(find.text('Remove Staff Two from this album?'), findsOneWidget);
|
|
// 取消不发请求。
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
adapter.requests.any((r) => r.method == 'DELETE'),
|
|
isFalse,
|
|
);
|
|
|
|
await tester.tap(find.byIcon(Icons.more_horiz));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
adapter.requests.any(
|
|
(r) => r.method == 'DELETE' && r.path == '/v1/albums/alb-1/members/u-2',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(find.text('Staff Two'), findsNothing);
|
|
expect(find.text('Members (2)'), findsOneWidget);
|
|
});
|
|
}
|