新增:健康事件时间线接入真实数据(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);
});
}