Files
patbond-flutter/test/features/pets/pet_avatar_wiring_test.dart
lixi eff3526840 新增:宠物头像接线——铅笔角标接上传、列表与详情展示真实头像(T3.5-09)
- `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退
  爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。
- 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有
  头像时先给「更换 / 移除」二选一,移除即三态显式 null。
- 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像,
  viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发
  `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严
  的一半定档,caregiver 立刻 403。
- `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持
  M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。
- 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的
  覆盖操作,不该自动生效两次)。

测试 588 → 597(+9)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:25 +08:00

324 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> 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<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
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<String>.value('a-1'),
);
expect(request.toJson(), {'version': 3, 'avatarAssetId': 'a-1'});
});
test('UpdatePetRequest:清除头像为显式 null;缺省则键不出现', () {
expect(
const UpdatePetRequest(
version: 3,
avatarAssetId: PatchField<String>.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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(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<PetAvatar>(find.byType(PetAvatar))
.toList();
expect(avatars.length, 2);
expect(avatars[0].url, signedAvatar);
expect(avatars[1].url, isNull, reason: '无头像回退爪印占位(不显示破图)');
});
});
}