Files
patbond-flutter/test/features/pets/health_event_form_page_test.dart
lixi 294fc4a781 修复:日期录入收口共享层——「今天」快捷键 + 品牌配色 datePickerTheme(M3.5-02)
用户实测已因日期选择器误录:Flutter 原生日历只给年份网格、月份必须靠 < >
逐月点,从 9 月回 4 月要点 5 次,他把当月(2026-09)的就医记录记成了
2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。

新增 lib/core/widgets/app_date_picker.dart,全仓 7 处裸 showDatePicker 收口
(改造后 lib/ 下 showDatePicker 只出现在该文件内部一次):

- pickAppDate(...):calendar 首屏 + 保留头部铅笔切手输;initialDate 自动夹进
  [firstDate, lastDate] 防原生越界断言(调用方常传「当前值 ?? 今天」,而
  「到期日期」的 firstDate 就是今天,历史值可能已越界);返回值统一抹时分秒;
  中文文案一律交给 zh-CN 本地化,不硬编码,避免两处文案漂移。
- AppDateFieldTrailing(...):7 处日期行统一「今天 + 日历图标」。今天越界自动
  隐藏按钮、提交中禁用、触控 44×44、primaryStrong 白底 4.49:1。

入口模式取舍:不用 calendarOnly——它恰好会砍掉手输按钮,把「录一个已知日期」
这条唯一快路堵死;也不用 input 首屏——「记今天」这类高频场景敲 8 个数字更慢。
两条路都留着最省事。

「今天」为何放表单行而非弹窗内:原生 showDatePicker 无法注入自定义动作
(builder 只能包裹整个 Dialog,拿不到内部选中态;塞进 Column 还会因 Dialog
在无界高度下贪心布局而溢出)。放表单行反而更快——一键落值连弹窗都不用开,
把容易走错的月份导航整段绕开,且一次实现 7 处形态完全一致。

各调用点原有的 firstDate/lastDate 业务约束原样传入、一字未改(健康事件
不许未来、到期日不许补记过去、疫苗 allowFuture 双态、生日不许未来),
并由 widget 测试直接断言 DatePickerDialog.firstDate/lastDate 防后续悄悄放宽。

顺带修配色:此前从未定制 datePickerTheme,选中日直接吃 ColorScheme.fromSeed
由珊瑚橙派生的暗红棕,与品牌脱节。新增 _datePickerTheme 只复用 05 号规范
(iteration-2/05、iteration-3/05)已审计的色对,不新造色值:头部
surfaceTint + primaryDark 7.98:1(选中 chip 同款)、选中日/年 primaryStrong
实底白字 4.49:1、今日 primaryStrong 1.5px 描边、星期表头 inkSoft 6.59:1、
越界日 muted(DEBT-2 允许的禁用态用途)。headerHeadlineStyle 取 22px
(默认 32):中文「9月10日周四」在横屏侧栏头部 26px 起就折行。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 17:59:52 +08:00

278 lines
9.6 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/app/app_localization.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/app_date_picker.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(),
// M3.5-01:与生产一致挂 zh-CN delegate,否则测里的日期选择器是
// 英文兜底,测不出用户真机看到的东西。
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: appSupportedLocales,
locale: appLocale,
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);
});
group('M3.5-02 · 日期录入(用户误录 2026-04 的那一格)', () {
testWidgets('点「今天」直接落今天,不开弹窗;同时算首次输入触发 started', (tester) async {
await pumpForm(tester);
final expected = dateToJson(DateTime.now());
// 先把日期改成远月,模拟用户翻错月份后的状态。
await tester.tap(find.widgetWithText(ListTile, '发生日期'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
await tester.enterText(
find.descendant(
of: find.byType(DatePickerDialog),
matching: find.byType(TextField),
),
'2026/04/09',
);
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(find.text('2026-04-09'), findsOneWidget);
// 一键回今天:不经日期选择器。
await tester.tap(find.text('今天'));
await tester.pumpAndSettle();
expect(find.text(expected), findsOneWidget);
expect(find.text('选择日期'), findsNothing);
expect(eventsOf('health_record_create_started'), hasLength(1));
});
testWidgets('选择器为中文,且业务约束不变(firstDate 1990 / lastDate 今天,不许未来)', (
tester,
) async {
await pumpForm(tester);
await tester.tap(find.widgetWithText(ListTile, '发生日期'));
await tester.pumpAndSettle();
expect(find.text('选择日期'), findsOneWidget);
expect(find.text('确定'), findsOneWidget);
expect(find.text('取消'), findsOneWidget);
// 手输快路可达(原生 calendarOnly 会砍掉它)。
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
final dialog = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(dialog.firstDate, DateTime(1990));
expect(dialog.lastDate, dateOnly(DateTime.now()));
});
testWidgets('提交中禁用「今天」快捷键', (tester) async {
final gate = Completer<HealthEvent>();
repository.createHealthEventHandler = (petId, req) => gate.future;
await pumpForm(tester);
await fillValid(tester);
await tester.tap(find.text('保存记录'));
await tester.pump();
expect(
tester
.widget<TextButton>(find.widgetWithText(TextButton, '今天'))
.onPressed,
isNull,
);
gate.complete(buildHealthEvent('he-1'));
await tester.pumpAndSettle();
});
});
}