import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/theme/app_theme.dart'; import 'package:patbond_flutter/core/widgets/inline_error_banner.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/features/profile/profile_controller.dart'; import 'package:patbond_flutter/features/profile/profile_edit_page.dart'; import '../../helpers/auth_test_helpers.dart'; import '../../helpers/community_test_helpers.dart'; import '../../helpers/media_test_helpers.dart'; /// T3.5-08 编辑页:**PATCH 三态载荷**是本文件的重点。 /// /// 每个成功用例都断言一次「未改字段的键不出现在 JSON 里」——这是三态与 /// 两态的唯一可观测差别,也是「只改昵称却把头像清掉」这类事故的唯一防线。 /// 昵称输入框(AppTextField 内层 TextFormField)。 Finder get nicknameField => find.byType(TextFormField); void main() { late FakeCommunityRepository community; late FakeMediaImagePicker picker; late FakeMediaCompressor compressor; late FakeDirectUploadClient direct; setUp(() { community = FakeCommunityRepository(); community.onGetMyCommunityStats = () async => CommunityStats.fromJson(sampleCommunityStatsJson()); community.onGetFollowStats = (_) async => FollowStats.fromJson(sampleFollowStatsJson()); picker = FakeMediaImagePicker([decodablePickedImage()]); compressor = FakeMediaCompressor(); direct = FakeDirectUploadClient(); community.onCreateMediaUpload = (_) async => credentials(); community.onCompleteMediaUpload = (assetId) async => readyAsset(assetId: assetId); }); MediaUploader buildUploader(MediaPurpose purpose) => MediaUploader( repository: community, purpose: purpose, picker: picker, compressor: compressor, directUpload: direct, maxImages: 1, maxConcurrentUploads: 1, ); /// 直接 push 编辑页(资料页链路另有测试;这里只测编辑页本身)。 Future pumpEdit( WidgetTester tester, { String? nickname, String? avatarUrl, bool withUploader = true, ApiException? saveError, }) async { tester.view.physicalSize = const Size(700, 1800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); final auth = FakeAuthRepository( meHandler: () async => buildProfile( username: 'llx', nickname: nickname, avatarUrl: avatarUrl, ), updateMeHandler: (_) async { if (saveError != null) throw saveError; return buildProfile(username: 'llx', nickname: nickname); }, ); final controller = ProfileController( authRepository: auth, communityRepository: community, ); addTearDown(controller.dispose); await controller.refresh(); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: ProfileEditPage( controller: controller, avatarUploaderBuilder: withUploader ? buildUploader : null, ), ), ); await tester.pumpAndSettle(); return auth; } group('三态载荷', () { testWidgets('只改昵称 → 载荷只有 nickname,avatarAssetId 键不出现', (tester) async { final auth = await pumpEdit( tester, avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', ); await tester.enterText(nicknameField, '小柴'); await tester.pump(); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); final payload = auth.updateMePayloads.single; expect(payload, {'nickname': '小柴'}); expect( payload.containsKey('avatarAssetId'), isFalse, reason: '发成 avatarAssetId: null 会把用户已有头像一起清掉', ); }); testWidgets('点「清除昵称」 → nickname 显式 null,头像键不出现', (tester) async { final auth = await pumpEdit( tester, nickname: '小柴', avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', ); await tester.tap(find.text('清除昵称')); await tester.pumpAndSettle(); expect(find.text('保存后将清除昵称,展示名回退为用户名'), findsOneWidget); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); final payload = auth.updateMePayloads.single; expect(payload.containsKey('nickname'), isTrue); expect(payload['nickname'], isNull); expect(payload.containsKey('avatarAssetId'), isFalse); }); testWidgets('点「清除头像」 → avatarAssetId 显式 null,nickname 键不出现', ( tester, ) async { final auth = await pumpEdit( tester, nickname: '小柴', avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s', ); await tester.tap(find.text('清除头像')); await tester.pumpAndSettle(); expect(find.text('保存后将移除头像'), findsOneWidget); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); final payload = auth.updateMePayloads.single; expect(payload.containsKey('avatarAssetId'), isTrue); expect(payload['avatarAssetId'], isNull); expect( payload.containsKey('nickname'), isFalse, reason: '昵称没碰过就不该出现在载荷里(更不该是 null)', ); }); testWidgets('输入与原昵称相同 → 视为未改,保存钮禁用、不发请求', (tester) async { final auth = await pumpEdit(tester, nickname: '小柴'); // 预填即原值。 expect(find.text('小柴'), findsOneWidget); final button = tester.widget( find.widgetWithText(FilledButton, '保存'), ); expect(button.onPressed, isNull); expect(auth.updateMePayloads, isEmpty); }); testWidgets('无昵称时预填空串(不预填 username,避免把回退固化成真昵称)', (tester) async { await pumpEdit(tester); final field = tester.widget(nicknameField); expect(field.controller!.text, ''); expect(find.text('未设置,当前展示为用户名「llx」'), findsOneWidget); // 无昵称时不给「清除昵称」入口(清一个不存在的东西无意义)。 expect(find.text('清除昵称'), findsNothing); }); }); group('校验与失败语义', () { testWidgets('33 码点昵称:字段级 errorText,不发请求', (tester) async { final auth = await pumpEdit(tester); await tester.enterText(nicknameField, '柴' * 33); await tester.pump(); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); expect(find.text('昵称最多 32 个字'), findsOneWidget); expect(auth.updateMePayloads, isEmpty); }); testWidgets('纯空格昵称:不是隐式清空,提示走显式清除入口', (tester) async { final auth = await pumpEdit(tester, nickname: '小柴'); await tester.enterText(nicknameField, ' '); await tester.pump(); // 空白输入被判为「不改」,保存钮禁用(不会误发清空)。 final button = tester.widget( find.widgetWithText(FilledButton, '保存'), ); expect(button.onPressed, isNull); expect(auth.updateMePayloads, isEmpty); }); testWidgets('42203 头像未就绪 → 横幅提示,页面留在编辑态可重试', (tester) async { await pumpEdit( tester, saveError: const ApiBusinessException( code: ApiCodes.mediaNotReady, message: 'asset uploading', ), ); await tester.enterText(nicknameField, '小柴'); await tester.pump(); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); expect(find.byType(InlineErrorBanner), findsOneWidget); expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget); expect(find.byType(ProfileEditPage), findsOneWidget); }); testWidgets('40405 头像 asset 失效 → 提示重新上传', (tester) async { await pumpEdit( tester, saveError: const ApiBusinessException( code: ApiCodes.mediaNotFound, message: 'gone', ), ); await tester.enterText(nicknameField, '小柴'); await tester.pump(); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); expect(find.text('头像已失效,请重新上传'), findsOneWidget); }); }); group('头像上传接线', () { testWidgets('上传全链路:purpose=user_avatar,ready 后确认 → 载荷带新 assetId', ( tester, ) async { final auth = await pumpEdit(tester); await tester.tap(find.text('更换头像')); await tester.pumpAndSettle(); // 上传成功后 sheet 给预览 + 「使用这张」。 expect(find.text('使用这张'), findsOneWidget); await tester.tap(find.text('使用这张')); await tester.pumpAndSettle(); // 用途即引用侧的类型检查:必须是 user_avatar。 final request = community.lastMediaUploadRequest!; expect(request.purpose, MediaPurpose.userAvatar); expect(request.purpose.wire, 'user_avatar'); expect(find.text('已选择\n新头像'), findsOneWidget); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); final payload = auth.updateMePayloads.single; expect(payload, {'avatarAssetId': 'a-1'}); expect(payload.containsKey('nickname'), isFalse); }); testWidgets('上传后再改昵称 → 两键齐出(一次 PATCH 改两样)', (tester) async { final auth = await pumpEdit(tester); await tester.tap(find.text('更换头像')); await tester.pumpAndSettle(); await tester.tap(find.text('使用这张')); await tester.pumpAndSettle(); await tester.enterText(nicknameField, '小柴'); await tester.pump(); await tester.tap(find.text('保存')); await tester.pumpAndSettle(); expect(auth.updateMePayloads.single, { 'nickname': '小柴', 'avatarAssetId': 'a-1', }); }); }); }