新增:健康事件时间线接入真实数据(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:
@@ -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('viewer(canWrite=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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user