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/health_event_edit_page.dart'; import 'package:patbond_flutter/features/pets/health_event_form_page.dart'; import 'package:patbond_flutter/features/pets/health_events_page.dart'; import 'package:patbond_flutter/features/pets/health_record_analytics.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?)> 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 pumpPage( WidgetTester tester, { bool canWrite = true, int? pageSize, }) 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(find.byType(Navigator)); unawaited( navigator.push( MaterialPageRoute( builder: (_) => HealthEventsPage( repository: repository, petId: 'p-1', canWrite: canWrite, analytics: analytics, pageSize: pageSize, ), ), ), ); await tester.pump(); } CursorPage page( List items, { String? next, bool hasMore = false, }) => CursorPage(items: items, nextCursor: next, hasMore: hasMore); testWidgets('四态 · ready:六类条目、月分组组头、金额元展示、类型标签;viewed 一次', (tester) async { repository.listHealthEventsHandler = (petId, limit, cursor) async => page([ buildHealthEvent( 'e-1', overrides: {'occurredAt': '2026-09-05T14:00:00+08:00'}, ), buildHealthEvent( 'e-2', overrides: { 'eventType': 'deworming', 'title': '体内驱虫', 'occurredAt': '2026-09-01T10:00:00+08:00', 'amountCents': null, 'notes': '博来恩', }, ), buildHealthEvent( 'e-3', overrides: { 'eventType': 'note', 'title': '换粮观察', 'occurredAt': '2026-08-20T10:00:00+08:00', 'amountCents': null, }, ), ]); await pumpPage(tester); await tester.pumpAndSettle(); // 月分组:9 月两条 + 8 月一条各一枚组头。 expect(find.text('2026 年 9 月'), findsOneWidget); expect(find.text('2026 年 8 月'), findsOneWidget); // 条目标题与副行(日期 · 备注)。 expect(find.text('皮肤检查'), findsOneWidget); expect(find.textContaining('· 博来恩'), findsOneWidget); // 类型 TagPill(图标 + 文字双通道)。 expect(find.widgetWithText(TagPill, '就医'), findsOneWidget); expect(find.widgetWithText(TagPill, '驱虫'), findsOneWidget); expect(find.widgetWithText(TagPill, '随手记'), findsOneWidget); // 金额:12850 分 → 元展示;无金额条目不渲染金额。 expect(find.text('¥128.50'), findsOneWidget); final viewed = eventsOf('health_record_viewed'); expect(viewed.single, { 'recordType': 'health_event', 'source': 'pet_detail', }); }); testWidgets('四态 · loading / empty:空态插画 + 录入 CTA', (tester) async { final completer = Completer>(); repository.listHealthEventsHandler = (petId, limit, cursor) => completer.future; await pumpPage(tester); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); completer.complete(page(const [])); await tester.pumpAndSettle(); expect(find.byType(EmptyStateIllustration), findsOneWidget); expect(find.text('还没有健康记录'), findsOneWidget); expect(find.text('记录第一条'), findsOneWidget); }); testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async { var calls = 0; repository.listHealthEventsHandler = (petId, limit, cursor) async { calls++; if (calls == 1) throw const ApiNetworkException('断网'); return page([buildHealthEvent('e-1')]); }; await pumpPage(tester); await tester.pumpAndSettle(); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.text('皮肤检查'), findsOneWidget); }); testWidgets('cursor 分页:透传游标追加不重不漏,末页收起按钮;翻页失败保留重试', (tester) async { final captured = <(int?, String?)>[]; var moreFails = true; repository.listHealthEventsHandler = (petId, limit, cursor) async { captured.add((limit, cursor)); if (cursor == null) { return page([buildHealthEvent('e-1')], next: 'CUR-1', hasMore: true); } if (moreFails) { moreFails = false; throw const ApiNetworkException('断网'); } return page([ buildHealthEvent( 'e-2', overrides: { 'title': '洗澡美容', 'eventType': 'grooming', 'occurredAt': '2026-09-01T10:00:00+08:00', 'amountCents': null, }, ), ]); }; await pumpPage(tester, pageSize: 1); await tester.pumpAndSettle(); expect(find.text('皮肤检查'), findsOneWidget); expect(find.text('加载更多'), findsOneWidget); // 第一次翻页失败:SnackBar + 按钮保留。 await tester.tap(find.text('加载更多')); await tester.pumpAndSettle(); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); expect(find.text('加载更多'), findsOneWidget); // 重试成功:追加且不重复,末页收起按钮。 await tester.tap(find.text('加载更多')); await tester.pumpAndSettle(); expect(find.text('皮肤检查'), findsOneWidget); expect(find.text('洗澡美容'), findsOneWidget); expect(find.text('加载更多'), findsNothing); expect(captured, [(1, null), (1, 'CUR-1'), (1, 'CUR-1')]); }); testWidgets('录入闭环:CTA → 表单(record_form 路由名)→ 成功后重拉首页', (tester) async { var listCalls = 0; repository.listHealthEventsHandler = (petId, limit, cursor) async { listCalls++; return listCalls == 1 ? page(const []) : page([ buildHealthEvent('e-new', overrides: {'title': '首次体检'}), ]); }; repository.createHealthEventHandler = (petId, request) async => buildHealthEvent('e-new', overrides: {'title': '首次体检'}); await pumpPage(tester); await tester.pumpAndSettle(); await tester.tap(find.text('记录第一条')); await tester.pumpAndSettle(); expect(find.byType(HealthEventFormPage), findsOneWidget); final route = ModalRoute.of( tester.element(find.byType(HealthEventFormPage)), )!; expect(route.settings.name, 'record_form'); await tester.tap(find.text('就医')); await tester.pump(); await tester.enterText( find.widgetWithText(TextFormField, '标题(如:皮肤检查)'), '首次体检', ); await tester.tap(find.text('保存记录')); await tester.pumpAndSettle(); expect(find.byType(HealthEventFormPage), findsNothing); expect(find.text('已记录健康事件'), findsOneWidget); // 排序/月分组以服务端为准:成功后重拉首页。 expect(listCalls, 2); expect(find.text('首次体检'), findsOneWidget); }); testWidgets('编辑闭环:点条目 → 编辑页(无路由名)→ 成功后就地替换', (tester) async { repository.listHealthEventsHandler = (petId, limit, cursor) async => page([buildHealthEvent('e-1')]); repository.updateHealthEventHandler = (eventId, request) async => buildHealthEvent('e-1', overrides: {'title': '皮肤复查', 'version': 2}); await pumpPage(tester); await tester.pumpAndSettle(); await tester.tap(find.text('皮肤检查')); await tester.pumpAndSettle(); expect(find.byType(HealthEventEditPage), findsOneWidget); final route = ModalRoute.of( tester.element(find.byType(HealthEventEditPage)), )!; // 编辑页不带路由名:record_form 专属创建漏斗到达段。 expect(route.settings.name, isNull); await tester.enterText(find.widgetWithText(TextFormField, '标题'), '皮肤复查'); await tester.tap(find.text('保存修改')); await tester.pumpAndSettle(); expect(find.byType(HealthEventEditPage), findsNothing); expect(find.text('已保存修改'), findsOneWidget); expect(find.text('皮肤复查'), findsOneWidget); expect(find.text('皮肤检查'), findsNothing); }); testWidgets('viewer(canWrite=false):无录入入口、空态无 CTA、点条目不进编辑', (tester) async { repository.listHealthEventsHandler = (petId, limit, cursor) async => page([buildHealthEvent('e-1')]); await pumpPage(tester, canWrite: false); await tester.pumpAndSettle(); expect(find.byIcon(Icons.add), findsNothing); await tester.tap(find.text('皮肤检查')); await tester.pumpAndSettle(); expect(find.byType(HealthEventEditPage), findsNothing); }); }