/// 成员管理页 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> members; late List> lookups; setUp(() { store = FakeSessionStore() ..session = Session( token: 'tok-live', expiresAt: DateTime.utc(2099, 1, 1), account: 'staff01', ); members = >[ 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 = >[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 = >[ ...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, {'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 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 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 = >[ 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)); expect( adapter.requests.where((r) => r.path == '/v1/admin/users'), isEmpty, ); // 连续输入两次,防抖合并为最后一次。 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(); expect(lookupsRequests, hasLength(1)); expect(lookupsRequests.single.uri.queryParameters['accountPrefix'], 'sta'); }); testWidgets('邀请流:搜索命中 → Add → POST userId 载荷 → 已添加态 + 列表刷新', (tester) async { // 命中结果同时含已成员(staff02)与新用户(staff09)。 lookups = >[ 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, {'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); }); }