e186ba3da9
- 时间线页: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>
289 lines
9.9 KiB
Dart
289 lines
9.9 KiB
Dart
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);
|
||
});
|
||
}
|