import 'dart:async'; 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/pet_avatar.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/media_uploader.dart'; import 'package:patbond_flutter/features/pets/pet_detail_page.dart'; import 'package:patbond_flutter/features/pets/pet_display.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; import 'package:patbond_flutter/features/pets/pets_page.dart'; import '../../helpers/community_test_helpers.dart'; import '../../helpers/media_test_helpers.dart'; import '../../helpers/pet_test_helpers.dart'; /// T3.5-09 宠物头像接线。 /// /// 两条不变量在这里守住: /// 1. **权限按 WRITE 档呈现**(owner + caregiver 有入口,viewer 没有),与 /// 资料编辑(MANAGE,仅 owner)不同档。 /// 2. **纯头像 PATCH 不夹带资料字段**——夹带会把服务端定档抬到 MANAGE, /// caregiver 立刻 403。 void main() { const signedAvatar = 'https://minio.local/patbond-media/pet_avatar/2026/09/a-1' '?X-Amz-Signature=sig'; late FakePetsRepository pets; late PetsController controller; late FakeCommunityRepository community; late FakeMediaImagePicker picker; late FakeMediaCompressor compressor; late FakeDirectUploadClient direct; setUp(() { pets = FakePetsRepository(); controller = PetsController(repository: pets); community = FakeCommunityRepository(); 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, ); Future pumpDetail( WidgetTester tester, { bool withUploader = true, }) async { tester.view.physicalSize = const Size(700, 2400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: const Scaffold(body: Text('列表基底')), ), ); final navigator = tester.state(find.byType(Navigator)); unawaited( navigator.push( MaterialPageRoute( builder: (_) => PetDetailPage( controller: controller, petId: 'p-1', avatarUploaderBuilder: withUploader ? buildUploader : null, ), ), ), ); await tester.pump(); } group('契约字段', () { test('Pet.avatarUrl 解析(无头像为 null;不外露 avatarAssetId)', () { expect(buildPet('p-1').avatarUrl, isNull); expect( buildPet('p-1', overrides: {'avatarUrl': signedAvatar}).avatarUrl, signedAvatar, ); }); test('UpdatePetRequest:只带头像时载荷仅 version + avatarAssetId', () { const request = UpdatePetRequest( version: 3, avatarAssetId: PatchField.value('a-1'), ); expect(request.toJson(), {'version': 3, 'avatarAssetId': 'a-1'}); }); test('UpdatePetRequest:清除头像为显式 null;缺省则键不出现', () { expect( const UpdatePetRequest( version: 3, avatarAssetId: PatchField.clear(), ).toJson(), {'version': 3, 'avatarAssetId': null}, ); final absent = const UpdatePetRequest(version: 3, name: '豆豆').toJson(); expect(absent, {'version': 3, 'name': '豆豆'}); expect( absent.containsKey('avatarAssetId'), isFalse, reason: '改名请求不该顺手把头像清掉', ); }); test('头像写入失败文案分层(40405 / 42203 与 40401 区分开)', () { expect( petAvatarSaveErrorMessage( const ApiBusinessException( code: ApiCodes.mediaNotFound, message: 'x', ), ), '头像已失效,请重新上传', ); expect( petAvatarSaveErrorMessage( const ApiBusinessException( code: ApiCodes.mediaNotReady, message: 'x', ), ), '头像还没上传完,请稍后重试', ); expect( petAvatarSaveErrorMessage( const ApiBusinessException( code: ApiCodes.petAccessDenied, message: 'x', ), ), '你没有修改该宠物头像的权限', ); }); }); group('权限呈现(WRITE 档)', () { testWidgets('owner:头像可点 + 铅笔角标', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1'); await pumpDetail(tester); await tester.pumpAndSettle(); final avatar = tester.widget(find.byType(PetAvatar)); expect(avatar.showEditBadge, isTrue); expect(avatar.onTap, isNotNull); expect(avatar.semanticLabel, '更换宠物头像'); }); testWidgets('caregiver:头像入口在(WRITE 档),但资料编辑入口不在(MANAGE 档)', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1', overrides: {'myRole': 'caregiver'}); await pumpDetail(tester); await tester.pumpAndSettle(); final avatar = tester.widget(find.byType(PetAvatar)); expect(avatar.showEditBadge, isTrue, reason: 'ADR-022:头像属 WRITE 档'); expect(avatar.onTap, isNotNull); expect(find.text('编辑资料'), findsNothing, reason: '改档案仍是 MANAGE 档'); }); testWidgets('viewer:头像不可点、无角标', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1', overrides: {'myRole': 'viewer'}); await pumpDetail(tester); await tester.pumpAndSettle(); final avatar = tester.widget(find.byType(PetAvatar)); expect(avatar.showEditBadge, isFalse); expect(avatar.onTap, isNull); }); testWidgets('未装配上传能力:owner 也不渲染头像入口', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1'); await pumpDetail(tester, withUploader: false); await tester.pumpAndSettle(); final avatar = tester.widget(find.byType(PetAvatar)); expect(avatar.showEditBadge, isFalse); expect(avatar.onTap, isNull); }); }); group('上传与清除', () { testWidgets('无头像 → 点头像直接上传 → PATCH 只带 version + avatarAssetId', ( tester, ) async { pets.getPetHandler = (_) async => buildPet('p-1', version: 3); UpdatePetRequest? captured; pets.updatePetHandler = (petId, request) async { captured = request; return buildPet( 'p-1', version: 4, overrides: {'avatarUrl': signedAvatar}, ); }; await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.byType(PetAvatar)); await tester.pumpAndSettle(); await tester.tap(find.text('使用这张')); await tester.pumpAndSettle(); expect(community.lastMediaUploadRequest!.purpose, MediaPurpose.petAvatar); expect(captured!.toJson(), {'version': 3, 'avatarAssetId': 'a-1'}); expect(find.text('头像已更新'), findsOneWidget); // 详情页头像随即换成服务端回显的现签 URL。 expect( tester.widget(find.byType(PetAvatar)).url, signedAvatar, ); }); testWidgets('已有头像 → 点头像给「更换 / 移除」;移除发显式 null', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1', version: 5, overrides: {'avatarUrl': signedAvatar}); UpdatePetRequest? captured; pets.updatePetHandler = (petId, request) async { captured = request; return buildPet('p-1', version: 6); }; await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.byType(PetAvatar)); await tester.pumpAndSettle(); expect(find.text('更换头像'), findsOneWidget); expect(find.text('移除头像'), findsOneWidget); await tester.tap(find.text('移除头像')); await tester.pumpAndSettle(); final payload = captured!.toJson(); expect(payload.containsKey('avatarAssetId'), isTrue); expect(payload['avatarAssetId'], isNull); expect(payload['version'], 5); expect(payload.keys.length, 2, reason: '不得夹带任何资料字段(否则抬到 MANAGE 档)'); expect(find.text('已移除头像'), findsOneWidget); expect(tester.widget(find.byType(PetAvatar)).url, isNull); }); testWidgets('40902 版本冲突:提示 + 自动重取档案(不静默重放)', (tester) async { var getCalls = 0; pets.getPetHandler = (_) async { getCalls += 1; return buildPet('p-1', version: getCalls == 1 ? 3 : 9); }; pets.updatePetHandler = (petId, request) async => throw const PetVersionConflictException(message: 'stale'); await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.byType(PetAvatar)); await tester.pumpAndSettle(); await tester.tap(find.text('使用这张')); await tester.pumpAndSettle(); expect(find.text('档案已被更新,请重新操作'), findsOneWidget); expect(getCalls, 2, reason: '冲突后重取档案拿新 version'); }); testWidgets('42203 头像未就绪:SnackBar 提示,不改动头像', (tester) async { pets.getPetHandler = (_) async => buildPet('p-1', version: 3); pets.updatePetHandler = (petId, request) async => throw const ApiBusinessException( code: ApiCodes.mediaNotReady, message: 'uploading', ); await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.byType(PetAvatar)); await tester.pumpAndSettle(); await tester.tap(find.text('使用这张')); await tester.pumpAndSettle(); expect(find.text('头像还没上传完,请稍后重试'), findsOneWidget); expect(tester.widget(find.byType(PetAvatar)).url, isNull); }); }); group('列表展示', () { testWidgets('列表卡展示真实头像;无头像回退占位', (tester) async { pets.listPetsHandler = () async => [ buildPet('p-1', name: '豆豆', overrides: {'avatarUrl': signedAvatar}), buildPet('p-2', name: '花花'), ]; await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Scaffold(body: PetsPage(controller: controller)), ), ); await tester.pumpAndSettle(); final avatars = tester .widgetList(find.byType(PetAvatar)) .toList(); expect(avatars.length, 2); expect(avatars[0].url, signedAvatar); expect(avatars[1].url, isNull, reason: '无头像回退爪印占位(不显示破图)'); }); }); }