新增:pets feature 数据层(T2-11,契约 v1.2.0 全 18 操作)
CI / flutter-gates (push) Successful in 1m12s

- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/
  疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封),
  枚举严格解析,请求体部分更新语义(缺席字段不出现)
- pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository,
  12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key;
  复用既有 ApiClient token 拦截与单飞刷新重放
- pet_exceptions.dart:新 8 错误码映射为类型化异常
  (40300/40401/40402/40902/40903/40904/42201/42202),
  仍可按 ApiBusinessException 基类捕获
- pets_controller.dart:宠物档案状态自 AppState 拆出
  (Controller → Repository → API Client 分层,四态就绪供 T2-12)
- money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用)
- api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define
  可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码
- 测试 64 → 126:DTO 映射、错误类型化映射、请求线路
  (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:22:24 +08:00
parent 33b993ca0c
commit 7fb9031f8d
12 changed files with 2665 additions and 1 deletions
@@ -0,0 +1,167 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.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_repository.dart';
import '../../helpers/pet_test_helpers.dart';
/// 假仓库:各方法可注入行为,未注入的方法抛 UnimplementedError。
class FakePetsRepository implements PetsRepository {
Future<List<Pet>> Function()? listPetsHandler;
Future<Pet> Function(CreatePetRequest)? createPetHandler;
Future<Pet> Function(String)? getPetHandler;
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
@override
Future<List<Pet>> listPets() =>
listPetsHandler?.call() ?? Future.value(const []);
@override
Future<Pet> createPet(CreatePetRequest request) => createPetHandler!(request);
@override
Future<Pet> getPet(String petId) => getPetHandler!(petId);
@override
Future<Pet> updatePet(String petId, UpdatePetRequest request) =>
updatePetHandler!(petId, request);
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}
Pet pet(String id, {String name = '豆豆', int version = 1}) => Pet.fromJson(
samplePetJson(overrides: {'id': id, 'name': name, 'version': version}),
);
void main() {
late FakePetsRepository repository;
late PetsController controller;
setUp(() {
repository = FakePetsRepository();
controller = PetsController(repository: repository);
});
test('初始态为 initial,不发请求', () {
expect(controller.phase, PetsLoadPhase.initial);
expect(controller.pets, isEmpty);
expect(controller.isEmpty, isFalse);
});
test('refresh 成功:loading → ready,列表就位', () async {
repository.listPetsHandler = () async => [pet('p-1'), pet('p-2')];
final phases = <PetsLoadPhase>[];
controller.addListener(() => phases.add(controller.phase));
await controller.refresh();
expect(phases, [PetsLoadPhase.loading, PetsLoadPhase.ready]);
expect(controller.pets.map((p) => p.id), ['p-1', 'p-2']);
expect(controller.isEmpty, isFalse);
expect(controller.lastError, isNull);
});
test('refresh 空列表:ready 且 isEmpty(新用户空态)', () async {
repository.listPetsHandler = () async => [];
await controller.refresh();
expect(controller.phase, PetsLoadPhase.ready);
expect(controller.isEmpty, isTrue);
});
test('refresh 失败:error 态保留类型化错误,重试可恢复', () async {
repository.listPetsHandler = () async =>
throw const ApiNetworkException('断网');
await controller.refresh();
expect(controller.phase, PetsLoadPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
expect(controller.phase, PetsLoadPhase.ready);
expect(controller.lastError, isNull);
expect(controller.pets, hasLength(1));
});
test('createPet:成功后插入列表头', () async {
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
repository.createPetHandler = (request) async => pet('p-2', name: '咪咪');
final created = await controller.createPet(
const CreatePetRequest(
name: '咪咪',
species: PetSpecies.cat,
sex: PetSex.female,
customBreedName: '狸花',
),
);
expect(created.id, 'p-2');
expect(controller.pets.map((p) => p.id), ['p-2', 'p-1']);
});
test('createPet 失败(40903):类型化异常外抛,列表不变', () async {
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
repository.createPetHandler = (request) async =>
throw const MicrochipTakenException(message: '芯片号已被登记');
await expectLater(
controller.createPet(
const CreatePetRequest(
name: '咪咪',
species: PetSpecies.cat,
sex: PetSex.female,
customBreedName: '狸花',
),
),
throwsA(isA<MicrochipTakenException>()),
);
expect(controller.pets, hasLength(1));
});
test('updatePet:成功后同步列表内存副本', () async {
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
await controller.refresh();
repository.updatePetHandler = (petId, request) async =>
pet('p-1', name: '豆豆二世', version: 2);
await controller.updatePet(
'p-1',
const UpdatePetRequest(version: 1, name: '豆豆二世'),
);
expect(controller.pets.single.name, '豆豆二世');
expect(controller.pets.single.version, 2);
});
test('updatePet 版本冲突(40902):异常外抛供页面提示刷新重提', () async {
repository.updatePetHandler = (petId, request) async =>
throw const PetVersionConflictException(message: '数据已被修改');
await expectLater(
controller.updatePet('p-1', const UpdatePetRequest(version: 1)),
throwsA(isA<PetVersionConflictException>()),
);
});
test('getPet:详情结果回写列表副本', () async {
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
await controller.refresh();
repository.getPetHandler = (petId) async => pet('p-1', version: 5);
final detail = await controller.getPet('p-1');
expect(detail.version, 5);
expect(controller.pets.single.version, 5);
});
}