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?)> events; late HealthRecordAnalytics analytics; setUp(() { repository = FakePetsRepository(); events = []; analytics = HealthRecordAnalytics( (name, [props]) async => events.add((name, props)), ); }); List?> eventsOf(String name) => [ for (final e in events) if (e.$1 == name) e.$2, ]; Future 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( builder: (_) => HealthEventFormPage( repository: repository, petId: 'p-1', analytics: analytics, ), ), ), child: const Text('打开表单'), ), ), ), ), ), ); await tester.tap(find.text('打开表单')); await tester.pumpAndSettle(); } Future 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()); }); 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( find.byType(DatePickerDialog), ); expect(dialog.firstDate, DateTime(1990)); expect(dialog.lastDate, dateOnly(DateTime.now())); }); testWidgets('提交中禁用「今天」快捷键', (tester) async { final gate = Completer(); repository.createHealthEventHandler = (petId, req) => gate.future; await pumpForm(tester); await fillValid(tester); await tester.tap(find.text('保存记录')); await tester.pump(); expect( tester .widget(find.widgetWithText(TextButton, '今天')) .onPressed, isNull, ); gate.complete(buildHealthEvent('he-1')); await tester.pumpAndSettle(); }); }); }