新增:健康事件时间线接入真实数据(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,207 @@
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_edit_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,
];
HealthEvent baseEvent({int version = 3}) =>
buildHealthEvent('e-1', overrides: {'notes': '医院复查', 'version': version});
Future<void> pumpEdit(WidgetTester tester, {HealthEvent? event}) async {
tester.view.physicalSize = const Size(700, 1600);
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: (_) => HealthEventEditPage(
repository: repository,
event: event ?? baseEvent(),
analytics: analytics,
),
),
),
child: const Text('打开编辑'),
),
),
),
),
),
);
await tester.tap(find.text('打开编辑'));
await tester.pumpAndSettle();
}
testWidgets('预填与身份静态区:类型/发生时间不可编辑,金额以元回显', (tester) async {
await pumpEdit(tester);
// 身份区:类型文案 + 发生时间(无输入控件)。
expect(find.text('就医'), findsOneWidget);
// 预填:标题 / 金额(12850 分 → 128.50 元)/ 备注。
expect(find.text('皮肤检查'), findsOneWidget);
expect(find.text('128.50'), findsOneWidget);
expect(find.text('医院复查'), findsOneWidget);
});
testWidgets('差量提交:只发送改动字段 + version;成功回传并报 edit_succeeded(fieldCount)', (
tester,
) async {
UpdateHealthEventRequest? captured;
repository.updateHealthEventHandler = (eventId, request) async {
captured = request;
return buildHealthEvent('e-1', overrides: {'version': 4});
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '99');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
// 备注未改不出现;金额 99 元 → 9900 分。
expect(captured!.toJson(), {
'version': 3,
'title': '皮肤复查',
'amountCents': 9900,
});
final succeeded = eventsOf('health_record_edit_succeeded');
expect(succeeded.single, {'recordType': 'health_event', 'fieldCount': 2});
expect(find.byType(HealthEventEditPage), findsNothing);
});
testWidgets('无变更不发 PATCH 直接返回;清空可选字段视为不变更(契约不支持清空回 null)', (tester) async {
var called = false;
repository.updateHealthEventHandler = (eventId, request) async {
called = true;
return baseEvent();
};
await pumpEdit(tester);
// 清空金额与备注:契约不支持清空回 null → 视为不变更。
await tester.enterText(find.widgetWithText(TextFormField, '金额(元)'), '');
await tester.enterText(find.widgetWithText(TextFormField, '备注'), '');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(called, isFalse);
expect(find.byType(HealthEventEditPage), findsNothing);
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
});
testWidgets('40902 冲突:明确提示 + 经时间线检索自动取新 version(保留输入),重提成功', (tester) async {
final submittedVersions = <int>[];
var conflictOnce = true;
repository.updateHealthEventHandler = (eventId, request) async {
submittedVersions.add(request.version);
if (conflictOnce) {
conflictOnce = false;
throw const PetVersionConflictException(message: '数据已被修改');
}
return buildHealthEvent('e-1', overrides: {'version': 8});
};
// 契约无按 id 读取端点:40902 后经时间线分页检索取回最新版本(version 7)。
final listCaptured = <String?>[];
repository.listHealthEventsHandler = (petId, limit, cursor) async {
listCaptured.add(cursor);
return CursorPage(
items: [buildHealthEvent('e-other'), baseEvent(version: 7)],
nextCursor: null,
hasMore: false,
);
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
// 明确提示 + 用户输入保留。
expect(find.text('记录已在其他设备被修改,已获取最新版本,请核对后重新保存'), findsOneWidget);
expect(find.text('皮肤复查'), findsOneWidget);
expect(listCaptured, [null]);
// conflict 失败事件(M2 验收「并发冲突明确」数据面)。
final failed = eventsOf('health_record_edit_failed');
expect(failed.single, {
'recordType': 'health_event',
'failureReason': 'conflict',
'errorCode': 40902,
'httpStatus': 409,
});
// 重提:自动用检索回的新 version。
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(submittedVersions, [3, 7]);
expect(find.byType(HealthEventEditPage), findsNothing);
});
testWidgets('40402 记录不存在:SnackBar + 返回;40300 横幅;网络 SnackBar', (tester) async {
var attempt = 0;
repository.updateHealthEventHandler = (eventId, request) async {
attempt++;
switch (attempt) {
case 1:
throw const PetAccessDeniedException(message: '无权限');
case 2:
throw const ApiNetworkException('断网');
default:
throw const PetRecordNotFoundException(message: '不存在');
}
};
await pumpEdit(tester);
await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查');
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.text('你没有权限修改该记录'), findsOneWidget);
await tester.tap(find.text('保存修改'));
await tester.pumpAndSettle();
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
// 经 SnackBar「重试」重提(顺带锁定重试动作接线)→ 40402。
await tester.tap(find.text('重试'));
await tester.pumpAndSettle();
expect(find.text('记录不存在或已被删除'), findsOneWidget);
expect(find.byType(HealthEventEditPage), findsNothing);
final failed = eventsOf('health_record_edit_failed');
expect(failed.length, 3);
expect(failed[0]!['failureReason'], 'permission_denied');
expect(failed[1]!['failureReason'], 'network_error');
expect(failed[2]!['failureReason'], 'not_found');
expect(failed[2]!['errorCode'], 40402);
});
}
@@ -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);
});
}
@@ -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);
});
}
@@ -82,4 +82,45 @@ void main() {
'source': 'pet_detail',
});
});
test('editSucceededrecordType + fieldCount(差量键数,不含 version', () {
analytics.editSucceeded(
recordType: HealthRecordType.healthEvent,
fieldCount: 2,
);
expect(events.single.$1, 'health_record_edit_succeeded');
expect(events.single.$2, {'recordType': 'health_event', 'fieldCount': 2});
});
test(
'editFailedconflict40902)带 errorCode 与 httpStatus 推导,无 attemptSeq',
() {
analytics.editFailed(
recordType: HealthRecordType.vaccine,
reason: HealthRecordFailureReason.conflict,
errorCode: 40902,
);
expect(events.single.$1, 'health_record_edit_failed');
expect(events.single.$2, {
'recordType': 'vaccine',
'failureReason': 'conflict',
'errorCode': 40902,
'httpStatus': 409,
});
},
);
test('editFailed:网络失败可空属性整体缺席', () {
analytics.editFailed(
recordType: HealthRecordType.healthEvent,
reason: HealthRecordFailureReason.networkError,
);
expect(events.single.$2, {
'recordType': 'health_event',
'failureReason': 'network_error',
});
});
}
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/core/widgets/record_type_dot.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
@@ -147,4 +148,57 @@ void main() {
);
});
});
group('T2-14 · 健康事件展示纯函数', () {
test('六类事件 → RecordType 映射齐备(note 归「其他」,其余五类专属)', () {
expect(
recordTypeForHealthEvent(HealthEventType.medical),
RecordType.medical,
);
expect(
recordTypeForHealthEvent(HealthEventType.feeding),
RecordType.feeding,
);
expect(
recordTypeForHealthEvent(HealthEventType.deworming),
RecordType.deworming,
);
expect(
recordTypeForHealthEvent(HealthEventType.grooming),
RecordType.grooming,
);
expect(
recordTypeForHealthEvent(HealthEventType.measurement),
RecordType.measurement,
);
expect(recordTypeForHealthEvent(HealthEventType.note), RecordType.other);
});
test('六类事件中文文案', () {
expect(
[for (final t in HealthEventType.values) healthEventTypeLabel(t)],
['就医', '喂养', '驱虫', '洗护', '测量', '随手记'],
);
});
test('月分组组头:按本地时区归月', () {
expect(healthEventMonthHeader(DateTime(2026, 9, 5, 14)), '2026 年 9 月');
expect(healthEventMonthHeader(DateTime(2025, 12, 31)), '2025 年 12 月');
});
});
group('T2-14 · tz 固定偏移(summary tz 参数)', () {
test('正/负/零/半小时偏移格式', () {
expect(tzOffsetQueryValue(const Duration(hours: 8)), '+08:00');
expect(
tzOffsetQueryValue(const Duration(hours: -5, minutes: -30)),
'-05:30',
);
expect(tzOffsetQueryValue(Duration.zero), '+00:00');
expect(
tzOffsetQueryValue(const Duration(hours: 5, minutes: 45)),
'+05:45',
);
});
});
}
+61 -5
View File
@@ -4,6 +4,8 @@ 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_events_page.dart';
import 'package:patbond_flutter/features/pets/health_record_display.dart';
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
@@ -24,8 +26,9 @@ void main() {
});
Future<void> pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async {
// 详情页自 T2-13 增加健康数据卡行,加高视口保证底部按钮在栏内。
tester.view.physicalSize = const Size(700, 1800);
// 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区,
// 加高视口保证底部按钮在栏内。
tester.view.physicalSize = const Size(700, 2200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
@@ -184,7 +187,7 @@ void main() {
});
group('T2-13 · 数据卡行接 summary', () {
testWidgets('卡取数:最新体重 / 疫苗进度 / 下一针(含疫苗名', (tester) async {
testWidgets('卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
String? capturedTz;
repository.getPetSummaryHandler = (petId, tz) async {
@@ -202,8 +205,12 @@ void main() {
expect(find.text('疫苗进度'), findsOneWidget);
expect(find.text('2026-08-01'), findsOneWidget);
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
// 本单不消费 monthlyExpenseT2-14),tz 不传走服务端缺省 UTC
expect(capturedTz, isNull);
// T2-14:月度花费卡接 monthlyExpense12850 分 → 元展示)
expect(find.text('¥128.50'), findsOneWidget);
expect(find.text('本月花费'), findsOneWidget);
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
});
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
@@ -292,4 +299,53 @@ void main() {
expect(find.byIcon(Icons.add), findsNothing);
});
});
group('T2-14 · 时间线入口', () {
testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
repository.getPetHandler = (petId) async => buildPet('p-1');
var summaryCalls = 0;
repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++;
return buildSummary();
};
repository.listHealthEventsHandler = (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(HealthEventsPage), 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.listHealthEventsHandler = (petId, limit, cursor) async =>
const CursorPage(items: [], nextCursor: null, hasMore: false);
await pumpDetail(tester);
await tester.pumpAndSettle();
final tile = find.widgetWithText(ListTile, '健康时间线');
expect(tile, findsOneWidget);
await tester.ensureVisible(tile);
await tester.tap(tile);
await tester.pumpAndSettle();
expect(find.byType(HealthEventsPage), findsOneWidget);
// viewer:空态无 CTA、AppBar 无添加入口。
expect(find.text('记录第一条'), findsNothing);
expect(find.byIcon(Icons.add), findsNothing);
});
});
}