- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标 双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、 支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉 - 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案, 最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行 - 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」 PATCH 流转;完成对话框补录厂商/批号(契约可选字段); 42201/40902/40402 兜底 - 埋点:create 三事件 + viewed(recordType=reminder)挂通; edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict); 提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋 - 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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/care_reminder_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, 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<CareReminder>(
|
||||
builder: (_) => CareReminderFormPage(
|
||||
repository: repository,
|
||||
petId: 'p-1',
|
||||
analytics: analytics,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('打开表单'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('打开表单'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> fillValid(WidgetTester tester) async {
|
||||
await tester.tap(find.text('用药'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||||
'心丝虫预防药',
|
||||
);
|
||||
await tester.tap(find.text('到期日期'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
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': 'reminder',
|
||||
'entryPoint': 'record_list',
|
||||
});
|
||||
// 四类类型齐备。
|
||||
for (final label in ['驱虫', '体检', '用药', '其他']) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('40300 横幅 / 网络 SnackBar 兜底 + 失败事件', (tester) async {
|
||||
var attempt = 0;
|
||||
repository.createCareReminderHandler = (petId, request) async {
|
||||
attempt++;
|
||||
if (attempt == 1) {
|
||||
throw const PetAccessDeniedException(message: '无权限');
|
||||
}
|
||||
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);
|
||||
|
||||
final failed = eventsOf('health_record_create_failed');
|
||||
expect(failed.length, 2);
|
||||
expect(failed[0], {
|
||||
'recordType': 'reminder',
|
||||
'failureReason': 'permission_denied',
|
||||
'attemptSeq': 1,
|
||||
'errorCode': 40300,
|
||||
'httpStatus': 403,
|
||||
});
|
||||
expect(failed[1]!['failureReason'], 'network_error');
|
||||
expect(failed[1]!['attemptSeq'], 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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/care_reminder_form_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/care_reminders_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 '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}) 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: (_) => CareRemindersPage(
|
||||
repository: repository,
|
||||
petId: 'p-1',
|
||||
canWrite: canWrite,
|
||||
analytics: analytics,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 相对当前时刻的待办样本(逾期判定依赖真实 now)。
|
||||
CareReminder pendingIn(
|
||||
String id,
|
||||
Duration offset, {
|
||||
Map<String, Object?> overrides = const {},
|
||||
}) => buildReminder(
|
||||
id,
|
||||
overrides: {
|
||||
'dueAt': DateTime.now().add(offset).toUtc().toIso8601String(),
|
||||
...overrides,
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('四态 · ready:类型/时间/状态齐备,逾期待办红标;viewed 一次', (tester) async {
|
||||
repository.listCareRemindersHandler = (petId, status) async => [
|
||||
pendingIn(
|
||||
'r-1',
|
||||
const Duration(days: -3),
|
||||
overrides: {'title': '体内外驱虫', 'reminderType': 'deworming'},
|
||||
),
|
||||
pendingIn('r-2', const Duration(days: 5)),
|
||||
buildReminder(
|
||||
'r-3',
|
||||
overrides: {
|
||||
'title': '疫苗加强针',
|
||||
'reminderType': 'medication',
|
||||
'status': 'completed',
|
||||
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('体内外驱虫'), findsOneWidget);
|
||||
expect(find.text('年度体检'), findsOneWidget);
|
||||
expect(find.text('疫苗加强针'), findsOneWidget);
|
||||
// 逾期视觉标识(工单硬项):过期待办标「已逾期」,未到期标「待办」。
|
||||
expect(find.widgetWithText(TagPill, '已逾期'), findsOneWidget);
|
||||
expect(find.widgetWithText(TagPill, '待办'), findsOneWidget);
|
||||
expect(find.widgetWithText(TagPill, '已完成'), findsOneWidget);
|
||||
// 待办行有完成/忽略动作,终态行没有。
|
||||
expect(find.text('标记完成'), findsNWidgets(2));
|
||||
expect(find.text('忽略'), findsNWidgets(2));
|
||||
|
||||
final viewed = eventsOf('health_record_viewed');
|
||||
expect(viewed.single, {'recordType': 'reminder', 'source': 'pet_detail'});
|
||||
});
|
||||
|
||||
testWidgets('四态 · loading / empty:空态插画 + CTA;过滤空态无 CTA', (tester) async {
|
||||
final completer = Completer<List<CareReminder>>();
|
||||
final captured = <CareReminderStatus?>[];
|
||||
repository.listCareRemindersHandler = (petId, status) {
|
||||
captured.add(status);
|
||||
if (captured.length == 1) return completer.future;
|
||||
return Future.value(const []);
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.complete(const []);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EmptyStateIllustration), findsOneWidget);
|
||||
expect(find.text('还没有照护提醒'), findsOneWidget);
|
||||
expect(find.text('添加第一条'), findsOneWidget);
|
||||
|
||||
// 切「已完成」过滤:status 参数透传服务端;过滤空态不给 CTA。
|
||||
await tester.tap(find.text('已完成'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(captured, [null, CareReminderStatus.completed]);
|
||||
expect(find.text('暂无「已完成」提醒'), findsOneWidget);
|
||||
expect(find.text('添加第一条'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
|
||||
var calls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
calls++;
|
||||
if (calls == 1) throw const ApiNetworkException('断网');
|
||||
return [pendingIn('r-1', const Duration(days: 5))];
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('重试'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('年度体检'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('创建闭环:CTA → 表单(record_form 路由名)→ 请求形状与事件 → 成功重拉', (tester) async {
|
||||
var listCalls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
listCalls++;
|
||||
return listCalls == 1
|
||||
? const []
|
||||
: [
|
||||
pendingIn(
|
||||
'r-new',
|
||||
const Duration(days: 30),
|
||||
overrides: {'title': '体内外驱虫'},
|
||||
),
|
||||
];
|
||||
};
|
||||
CreateCareReminderRequest? captured;
|
||||
repository.createCareReminderHandler = (petId, request) async {
|
||||
captured = request;
|
||||
return pendingIn(
|
||||
'r-new',
|
||||
const Duration(days: 30),
|
||||
overrides: {'title': '体内外驱虫'},
|
||||
);
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('添加第一条'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CareReminderFormPage), findsOneWidget);
|
||||
final route = ModalRoute.of(
|
||||
tester.element(find.byType(CareReminderFormPage)),
|
||||
)!;
|
||||
expect(route.settings.name, 'record_form');
|
||||
|
||||
// 类型必选 + 标题必填 + 到期日期必选:先空提交拦截。
|
||||
await tester.tap(find.text('保存提醒'));
|
||||
await tester.pump();
|
||||
expect(find.text('请选择提醒类型'), findsOneWidget);
|
||||
expect(find.text('请输入提醒内容'), findsOneWidget);
|
||||
expect(find.text('请选择到期日期'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('驱虫'));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||||
'体内外驱虫',
|
||||
);
|
||||
await tester.tap(find.text('到期日期'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('保存提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final json = captured!.toJson();
|
||||
expect(json['reminderType'], 'deworming');
|
||||
expect(json['title'], '体内外驱虫');
|
||||
expect(json['dueAt'], endsWith('Z'));
|
||||
expect(json.containsKey('status'), isFalse);
|
||||
|
||||
expect(find.byType(CareReminderFormPage), findsNothing);
|
||||
expect(find.text('已添加提醒'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
|
||||
// 创建漏斗事件(recordType=reminder)。
|
||||
final started = eventsOf('health_record_create_started');
|
||||
expect(started.single, {
|
||||
'recordType': 'reminder',
|
||||
'entryPoint': 'record_list',
|
||||
});
|
||||
final failed = eventsOf('health_record_create_failed');
|
||||
expect(failed.single!['failureReason'], 'validation_error');
|
||||
final succeeded = eventsOf('health_record_create_succeeded');
|
||||
expect(succeeded.single!['recordType'], 'reminder');
|
||||
});
|
||||
|
||||
testWidgets('标记完成:completedAt 必带(UTC);忽略:禁带 completedAt;成功重拉', (
|
||||
tester,
|
||||
) async {
|
||||
var listCalls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
listCalls++;
|
||||
return [
|
||||
pendingIn('r-1', const Duration(days: 5)),
|
||||
pendingIn(
|
||||
'r-2',
|
||||
const Duration(days: 9),
|
||||
overrides: {'title': '体内外驱虫'},
|
||||
),
|
||||
];
|
||||
};
|
||||
final captured = <(String, Map<String, Object?>)>[];
|
||||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||
captured.add((reminderId, request.toJson()));
|
||||
return buildReminder(
|
||||
reminderId,
|
||||
overrides: {
|
||||
'status': request.status.name,
|
||||
'completedAt': request.completedAt?.toIso8601String(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 完成 r-1:对话框(默认今天)确认 → PATCH completed + completedAt。
|
||||
await tester.tap(find.text('标记完成').first);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured.length, 1);
|
||||
expect(captured[0].$1, 'r-1');
|
||||
expect(captured[0].$2['status'], 'completed');
|
||||
expect(captured[0].$2['completedAt'], endsWith('Z'));
|
||||
expect(find.text('已标记完成'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
|
||||
// 忽略 r-2:确认对话框 → PATCH dismissed,completedAt 键缺席。
|
||||
await tester.tap(find.text('忽略').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('忽略这条提醒?'), findsOneWidget);
|
||||
// 对话框主按钮文案与动作同名,用 FilledButton 定位。
|
||||
await tester.tap(find.widgetWithText(FilledButton, '忽略'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured.length, 2);
|
||||
expect(captured[1].$2, {'status': 'dismissed'});
|
||||
expect(listCalls, 3);
|
||||
// 提醒完成/忽略不埋事件(06 §7 缺口 3 既定取舍)。
|
||||
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('42202 规则兜底:提示 + 重拉', (tester) async {
|
||||
var listCalls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
listCalls++;
|
||||
return [pendingIn('r-1', const Duration(days: 5))];
|
||||
};
|
||||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||
throw const CareReminderRuleException(message: '规则违反');
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('标记完成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('提醒状态不满足流转规则,已刷新,请重试'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('40902 并发流转抢先:提示已被处理 + 重拉', (tester) async {
|
||||
var listCalls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
listCalls++;
|
||||
return [pendingIn('r-1', const Duration(days: 5))];
|
||||
};
|
||||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||||
throw const PetVersionConflictException(message: '数据已被修改');
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('标记完成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('提醒已在其他设备被处理,已刷新'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('viewer(canWrite=false):无添加入口、无完成/忽略动作、空态无 CTA', (tester) async {
|
||||
repository.listCareRemindersHandler = (petId, status) async => [
|
||||
pendingIn('r-1', const Duration(days: 5)),
|
||||
];
|
||||
|
||||
await pumpPage(tester, canWrite: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.add), findsNothing);
|
||||
expect(find.text('标记完成'), findsNothing);
|
||||
expect(find.text('忽略'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -201,4 +201,80 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('T2-14 · 照护提醒展示纯函数', () {
|
||||
test('四类提醒文案与视觉映射(体检/用药归就医族)', () {
|
||||
expect(
|
||||
[for (final t in CareReminderType.values) careReminderTypeLabel(t)],
|
||||
['驱虫', '体检', '用药', '其他'],
|
||||
);
|
||||
expect(
|
||||
recordTypeForReminder(CareReminderType.deworming),
|
||||
RecordType.deworming,
|
||||
);
|
||||
expect(
|
||||
recordTypeForReminder(CareReminderType.checkup),
|
||||
RecordType.medical,
|
||||
);
|
||||
expect(
|
||||
recordTypeForReminder(CareReminderType.medication),
|
||||
RecordType.medical,
|
||||
);
|
||||
expect(recordTypeForReminder(CareReminderType.other), RecordType.other);
|
||||
});
|
||||
|
||||
test('逾期判定:仅待办且 dueAt 已过;标签与基色随之切换', () {
|
||||
final now = DateTime(2026, 9, 8, 12);
|
||||
final overduePending = buildReminder(
|
||||
'r-1',
|
||||
overrides: {'dueAt': '2026-09-01T00:00:00+08:00'},
|
||||
);
|
||||
final futurePending = buildReminder(
|
||||
'r-2',
|
||||
overrides: {'dueAt': '2026-10-01T00:00:00+08:00'},
|
||||
);
|
||||
// 已完成的过期提醒不算逾期(终态)。
|
||||
final completedPast = buildReminder(
|
||||
'r-3',
|
||||
overrides: {
|
||||
'dueAt': '2026-09-01T00:00:00+08:00',
|
||||
'status': 'completed',
|
||||
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||
},
|
||||
);
|
||||
|
||||
expect(isReminderOverdue(overduePending, now), isTrue);
|
||||
expect(isReminderOverdue(futurePending, now), isFalse);
|
||||
expect(isReminderOverdue(completedPast, now), isFalse);
|
||||
|
||||
expect(reminderStatusTag(overduePending, now), '已逾期');
|
||||
expect(reminderStatusColor(overduePending, now), AppColors.error);
|
||||
expect(reminderStatusTag(futurePending, now), '待办');
|
||||
expect(reminderStatusColor(futurePending, now), AppColors.accent);
|
||||
expect(reminderStatusTag(completedPast, now), '已完成');
|
||||
expect(reminderStatusColor(completedPast, now), AppColors.success);
|
||||
});
|
||||
|
||||
test('时间副行按状态给语义(到期 / 完成于 / 已忽略)', () {
|
||||
final now = DateTime(2026, 9, 8);
|
||||
final pending = buildReminder('r-1');
|
||||
final completed = buildReminder(
|
||||
'r-2',
|
||||
overrides: {
|
||||
'status': 'completed',
|
||||
'completedAt': '2026-09-02T10:00:00+08:00',
|
||||
},
|
||||
);
|
||||
final dismissed = buildReminder(
|
||||
'r-3',
|
||||
overrides: {'status': 'dismissed'},
|
||||
);
|
||||
|
||||
expect(reminderDateLine(pending), startsWith('到期 '));
|
||||
expect(reminderDateLine(completed), startsWith('完成于 '));
|
||||
expect(reminderDateLine(dismissed), startsWith('已忽略 · 原到期 '));
|
||||
expect(reminderStatusTag(dismissed, now), '已忽略');
|
||||
expect(reminderStatusColor(dismissed, now), AppColors.muted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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/care_reminders_page.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';
|
||||
@@ -348,4 +349,107 @@ void main() {
|
||||
expect(find.byIcon(Icons.add), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('T2-14 · 健康提醒真实数据驱动(取代 demo 硬编码)', () {
|
||||
testWidgets('有待办:alert 卡显示最近到期提醒;入口副行显示待办数;点卡进提醒页并回拉', (tester) async {
|
||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||
final due = DateTime.now().add(const Duration(days: 30));
|
||||
var reminderCalls = 0;
|
||||
final capturedStatus = <CareReminderStatus?>[];
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
reminderCalls++;
|
||||
capturedStatus.add(status);
|
||||
return [
|
||||
buildReminder(
|
||||
'r-1',
|
||||
overrides: {
|
||||
'title': '已经半年没有进行体内外驱虫',
|
||||
'reminderType': 'deworming',
|
||||
'dueAt': due.toUtc().toIso8601String(),
|
||||
},
|
||||
),
|
||||
buildReminder('r-2'),
|
||||
];
|
||||
};
|
||||
|
||||
await pumpDetail(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// alert 卡取 due_at ASC 首条(最近到期)真实数据。
|
||||
expect(
|
||||
find.text('健康提醒:已经半年没有进行体内外驱虫(${dateToJson(due)} 到期)'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('2 条待办'), findsOneWidget);
|
||||
// 详情页只拉待办视图。
|
||||
expect(capturedStatus.first, CareReminderStatus.pending);
|
||||
|
||||
final alert = find.textContaining('健康提醒:');
|
||||
await tester.ensureVisible(alert);
|
||||
await tester.tap(alert);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CareRemindersPage), findsOneWidget);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 返回后重拉待办(详情页 1 次 + 提醒页自身 1 次 + 返回重拉 1 次)。
|
||||
expect(reminderCalls, 3);
|
||||
});
|
||||
|
||||
testWidgets('逾期待办:alert 卡切警示形态', (tester) async {
|
||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||
repository.listCareRemindersHandler = (petId, status) async => [
|
||||
buildReminder(
|
||||
'r-1',
|
||||
overrides: {
|
||||
'dueAt': DateTime.now()
|
||||
.subtract(const Duration(days: 7))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await pumpDetail(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('健康提醒:年度体检(已逾期)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('无待办:不渲染 alert 卡(无 demo 占位),入口副行「暂无待办提醒」', (tester) async {
|
||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||
repository.listCareRemindersHandler = (petId, status) async => const [];
|
||||
|
||||
await pumpDetail(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('健康提醒:'), findsNothing);
|
||||
expect(find.text('暂无待办提醒'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('待办加载失败:主链路不受阻,入口副行降级提示且仍可进提醒页', (tester) async {
|
||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||
var calls = 0;
|
||||
repository.listCareRemindersHandler = (petId, status) async {
|
||||
calls++;
|
||||
if (calls == 1) throw const ApiNetworkException('断网');
|
||||
return const [];
|
||||
};
|
||||
|
||||
await pumpDetail(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('豆豆'), findsOneWidget);
|
||||
expect(find.text('提醒加载失败,点击查看'), findsOneWidget);
|
||||
|
||||
final tile = find.widgetWithText(ListTile, '照护提醒');
|
||||
await tester.ensureVisible(tile);
|
||||
await tester.tap(tile);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CareRemindersPage), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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_record_analytics.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||
import 'package:patbond_flutter/features/pets/vaccination_form_page.dart';
|
||||
import 'package:patbond_flutter/features/pets/vaccination_records_page.dart';
|
||||
@@ -185,4 +186,173 @@ void main() {
|
||||
expect(find.byIcon(Icons.add), findsNothing);
|
||||
expect(find.text('登记第一针'), findsNothing);
|
||||
});
|
||||
|
||||
group('T2-14 · 流转动作(25 号报告遗留①②)', () {
|
||||
testWidgets('标记完成:厂商/批号补录 + 请求形状 + edit_succeeded;动作仅 scheduled 行', (
|
||||
tester,
|
||||
) async {
|
||||
var listCalls = 0;
|
||||
repository.listVaccinationsHandler = (petId) async {
|
||||
listCalls++;
|
||||
return [
|
||||
buildVaccination('vx-1'),
|
||||
buildVaccination(
|
||||
'vx-2',
|
||||
overrides: {
|
||||
'doseNo': 2,
|
||||
'status': 'completed',
|
||||
'plannedOn': null,
|
||||
'administeredOn': '2026-06-12',
|
||||
},
|
||||
),
|
||||
];
|
||||
};
|
||||
final captured = <(String, Map<String, Object?>)>[];
|
||||
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||
captured.add((vaccinationId, request.toJson()));
|
||||
return buildVaccination(
|
||||
'vx-1',
|
||||
overrides: {'status': 'completed', 'version': 2},
|
||||
);
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 动作仅 scheduled 行渲染(completed/cancelled 终态无动作)。
|
||||
expect(find.text('标记完成'), findsOneWidget);
|
||||
expect(find.text('取消登记'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('标记完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 完成对话框:接种日期默认今天;补录厂商/批号(契约可选字段)。
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '厂商(可选)'),
|
||||
'硕腾',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '批号(可选)'),
|
||||
'LOT-2026-09',
|
||||
);
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured.length, 1);
|
||||
expect(captured[0].$1, 'vx-1');
|
||||
final json = captured[0].$2;
|
||||
expect(json['version'], 1);
|
||||
expect(json['status'], 'completed');
|
||||
expect(json['administeredOn'], isA<String>());
|
||||
expect(json['manufacturer'], '硕腾');
|
||||
expect(json['batchNo'], 'LOT-2026-09');
|
||||
// 未选下次接种:键缺席。
|
||||
expect(json.containsKey('nextDueOn'), isFalse);
|
||||
|
||||
expect(find.text('已标记完成'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
|
||||
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 4});
|
||||
});
|
||||
|
||||
testWidgets('取消登记:确认后仅发 version + status=cancelled', (tester) async {
|
||||
var listCalls = 0;
|
||||
repository.listVaccinationsHandler = (petId) async {
|
||||
listCalls++;
|
||||
return [buildVaccination('vx-1')];
|
||||
};
|
||||
final captured = <Map<String, Object?>>[];
|
||||
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||
captured.add(request.toJson());
|
||||
return buildVaccination(
|
||||
'vx-1',
|
||||
overrides: {'status': 'cancelled', 'version': 2},
|
||||
);
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('取消登记'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('取消这条登记?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, '取消登记'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(captured.single, {'version': 1, 'status': 'cancelled'});
|
||||
expect(find.text('已取消登记'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
|
||||
final succeeded = eventsOf('health_record_edit_succeeded');
|
||||
expect(succeeded.single, {'recordType': 'vaccine', 'fieldCount': 1});
|
||||
});
|
||||
|
||||
testWidgets('40902 并发修改:提示 + 重拉 + edit_failed(conflict)', (tester) async {
|
||||
var listCalls = 0;
|
||||
repository.listVaccinationsHandler = (petId) async {
|
||||
listCalls++;
|
||||
return [buildVaccination('vx-1')];
|
||||
};
|
||||
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||
throw const PetVersionConflictException(message: '数据已被修改');
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('标记完成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('记录已在其他设备被修改,已刷新,请重试'), findsOneWidget);
|
||||
expect(listCalls, 2);
|
||||
|
||||
final failed = eventsOf('health_record_edit_failed');
|
||||
expect(failed.single, {
|
||||
'recordType': 'vaccine',
|
||||
'failureReason': 'conflict',
|
||||
'errorCode': 40902,
|
||||
'httpStatus': 409,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('42201 规则兜底:提示核对重试 + edit_failed(validation_error)', (
|
||||
tester,
|
||||
) async {
|
||||
repository.listVaccinationsHandler = (petId) async => [
|
||||
buildVaccination('vx-1'),
|
||||
];
|
||||
repository.updateVaccinationHandler = (vaccinationId, request) async {
|
||||
throw const VaccinationRuleException(message: '规则违反');
|
||||
};
|
||||
|
||||
await pumpPage(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('标记完成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('确认完成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('接种状态与日期不符合规则,请核对后重试'), findsOneWidget);
|
||||
|
||||
final failed = eventsOf('health_record_edit_failed');
|
||||
expect(failed.single!['failureReason'], 'validation_error');
|
||||
expect(failed.single!['errorCode'], 42201);
|
||||
});
|
||||
|
||||
testWidgets('viewer:scheduled 行也无流转动作', (tester) async {
|
||||
repository.listVaccinationsHandler = (petId) async => [
|
||||
buildVaccination('vx-1'),
|
||||
];
|
||||
|
||||
await pumpPage(tester, canWrite: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('标记完成'), findsNothing);
|
||||
expect(find.text('取消登记'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user