新增:健康事件时间线接入真实数据(T2-14 时间线半边)

- 时间线页:occurred_at DESC cursor 分页 + 月分组组头,四态齐备;
  六类事件经 RecordTypeDot 映射(增补喂养/洗护/测量三型,色族复用
  已审计色对),类型 TagPill 双通道
- 事件录入表单:六类类型选择器、金额以元录入/整数分传输(money.dart
  换算)、UTC 时间戳约定与体重表单一致
- 事件编辑页:顶层 PATCH 差量提交(title/notes/amountCents + version),
  40902 照 T2-12 模式自动经时间线检索取新 version 重提(保留输入)
- 档案页:月度花费卡接 summary.monthlyExpense(分→元展示)、tz 透传
  设备时区固定偏移(T2-13 遗留③)、健康时间线导航入口
- 埋点:health_record 域 create 三事件 + viewed(recordType=health_event)
  挂通;新增 edit_succeeded/failed 封装(failureReason 含 conflict)
- 测试 224 → 250 全绿(+26);analyze 0 问题;format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:49:48 +08:00
parent c91f18a845
commit e186ba3da9
15 changed files with 2274 additions and 23 deletions
@@ -0,0 +1,195 @@
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_event_form_page.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 '../../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, 1700);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<HealthEvent>(
builder: (_) => HealthEventFormPage(
repository: repository,
petId: 'p-1',
analytics: analytics,
),
),
),
child: const Text('打开表单'),
),
),
),
),
),
);
await tester.tap(find.text('打开表单'));
await tester.pumpAndSettle();
}
Future<void> fillValid(WidgetTester tester, {String? amount}) async {
await tester.tap(find.text('就医'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
'皮肤检查',
);
if (amount != null) {
await tester.enterText(
find.widgetWithText(TextFormField, '金额(元,可选)'),
amount,
);
}
await tester.pump();
}
testWidgets('本地校验:类型/标题缺失与金额非法拦截,不发请求且报 validation_error', (tester) async {
var called = false;
repository.createHealthEventHandler = (petId, request) async {
called = true;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await tester.enterText(
find.widgetWithText(TextFormField, '金额(元,可选)'),
'12.345',
);
await tester.tap(find.text('保存记录'));
await tester.pump();
expect(find.text('请选择事件类型'), findsOneWidget);
expect(find.text('请输入标题'), findsOneWidget);
expect(find.text('金额格式不正确,最多两位小数'), findsOneWidget);
expect(called, isFalse);
final failed = eventsOf('health_record_create_failed');
expect(failed.single!['recordType'], 'health_event');
expect(failed.single!['failureReason'], 'validation_error');
});
testWidgets('成功请求形状:六类类型、UTC 时间戳、金额元→整数分、无金额时键缺席', (tester) async {
CreateHealthEventRequest? captured;
repository.createHealthEventHandler = (petId, request) async {
captured = request;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await fillValid(tester, amount: '128.50');
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
final json = captured!.toJson();
expect(json['eventType'], 'medical');
expect(json['title'], '皮肤检查');
// 金额以元录入 → 整数分传输(工单硬项)。
expect(json['amountCents'], 12850);
// 今日默认此刻,转 UTC 带 Z 上送。
expect(json['occurredAt'], endsWith('Z'));
expect(json.containsKey('notes'), isFalse);
final succeeded = eventsOf('health_record_create_succeeded');
expect(succeeded.single!['recordType'], 'health_event');
expect(succeeded.single!['durationMs'], isA<int>());
});
testWidgets('无金额提交:amountCents 键整体缺席(非 0、非 null)', (tester) async {
CreateHealthEventRequest? captured;
repository.createHealthEventHandler = (petId, request) async {
captured = request;
return buildHealthEvent('e-1');
};
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(captured!.toJson().containsKey('amountCents'), isFalse);
});
testWidgets('started 去重:首次输入一次,后续输入不再上报', (tester) async {
await pumpForm(tester);
await fillValid(tester);
await tester.enterText(
find.widgetWithText(TextFormField, '备注(可选)'),
'换季护理',
);
await tester.pump();
final started = eventsOf('health_record_create_started');
expect(started.single, {
'recordType': 'health_event',
'entryPoint': 'record_list',
});
});
testWidgets('40300 横幅 / 40000 横幅 / 网络 SnackBar 三兜底 + 失败事件', (tester) async {
var attempt = 0;
repository.createHealthEventHandler = (petId, request) async {
attempt++;
switch (attempt) {
case 1:
throw const PetAccessDeniedException(message: '无权限');
case 2:
throw const ApiBusinessException(code: 40000, message: '参数错误');
default:
throw const ApiNetworkException('断网');
}
};
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('你没有权限为该宠物添加记录'), findsOneWidget);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('请检查填写内容后重试'), findsOneWidget);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
final failed = eventsOf('health_record_create_failed');
expect(failed.length, 3);
expect(failed[0]!['failureReason'], 'permission_denied');
expect(failed[0]!['errorCode'], 40300);
expect(failed[1]!['failureReason'], 'validation_error');
expect(failed[1]!['errorCode'], 40000);
expect(failed[2]!['failureReason'], 'network_error');
expect(failed[2]!['attemptSeq'], 3);
});
}