新增:健康事件时间线接入真实数据(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,288 @@
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_event_edit_page.dart';
import 'package:patbond_flutter/features/pets/health_event_form_page.dart';
import 'package:patbond_flutter/features/pets/health_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
import 'package:patbond_flutter/features/pets/pet_models.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,
int? pageSize,
}) 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: (_) => HealthEventsPage(
repository: repository,
petId: 'p-1',
canWrite: canWrite,
analytics: analytics,
pageSize: pageSize,
),
),
),
);
await tester.pump();
}
CursorPage<HealthEvent> page(
List<HealthEvent> items, {
String? next,
bool hasMore = false,
}) => CursorPage(items: items, nextCursor: next, hasMore: hasMore);
testWidgets('四态 · ready:六类条目、月分组组头、金额元展示、类型标签;viewed 一次', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async => page([
buildHealthEvent(
'e-1',
overrides: {'occurredAt': '2026-09-05T14:00:00+08:00'},
),
buildHealthEvent(
'e-2',
overrides: {
'eventType': 'deworming',
'title': '体内驱虫',
'occurredAt': '2026-09-01T10:00:00+08:00',
'amountCents': null,
'notes': '博来恩',
},
),
buildHealthEvent(
'e-3',
overrides: {
'eventType': 'note',
'title': '换粮观察',
'occurredAt': '2026-08-20T10:00:00+08:00',
'amountCents': null,
},
),
]);
await pumpPage(tester);
await tester.pumpAndSettle();
// 月分组:9 月两条 + 8 月一条各一枚组头。
expect(find.text('2026 年 9 月'), findsOneWidget);
expect(find.text('2026 年 8 月'), findsOneWidget);
// 条目标题与副行(日期 · 备注)。
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.textContaining('· 博来恩'), findsOneWidget);
// 类型 TagPill(图标 + 文字双通道)。
expect(find.widgetWithText(TagPill, '就医'), findsOneWidget);
expect(find.widgetWithText(TagPill, '驱虫'), findsOneWidget);
expect(find.widgetWithText(TagPill, '随手记'), findsOneWidget);
// 金额:12850 分 → 元展示;无金额条目不渲染金额。
expect(find.text('¥128.50'), findsOneWidget);
final viewed = eventsOf('health_record_viewed');
expect(viewed.single, {
'recordType': 'health_event',
'source': 'pet_detail',
});
});
testWidgets('四态 · loading / empty:空态插画 + 录入 CTA', (tester) async {
final completer = Completer<CursorPage<HealthEvent>>();
repository.listHealthEventsHandler = (petId, limit, cursor) =>
completer.future;
await pumpPage(tester);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
completer.complete(page(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.listHealthEventsHandler = (petId, limit, cursor) async {
calls++;
if (calls == 1) throw const ApiNetworkException('断网');
return page([buildHealthEvent('e-1')]);
};
await pumpPage(tester);
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
});
testWidgets('cursor 分页:透传游标追加不重不漏,末页收起按钮;翻页失败保留重试', (tester) async {
final captured = <(int?, String?)>[];
var moreFails = true;
repository.listHealthEventsHandler = (petId, limit, cursor) async {
captured.add((limit, cursor));
if (cursor == null) {
return page([buildHealthEvent('e-1')], next: 'CUR-1', hasMore: true);
}
if (moreFails) {
moreFails = false;
throw const ApiNetworkException('断网');
}
return page([
buildHealthEvent(
'e-2',
overrides: {
'title': '洗澡美容',
'eventType': 'grooming',
'occurredAt': '2026-09-01T10:00:00+08:00',
'amountCents': null,
},
),
]);
};
await pumpPage(tester, pageSize: 1);
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('加载更多'), findsOneWidget);
// 第一次翻页失败:SnackBar + 按钮保留。
await tester.tap(find.text('加载更多'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
expect(find.text('加载更多'), findsOneWidget);
// 重试成功:追加且不重复,末页收起按钮。
await tester.tap(find.text('加载更多'));
await tester.pumpAndSettle();
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('洗澡美容'), findsOneWidget);
expect(find.text('加载更多'), findsNothing);
expect(captured, [(1, null), (1, 'CUR-1'), (1, 'CUR-1')]);
});
testWidgets('录入闭环:CTA → 表单(record_form 路由名)→ 成功后重拉首页', (tester) async {
var listCalls = 0;
repository.listHealthEventsHandler = (petId, limit, cursor) async {
listCalls++;
return listCalls == 1
? page(const [])
: page([
buildHealthEvent('e-new', overrides: {'title': '首次体检'}),
]);
};
repository.createHealthEventHandler = (petId, request) async =>
buildHealthEvent('e-new', overrides: {'title': '首次体检'});
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('记录第一条'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventFormPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(HealthEventFormPage)),
)!;
expect(route.settings.name, 'record_form');
await tester.tap(find.text('就医'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, '标题(如:皮肤检查)'),
'首次体检',
);
await tester.tap(find.text('保存记录'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventFormPage), findsNothing);
expect(find.text('已记录健康事件'), findsOneWidget);
// 排序/月分组以服务端为准:成功后重拉首页。
expect(listCalls, 2);
expect(find.text('首次体检'), findsOneWidget);
});
testWidgets('编辑闭环:点条目 → 编辑页(无路由名)→ 成功后就地替换', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
page([buildHealthEvent('e-1')]);
repository.updateHealthEventHandler = (eventId, request) async =>
buildHealthEvent('e-1', overrides: {'title': '皮肤复查', 'version': 2});
await pumpPage(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('皮肤检查'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsOneWidget);
final route = ModalRoute.of(
tester.element(find.byType(HealthEventEditPage)),
)!;
// 编辑页不带路由名:record_form 专属创建漏斗到达段。
expect(route.settings.name, isNull);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsNothing);
expect(find.text('已保存修改'), findsOneWidget);
expect(find.text('皮肤复查'), findsOneWidget);
expect(find.text('皮肤检查'), findsNothing);
});
testWidgets('viewercanWrite=false):无录入入口、空态无 CTA、点条目不进编辑', (tester) async {
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
page([buildHealthEvent('e-1')]);
await pumpPage(tester, canWrite: false);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add), findsNothing);
await tester.tap(find.text('皮肤检查'));
await tester.pumpAndSettle();
expect(find.byType(HealthEventEditPage), findsNothing);
});
}