import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart'; /// 契约样本(openapi.yaml v1.2.0 各 schema 的全字段实例)。 Map petJson({Map overrides = const {}}) => { 'id': '019212aa-0000-7000-8000-00000000p001', 'name': '豆豆', 'species': 'dog', 'breedId': '019212aa-0000-7000-8000-00000000b001', 'breedDisplayName': '柴犬', 'customBreedName': null, 'sex': 'male', 'birthDate': '2024-03-15', 'birthDateEstimated': true, 'personality': '粘人', 'microchipNo': '900123456789012', 'sterilizedOn': '2025-06-01', 'status': 'active', 'myRole': 'owner', 'createdAt': '2026-09-01T10:00:00+08:00', 'updatedAt': '2026-09-02T10:00:00+08:00', 'version': 3, ...overrides, }; void main() { group('Pet', () { test('全字段映射', () { final pet = Pet.fromJson(petJson()); expect(pet.id, '019212aa-0000-7000-8000-00000000p001'); expect(pet.name, '豆豆'); expect(pet.species, PetSpecies.dog); expect(pet.breedId, '019212aa-0000-7000-8000-00000000b001'); expect(pet.breedDisplayName, '柴犬'); expect(pet.customBreedName, isNull); expect(pet.sex, PetSex.male); expect(pet.birthDate, DateTime.parse('2024-03-15')); expect(pet.birthDateEstimated, isTrue); expect(pet.personality, '粘人'); expect(pet.microchipNo, '900123456789012'); expect(pet.sterilizedOn, DateTime.parse('2025-06-01')); expect(pet.status, PetStatus.active); expect(pet.myRole, PetRole.owner); expect(pet.version, 3); }); test('可空字段缺席 / null 均映射为 null(自定义品种侧)', () { final pet = Pet.fromJson( petJson( overrides: { 'breedId': null, 'breedDisplayName': null, 'customBreedName': '串串', 'birthDate': null, 'personality': null, 'microchipNo': null, 'sterilizedOn': null, }, ), ); expect(pet.breedId, isNull); expect(pet.breedDisplayName, isNull); expect(pet.customBreedName, '串串'); expect(pet.birthDate, isNull); expect(pet.personality, isNull); expect(pet.microchipNo, isNull); expect(pet.sterilizedOn, isNull); }); test('未知枚举取值抛 FormatException(契约漂移显式暴露)', () { expect( () => Pet.fromJson(petJson(overrides: {'status': 'deleted'})), throwsFormatException, ); expect( () => Pet.fromJson(petJson(overrides: {'myRole': 'admin'})), throwsFormatException, ); }); }); group('CreatePetRequest / UpdatePetRequest', () { test('创建请求:必填 + 可选字段序列化,日期为 YYYY-MM-DD', () { final json = CreatePetRequest( name: '豆豆', species: PetSpecies.dog, sex: PetSex.male, breedId: 'b-1', birthDate: DateTime(2024, 3, 5), birthDateEstimated: false, microchipNo: '900123456789012', sterilizedOn: DateTime(2025, 6, 1), ).toJson(); expect(json, { 'name': '豆豆', 'species': 'dog', 'sex': 'male', 'breedId': 'b-1', 'birthDate': '2024-03-05', 'birthDateEstimated': false, 'microchipNo': '900123456789012', 'sterilizedOn': '2025-06-01', }); }); test('创建请求:可选字段缺席不出现在请求体(部分语义)', () { final json = const CreatePetRequest( name: '咪咪', species: PetSpecies.cat, sex: PetSex.female, customBreedName: '狸花', ).toJson(); expect(json, { 'name': '咪咪', 'species': 'cat', 'sex': 'female', 'customBreedName': '狸花', }); }); test('更新请求:version 必带,缺席字段不出现(不支持清空回 null)', () { final json = const UpdatePetRequest( version: 3, name: '豆豆二世', status: PetStatus.lost, ).toJson(); expect(json, {'version': 3, 'name': '豆豆二世', 'status': 'lost'}); expect(json.containsKey('species'), isFalse); }); }); group('Breed / VaccineCatalogItem', () { test('品种映射', () { final breed = Breed.fromJson({ 'id': 'b-1', 'species': 'cat', 'code': 'ragdoll', 'displayName': '布偶猫', }); expect(breed.id, 'b-1'); expect(breed.species, PetSpecies.cat); expect(breed.code, 'ragdoll'); expect(breed.displayName, '布偶猫'); }); test('疫苗目录映射(description 可空)', () { final item = VaccineCatalogItem.fromJson({ 'id': 'v-1', 'code': 'rabies', 'name': '狂犬疫苗', 'species': 'dog', 'description': null, }); expect(item.name, '狂犬疫苗'); expect(item.description, isNull); }); }); group('WeightRecord', () { test('全字段映射;weightKg 接受 int 与 double 下发', () { final record = WeightRecord.fromJson({ 'id': 'w-1', 'petId': 'p-1', 'weightKg': 12, 'measuredAt': '2026-09-07T09:00:00+08:00', 'source': 'clinic', 'note': '年检称重', 'createdAt': '2026-09-07T09:01:00+08:00', }); expect(record.weightKg, 12.0); expect(record.source, WeightSource.clinic); expect(record.note, '年检称重'); final decimal = WeightRecord.fromJson({ 'id': 'w-2', 'petId': 'p-1', 'weightKg': 4.35, 'measuredAt': '2026-09-07T09:00:00Z', 'source': 'manual', 'note': null, 'createdAt': '2026-09-07T09:01:00Z', }); expect(decimal.weightKg, 4.35); expect(decimal.note, isNull); }); test('创建请求序列化(source/note 可选缺席)', () { final json = CreateWeightRequest( weightKg: 4.35, measuredAt: DateTime.utc(2026, 9, 7, 1, 0), ).toJson(); expect(json['weightKg'], 4.35); expect(json['measuredAt'], '2026-09-07T01:00:00.000Z'); expect(json.containsKey('source'), isFalse); expect(json.containsKey('note'), isFalse); }); }); group('Vaccination', () { Map vaccinationJson({ Map overrides = const {}, }) => { 'id': 'vx-1', 'petId': 'p-1', 'vaccineId': 'v-1', 'vaccineName': '狂犬疫苗', 'seriesKey': 'rabies-initial', 'doseNo': 2, 'doseLabel': '第二针', 'status': 'completed', 'plannedOn': '2026-08-01', 'administeredOn': '2026-08-03', 'nextDueOn': '2027-08-03', 'manufacturer': '硕腾', 'batchNo': 'B20260801', 'notes': '无不良反应', 'createdAt': '2026-08-03T10:00:00+08:00', 'updatedAt': '2026-08-03T10:00:00+08:00', 'version': 1, ...overrides, }; test('全字段映射', () { final record = Vaccination.fromJson(vaccinationJson()); expect(record.vaccineName, '狂犬疫苗'); expect(record.seriesKey, 'rabies-initial'); expect(record.doseNo, 2); expect(record.status, VaccinationStatus.completed); expect(record.administeredOn, DateTime.parse('2026-08-03')); expect(record.nextDueOn, DateTime.parse('2027-08-03')); expect(record.version, 1); }); test('scheduled 态:日期可空字段为 null', () { final record = Vaccination.fromJson( vaccinationJson( overrides: { 'status': 'scheduled', 'administeredOn': null, 'nextDueOn': null, 'doseLabel': null, 'manufacturer': null, 'batchNo': null, 'notes': null, }, ), ); expect(record.status, VaccinationStatus.scheduled); expect(record.administeredOn, isNull); expect(record.nextDueOn, isNull); expect(record.doseLabel, isNull); }); test('创建请求:completed 携带 administeredOn,日期 YYYY-MM-DD', () { final json = CreateVaccinationRequest( vaccineId: 'v-1', seriesKey: 'rabies-initial', doseNo: 1, status: VaccinationStatus.completed, administeredOn: DateTime(2026, 8, 3), nextDueOn: DateTime(2027, 8, 3), ).toJson(); expect(json, { 'vaccineId': 'v-1', 'seriesKey': 'rabies-initial', 'doseNo': 1, 'status': 'completed', 'administeredOn': '2026-08-03', 'nextDueOn': '2027-08-03', }); }); test('更新请求:version 必带,仅提交的字段出现', () { final json = const UpdateVaccinationRequest( version: 1, status: VaccinationStatus.cancelled, ).toJson(); expect(json, {'version': 1, 'status': 'cancelled'}); expect(json.containsKey('vaccineId'), isFalse); }); }); group('HealthEvent', () { test('全字段映射;amountCents 保持整数分不换算', () { final event = HealthEvent.fromJson({ 'id': 'e-1', 'petId': 'p-1', 'eventType': 'medical', 'occurredAt': '2026-09-05T14:00:00+08:00', 'title': '皮肤检查', 'notes': '轻微湿疹', 'amountCents': 12850, 'createdByUserId': 'u-1', 'createdAt': '2026-09-05T14:05:00+08:00', 'updatedAt': '2026-09-05T14:05:00+08:00', 'version': 1, }); expect(event.eventType, HealthEventType.medical); expect(event.title, '皮肤检查'); expect(event.amountCents, 12850); expect(event.createdByUserId, 'u-1'); }); test('amountCents / notes 可空', () { final event = HealthEvent.fromJson({ 'id': 'e-2', 'petId': 'p-1', 'eventType': 'note', 'occurredAt': '2026-09-05T14:00:00Z', 'title': '记录', 'notes': null, 'amountCents': null, 'createdByUserId': 'u-1', 'createdAt': '2026-09-05T14:05:00Z', 'updatedAt': '2026-09-05T14:05:00Z', 'version': 1, }); expect(event.amountCents, isNull); expect(event.notes, isNull); }); test('创建 / 更新请求序列化(amountCents 整数分)', () { final create = CreateHealthEventRequest( eventType: HealthEventType.deworming, occurredAt: DateTime.utc(2026, 9, 5, 6, 0), title: '体内驱虫', amountCents: 6800, ).toJson(); expect(create, { 'eventType': 'deworming', 'occurredAt': '2026-09-05T06:00:00.000Z', 'title': '体内驱虫', 'amountCents': 6800, }); final update = const UpdateHealthEventRequest( version: 2, title: '体内外驱虫', amountCents: 9800, ).toJson(); expect(update, {'version': 2, 'title': '体内外驱虫', 'amountCents': 9800}); }); }); group('CareReminder', () { test('全字段映射;completedAt 非空当且仅当 completed', () { final reminder = CareReminder.fromJson({ 'id': 'r-1', 'petId': 'p-1', 'reminderType': 'deworming', 'title': '体外驱虫', 'dueAt': '2026-09-15T00:00:00+08:00', 'status': 'completed', 'completedAt': '2026-09-14T20:00:00+08:00', 'createdAt': '2026-09-01T10:00:00+08:00', 'updatedAt': '2026-09-14T20:00:00+08:00', }); expect(reminder.reminderType, CareReminderType.deworming); expect(reminder.status, CareReminderStatus.completed); expect(reminder.completedAt, isNotNull); }); test('pending 态 completedAt 为 null', () { final reminder = CareReminder.fromJson({ 'id': 'r-2', 'petId': 'p-1', 'reminderType': 'checkup', 'title': '年度体检', 'dueAt': '2026-10-01T00:00:00Z', 'status': 'pending', 'completedAt': null, 'createdAt': '2026-09-01T10:00:00Z', 'updatedAt': '2026-09-01T10:00:00Z', }); expect(reminder.status, CareReminderStatus.pending); expect(reminder.completedAt, isNull); }); test('创建请求恒不含 status;流转请求 dismissed 不带 completedAt', () { final create = CreateCareReminderRequest( reminderType: CareReminderType.medication, title: '心丝虫药', dueAt: DateTime.utc(2026, 10, 1), ).toJson(); expect(create, { 'reminderType': 'medication', 'title': '心丝虫药', 'dueAt': '2026-10-01T00:00:00.000Z', }); expect(create.containsKey('status'), isFalse); final complete = UpdateCareReminderRequest( status: CareReminderStatus.completed, completedAt: DateTime.utc(2026, 9, 14, 12), ).toJson(); expect(complete, { 'status': 'completed', 'completedAt': '2026-09-14T12:00:00.000Z', }); final dismiss = const UpdateCareReminderRequest( status: CareReminderStatus.dismissed, ).toJson(); expect(dismiss, {'status': 'dismissed'}); expect(dismiss.containsKey('completedAt'), isFalse); }); }); group('CursorPage', () { test('分页信封 {items, nextCursor, hasMore} 映射', () { final page = CursorPage.fromJson({ 'items': [ { 'id': 'w-1', 'petId': 'p-1', 'weightKg': 4.2, 'measuredAt': '2026-09-07T09:00:00Z', 'source': 'manual', 'note': null, 'createdAt': '2026-09-07T09:01:00Z', }, ], 'nextCursor': 'b64cursor', 'hasMore': true, }, WeightRecord.fromJson); expect(page.items.single.weightKg, 4.2); expect(page.nextCursor, 'b64cursor'); expect(page.hasMore, isTrue); }); test('末页:hasMore=false 且 nextCursor 为 null(缺席同义)', () { final page = CursorPage.fromJson({ 'items': [], 'hasMore': false, }, WeightRecord.fromJson); expect(page.items, isEmpty); expect(page.nextCursor, isNull); expect(page.hasMore, isFalse); }); }); group('PetSummary', () { test('四聚合全量映射', () { final summary = PetSummary.fromJson({ 'petId': 'p-1', 'latestWeight': { 'weightKg': 4.35, 'measuredAt': '2026-09-07T09:00:00+08:00', }, 'vaccinationProgress': {'completedDoses': 2, 'totalDoses': 3}, 'nextVaccination': { 'vaccinationId': 'vx-3', 'vaccineId': 'v-1', 'vaccineName': '狂犬疫苗', 'doseNo': 3, 'doseLabel': null, 'dueOn': '2026-08-01', 'source': 'planned', }, 'monthlyExpense': { 'month': '2026-09', 'timezone': 'Asia/Shanghai', 'amountCents': 12850, }, }); expect(summary.petId, 'p-1'); expect(summary.latestWeight!.weightKg, 4.35); expect(summary.vaccinationProgress!.completedDoses, 2); expect(summary.vaccinationProgress!.totalDoses, 3); expect(summary.nextVaccination!.vaccinationId, 'vx-3'); expect(summary.nextVaccination!.doseLabel, isNull); // dueOn 可为过去日期(逾期针仍是下一针) expect(summary.nextVaccination!.dueOn, DateTime.parse('2026-08-01')); expect(summary.nextVaccination!.source, NextVaccinationSource.planned); expect(summary.monthlyExpense.month, '2026-09'); expect(summary.monthlyExpense.timezone, 'Asia/Shanghai'); expect(summary.monthlyExpense.amountCents, 12850); }); test('null 语义:前三项无记录为 null,monthlyExpense 恒非 null、无支出为 0', () { final summary = PetSummary.fromJson({ 'petId': 'p-1', 'latestWeight': null, 'vaccinationProgress': null, 'nextVaccination': null, 'monthlyExpense': { 'month': '2026-09', 'timezone': 'UTC', 'amountCents': 0, }, }); expect(summary.latestWeight, isNull); expect(summary.vaccinationProgress, isNull); expect(summary.nextVaccination, isNull); expect(summary.monthlyExpense.amountCents, 0); }); test('nextDue 来源枚举', () { final next = SummaryNextVaccination.fromJson({ 'vaccinationId': 'vx-1', 'vaccineId': 'v-1', 'vaccineName': '猫三联', 'doseNo': 1, 'doseLabel': '首针', 'dueOn': '2027-08-03', 'source': 'nextDue', }); expect(next.source, NextVaccinationSource.nextDue); expect(next.doseLabel, '首针'); }); }); }