294fc4a781
用户实测已因日期选择器误录: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>
278 lines
9.6 KiB
Dart
278 lines
9.6 KiB
Dart
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();
|
||
});
|
||
});
|
||
}
|