新增:疫苗登记/记录列表与档案页摘要卡接入(T2-13 疫苗半边)
CI / flutter-gates (push) Successful in 1m49s

- VaccinationFormPage:目录按宠物物种过滤(四态)、seriesKey 目录 code
  预填、scheduled/completed 状态-日期规则结构化 + 纯函数双重前端拦截,
  42201/40904 后端兜底横幅(均有测试)
- VaccinationRecordsPage:按系列分组、三态 TagPill(含 cancelled 留痕)、
  四态齐备;viewer 隐藏登记入口
- 档案页数据卡行接 GET /pets/{id}/summary 实时聚合:最新体重/疫苗进度/
  下一针三卡,null 语义为空态文案而非 0/0;从记录页返回即重拉摘要
- health_record 埋点装配(app→shell→pets→detail→记录页面族)
- 测试 205 → 224(+19)全绿;analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:20:04 +08:00
parent 5b34fa336b
commit c91f18a845
9 changed files with 1570 additions and 3 deletions
@@ -9,6 +9,8 @@ import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.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/vaccination_records_page.dart';
import 'package:patbond_flutter/features/pets/weight_records_page.dart';
import '../../helpers/pet_test_helpers.dart';
@@ -22,6 +24,10 @@ void main() {
});
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。
tester.view.physicalSize = const Size(700, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
@@ -176,4 +182,114 @@ void main() {
expect(find.text('编辑宠物资料'), findsOneWidget);
expect(find.text('豆豆'), findsOneWidget);
});
group('T2-13 · 数据卡行接 summary', () {
testWidgets('三卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名)', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz;
repository.getPetSummaryHandler = (petId, tz) async {
capturedTz = tz;
return buildSummary();
};
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('健康数据'), findsOneWidget);
expect(find.text('4.35 kg'), findsOneWidget);
expect(find.text('最新体重'), findsOneWidget);
expect(find.text('2/3 针'), findsOneWidget);
expect(find.text('疫苗进度'), findsOneWidget);
expect(find.text('2026-08-01'), findsOneWidget);
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// 本单不消费 monthlyExpenseT2-14),tz 不传走服务端缺省 UTC。
expect(capturedTz, isNull);
});
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
overrides: {
'latestWeight': null,
'vaccinationProgress': null,
'nextVaccination': null,
},
);
await pumpDetail(tester);
await tester.pumpAndSettle();
expect(find.text('暂无记录'), findsOneWidget);
expect(find.text('未登记'), findsOneWidget);
expect(find.text('暂无安排'), findsOneWidget);
expect(find.textContaining('0/0'), findsNothing);
expect(find.text('下一针'), findsOneWidget);
});
testWidgets('摘要加载失败:档案主链路不受阻,行内重试恢复', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var calls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return buildSummary();
};
await pumpDetail(tester);
await tester.pumpAndSettle();
// 基本资料照常渲染,摘要行内错误 + 重试。
expect(find.text('豆豆'), findsOneWidget);
expect(find.text('健康数据加载失败'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('4.35 kg'), findsOneWidget);
expect(find.text('健康数据加载失败'), findsNothing);
});
testWidgets('点体重卡 → 体重历史页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++;
return buildSummary();
};
repository.listWeightsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false);
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('最新体重'));
await tester.pumpAndSettle();
expect(find.byType(WeightRecordsPage), findsOneWidget);
expect(find.text('体重记录'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(summaryCalls, 2);
});
testWidgets('点疫苗卡 → 疫苗记录页(viewer 权限透传隐藏登记入口)', (tester) async {
repository.getPetHandler = (petId) async =>
buildPet('p-1', overrides: {'myRole': 'viewer'});
repository.getPetSummaryHandler = (petId, tz) async => buildSummary();
repository.listVaccinationsHandler = (petId) async => const [];
await pumpDetail(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('疫苗进度'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationRecordsPage), findsOneWidget);
// viewer:空态无 CTA、AppBar 无添加入口。
expect(find.text('登记第一针'), findsNothing);
expect(find.byIcon(Icons.add), findsNothing);
});
});
}
@@ -0,0 +1,261 @@
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/features/pets/health_record_analytics.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/vaccination_form_page.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late FakePetsRepository repository;
late List<(String, Map<String, dynamic>?)> events;
late HealthRecordAnalytics analytics;
setUp(() {
repository = FakePetsRepository();
events = [];
analytics = HealthRecordAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> eventsOf(String name) => [
for (final e in events)
if (e.$1 == name) e.$2,
];
Future<void> pumpForm(WidgetTester tester) async {
tester.view.physicalSize = const Size(700, 2000);
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: (_) => VaccinationFormPage(
repository: repository,
petId: 'p-1',
petSpecies: PetSpecies.dog,
analytics: analytics,
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> selectVaccine(WidgetTester tester, String name) async {
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text(name).last);
await tester.pumpAndSettle();
}
Future<void> pickDate(WidgetTester tester, String tileLabel) async {
await tester.tap(find.text(tileLabel));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
testWidgets('目录按宠物物种过滤;加载失败可重试恢复', (tester) async {
var calls = 0;
PetSpecies? capturedSpecies;
repository.listVaccineCatalogHandler = (species) async {
calls++;
capturedSpecies = species;
if (calls == 1) throw const ApiNetworkException('断网');
return [VaccineCatalogItem.fromJson(sampleVaccineCatalogJson())];
};
await pumpForm(tester);
expect(find.text('疫苗目录加载失败,请重试后选择'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
expect(capturedSpecies, PetSpecies.dog);
});
testWidgets('空表单提交:疫苗与计划日期双拦截 + failed(validation_error)', (tester) async {
await pumpForm(tester);
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('请选择疫苗'), findsOneWidget);
expect(find.text('请选择计划接种日期'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['recordType'], 'vaccine');
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('选疫苗自动预填系列(目录 code),首次输入触发 started 一次', (tester) async {
await pumpForm(tester);
expect(eventsOf('health_record_create_started'), isEmpty);
await selectVaccine(tester, '狂犬疫苗');
expect(
tester
.widget<TextFormField>(find.widgetWithText(TextFormField, '系列名称'))
.controller!
.text,
'rabies',
);
// 再动一个字段:started 不重复。
await tester.enterText(find.widgetWithText(TextFormField, '剂次'), '2');
await tester.pumpAndSettle();
final started = eventsOf('health_record_create_started');
expect(started, hasLength(1));
expect(started.single, {
'recordType': 'vaccine',
'entryPoint': 'record_list',
});
});
testWidgets('状态机拦截 · 未填接种日期就标完成:前端拦截不发请求', (tester) async {
var createCalls = 0;
repository.createVaccinationHandler = (petId, request) async {
createCalls++;
return buildVaccination('vx-x');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.tap(find.text('已完成'));
await tester.pumpAndSettle();
// completed 态不渲染计划日期字段(结构上杜绝 scheduled 带 administeredOn 的反向路径)。
expect(find.text('计划接种日期'), findsNothing);
expect(find.text('接种日期'), findsOneWidget);
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('请选择接种日期'), findsOneWidget);
expect(createCalls, 0);
});
testWidgets('scheduled 登记成功:请求形状精确对齐契约 + succeeded', (tester) async {
CreateVaccinationRequest? captured;
repository.createVaccinationHandler = (petId, request) async {
expect(petId, 'p-1');
captured = request;
return buildVaccination('vx-new');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.enterText(
find.widgetWithText(TextFormField, '剂次标签(可选)'),
'第一针',
);
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['vaccineId'], 'v-1');
expect(json['seriesKey'], 'rabies');
expect(json['doseNo'], 1);
expect(json['status'], 'scheduled');
expect(json['doseLabel'], '第一针');
expect(json.containsKey('plannedOn'), isTrue);
// scheduled 不得携带 administeredOn(契约 42201 规则,结构化保证)。
expect(json.containsKey('administeredOn'), isFalse);
expect(json.containsKey('nextDueOn'), isFalse);
expect(find.text('列表基底'), findsOneWidget);
final succeeded = eventsOf('health_record_create_succeeded');
expect(succeeded.single!['recordType'], 'vaccine');
expect(succeeded.single!['photoCount'], 0);
});
testWidgets('completed 登记成功:administeredOn 必填已给、无计划日期字段', (tester) async {
CreateVaccinationRequest? captured;
repository.createVaccinationHandler = (petId, request) async {
captured = request;
return buildVaccination('vx-new');
};
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.tap(find.text('已完成'));
await tester.pumpAndSettle();
await pickDate(tester, '接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['status'], 'completed');
expect(json.containsKey('administeredOn'), isTrue);
expect(json.containsKey('plannedOn'), isFalse);
});
testWidgets('40904 同系列同剂次冲突:兜底横幅 + failed 带码', (tester) async {
repository.createVaccinationHandler = (petId, request) async =>
throw const VaccinationDoseExistsException(message: '已存在');
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记'), findsOneWidget);
expect(find.byType(VaccinationFormPage), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['errorCode'], 40904);
expect(failed.single!['httpStatus'], 409);
});
testWidgets('42201 状态-日期规则后端兜底:横幅提示 + failed 带码', (tester) async {
repository.createVaccinationHandler = (petId, request) async =>
throw const VaccinationRuleException(
message: 'scheduled 必须提供 plannedOn',
);
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('接种状态与日期不符合规则,请核对后重试'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['errorCode'], 42201);
expect(failed.single!['httpStatus'], 422);
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('剂次非法(0)拦截', (tester) async {
await pumpForm(tester);
await selectVaccine(tester, '狂犬疫苗');
await tester.enterText(find.widgetWithText(TextFormField, '剂次'), '0');
await pickDate(tester, '计划接种日期');
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.text('剂次需为 1~32767 的整数'), findsOneWidget);
});
}
@@ -0,0 +1,188 @@
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/empty_state_illustration.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
import 'package:patbond_flutter/widgets/common.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late FakePetsRepository repository;
late List<(String, Map<String, dynamic>?)> events;
late HealthRecordAnalytics analytics;
setUp(() {
repository = FakePetsRepository();
events = [];
analytics = HealthRecordAnalytics(
(name, [props]) async => events.add((name, props)),
);
});
List<Map<String, dynamic>?> eventsOf(String name) => [
for (final e in events)
if (e.$1 == name) e.$2,
];
Future<void> pumpPage(WidgetTester tester, {bool canWrite = true}) async {
tester.view.physicalSize = const Size(700, 1600);
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: (_) => VaccinationRecordsPage(
repository: repository,
petId: 'p-1',
petSpecies: PetSpecies.dog,
canWrite: canWrite,
analytics: analytics,
),
),
),
);
await tester.pump();
}
testWidgets('四态 · ready:按系列分组,剂次/日期/状态标签齐备;viewed 一次', (tester) async {
repository.listVaccinationsHandler = (petId) async => [
buildVaccination(
'vx-1',
overrides: {
'status': 'completed',
'plannedOn': null,
'administeredOn': '2026-06-12',
'nextDueOn': '2027-06-12',
'doseLabel': '首免第一针',
},
),
buildVaccination('vx-2', overrides: {'doseNo': 2}),
buildVaccination(
'vx-3',
overrides: {
'seriesKey': 'dhppi-initial',
'vaccineName': '四联疫苗',
'vaccineId': 'v-2',
'status': 'cancelled',
'plannedOn': null,
},
),
];
await pumpPage(tester);
await tester.pumpAndSettle();
// 系列组头(疫苗名 · 系列键)。
expect(find.text('狂犬疫苗 · rabies-initial'), findsOneWidget);
expect(find.text('四联疫苗 · dhppi-initial'), findsOneWidget);
// 条目:doseLabel 优先 / 回落第 N 针;状态三色 TagPill。
expect(find.text('首免第一针'), findsOneWidget);
expect(find.text('第 2 针'), findsOneWidget);
expect(find.text('接种 2026-06-12 · 下次 2027-06-12'), findsOneWidget);
expect(find.text('计划 2026-10-01'), findsOneWidget);
expect(find.text('已完成'), findsOneWidget);
expect(find.text('计划中'), findsOneWidget);
expect(find.widgetWithText(TagPill, '已取消'), findsOneWidget);
final viewed = eventsOf('health_record_viewed');
expect(viewed.single, {'recordType': 'vaccine', 'source': 'pet_detail'});
});
testWidgets('四态 · loading / empty:空态插画 + 登记 CTA', (tester) async {
final completer = Completer<List<Vaccination>>();
repository.listVaccinationsHandler = (petId) => completer.future;
await pumpPage(tester);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(const []);
await tester.pumpAndSettle();
expect(find.byType(EmptyStateIllustration), findsOneWidget);
expect(find.text('还没有疫苗记录'), findsOneWidget);
expect(find.text('登记第一针'), findsOneWidget);
});
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
var calls = 0;
repository.listVaccinationsHandler = (petId) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return [buildVaccination('vx-1')];
};
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('第 1 针'), findsOneWidget);
});
testWidgets('登记闭环:CTA → 表单(record_form 路由名)→ 成功后重拉列表', (tester) async {
var listCalls = 0;
repository.listVaccinationsHandler = (petId) async {
listCalls++;
return listCalls == 1 ? const [] : [buildVaccination('vx-new')];
};
repository.createVaccinationHandler = (petId, request) async =>
buildVaccination('vx-new');
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('登记第一针'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationFormPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(VaccinationFormPage)),
)!;
expect(route.settings.name, 'record_form');
// 选疫苗(目录默认桩:狂犬疫苗)→ 选计划日期 → 提交。
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('狂犬疫苗').last);
await tester.pumpAndSettle();
await tester.tap(find.text('计划接种日期'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('保存登记'));
await tester.pumpAndSettle();
expect(find.byType(VaccinationFormPage), findsNothing);
expect(find.text('已登记疫苗'), findsOneWidget);
expect(listCalls, 2);
expect(find.text('第 1 针'), findsOneWidget);
});
testWidgets('viewercanWrite=false):无登记入口、空态无 CTA', (tester) async {
repository.listVaccinationsHandler = (petId) async => const [];
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add), findsNothing);
expect(find.text('登记第一针'), findsNothing);
});
}