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/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'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_form_page.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pets_controller.dart'; import 'package:patbond_flutter/features/pets/vaccination_records_page.dart'; import 'package:patbond_flutter/features/pets/weight_records_page.dart'; import '../../helpers/pet_test_helpers.dart'; void main() { late FakePetsRepository repository; late PetsController controller; setUp(() { repository = FakePetsRepository(); controller = PetsController(repository: repository); }); Future pumpDetail(WidgetTester tester, {String petId = 'p-1'}) async { // 详情页自 T2-13 增加健康数据卡行、T2-14 增加花费卡与记录导航区, // 加高视口保证底部按钮在栏内。 tester.view.physicalSize = const Size(700, 2200); 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: (_) => PetDetailPage(controller: controller, petId: petId), ), ), ); await tester.pump(); } testWidgets('ready:档案字段齐备,owner 可见编辑入口', (tester) async { repository.getPetHandler = (petId) async => buildPet( 'p-1', overrides: { 'microchipNo': '985112000000001', 'personality': '活泼', 'sterilizedOn': '2025-06-01', }, ); await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('豆豆'), findsOneWidget); expect(find.text('基本资料'), findsOneWidget); expect(find.text('柴犬'), findsOneWidget); expect(find.text('男孩'), findsOneWidget); expect(find.text('2024-03-15'), findsOneWidget); expect(find.text('985112000000001'), findsOneWidget); expect(find.text('活泼'), findsOneWidget); expect(find.text('2025-06-01'), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsWidgets); expect(find.text('编辑资料'), findsOneWidget); }); testWidgets('生日估算标记与未填写字段兜底', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1', overrides: {'birthDateEstimated': true}); await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('2024-03-15(估算)'), findsOneWidget); // 芯片号 / 性格 / 绝育日期未填。 expect(find.text('未填写'), findsNWidgets(3)); }); testWidgets('四态 · loading:无内存副本时居中转圈', (tester) async { final completer = Completer(); repository.getPetHandler = (petId) => completer.future; await pumpDetail(tester); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); completer.complete(buildPet('p-1')); await tester.pumpAndSettle(); expect(find.text('豆豆'), findsOneWidget); }); testWidgets('四态 · error/retry:无副本加载失败给横幅与重试', (tester) async { var calls = 0; repository.getPetHandler = (petId) async { calls++; if (calls == 1) throw const ApiNetworkException('断网'); return buildPet('p-1'); }; await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.text('豆豆'), findsOneWidget); }); testWidgets('四态 · notFound(40401):提示不存在,返回列表并触发刷新', (tester) async { repository.getPetHandler = (petId) async => throw const PetNotFoundException(message: '不存在'); var listCalls = 0; repository.listPetsHandler = () async { listCalls++; return const []; }; await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('宠物不存在或已被删除'), findsOneWidget); await tester.tap(find.text('返回列表')); await tester.pumpAndSettle(); expect(find.byType(PetDetailPage), findsNothing); expect(find.text('列表基底'), findsOneWidget); expect(listCalls, 1); }); testWidgets('有内存副本时先渲染副本;后台刷新失败降级 SnackBar', (tester) async { repository.listPetsHandler = () async => [buildPet('p-1')]; await controller.refresh(); final completer = Completer(); repository.getPetHandler = (petId) => completer.future; await pumpDetail(tester); await tester.pump(); // 副本即时渲染,无整页 loading。 expect(find.text('豆豆'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsNothing); completer.completeError(const ApiNetworkException('断网')); await tester.pumpAndSettle(); expect(find.text('豆豆'), findsOneWidget); expect(find.text('网络异常,请检查网络后重试'), findsOneWidget); }); testWidgets('viewer 角色隐藏全部编辑入口(40300 语义)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1', overrides: {'myRole': 'viewer'}); await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.byIcon(Icons.edit_outlined), findsNothing); expect(find.text('编辑资料'), findsNothing); expect(find.byIcon(Icons.edit), findsNothing); }); testWidgets('owner 点编辑 → 进入编辑表单并预填', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.text('编辑资料')); await tester.pumpAndSettle(); expect(find.byType(PetFormPage), findsOneWidget); expect(find.text('编辑宠物资料'), findsOneWidget); expect(find.text('豆豆'), findsOneWidget); }); group('T2-13 · 数据卡行接 summary', () { testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 当月花费(tz 透传)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); String? capturedTz; // M3.5-03:花费卡标签展示服务端归月的实际月份,故样本月份取「本月」, // 断言不随年份漂移(跨年格式另由 monthlyExpenseCardLabel 单测覆盖)。 final now = DateTime.now(); final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; repository.getPetSummaryHandler = (petId, tz) async { capturedTz = tz; return buildSummary( overrides: { 'monthlyExpense': { 'month': thisMonth, 'timezone': 'Asia/Shanghai', 'amountCents': 12850, }, }, ); }; await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('健康数据'), findsOneWidget); expect(find.text('4.35 kg'), findsOneWidget); expect(find.text('最新体重'), findsOneWidget); expect(find.text('2/3 针'), findsOneWidget); expect(find.text('疫苗进度'), findsOneWidget); expect(find.text('2026-08-01'), findsOneWidget); expect(find.text('下一针·狂犬疫苗'), findsOneWidget); // T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。 expect(find.text('¥128.50'), findsOneWidget); // M3.5-03:不再硬编码「本月花费」,展示实际月份便于用户自查。 expect(find.text('${now.month} 月花费'), findsOneWidget); expect(find.text('本月花费'), findsNothing); // T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。 expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset)); expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$'))); }); testWidgets('M3.5-03 · 四张数据卡均有 chevron 可点提示', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); final now = DateTime.now(); final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; repository.getPetSummaryHandler = (petId, tz) async => buildSummary( overrides: { 'monthlyExpense': { 'month': thisMonth, 'timezone': 'Asia/Shanghai', 'amountCents': 0, }, }, ); await pumpDetail(tester); await tester.pumpAndSettle(); // 四张 stat 卡都挂了 onTap,此前无任何视觉提示;现统一补 // chevron_right(与宠物列表卡 / 健康提醒卡同款)。 for (final label in ['最新体重', '疫苗进度', '下一针·狂犬疫苗', '${now.month} 月花费']) { expect( find.ancestor( of: find.text(label), matching: find.widgetWithIcon(Card, Icons.chevron_right), ), findsOneWidget, reason: '「$label」卡缺可点提示', ); } }); testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); repository.getPetSummaryHandler = (petId, tz) async => buildSummary( overrides: { 'latestWeight': null, 'vaccinationProgress': null, 'nextVaccination': null, }, ); await pumpDetail(tester); await tester.pumpAndSettle(); expect(find.text('暂无记录'), findsOneWidget); expect(find.text('未登记'), findsOneWidget); expect(find.text('暂无安排'), findsOneWidget); expect(find.textContaining('0/0'), findsNothing); expect(find.text('下一针'), findsOneWidget); }); testWidgets('摘要加载失败:档案主链路不受阻,行内重试恢复', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); var calls = 0; repository.getPetSummaryHandler = (petId, tz) async { calls++; if (calls == 1) throw const ApiNetworkException('断网'); return buildSummary(); }; await pumpDetail(tester); await tester.pumpAndSettle(); // 基本资料照常渲染,摘要行内错误 + 重试。 expect(find.text('豆豆'), findsOneWidget); expect(find.text('健康数据加载失败'), findsOneWidget); await tester.tap(find.text('重试')); await tester.pumpAndSettle(); expect(find.text('4.35 kg'), findsOneWidget); expect(find.text('健康数据加载失败'), findsNothing); }); testWidgets('点体重卡 → 体重历史页;返回后重拉摘要', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); var summaryCalls = 0; repository.getPetSummaryHandler = (petId, tz) async { summaryCalls++; return buildSummary(); }; repository.listWeightsHandler = (petId, limit, cursor) async => const CursorPage(items: [], nextCursor: null, hasMore: false); await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.text('最新体重')); await tester.pumpAndSettle(); expect(find.byType(WeightRecordsPage), findsOneWidget); expect(find.text('体重记录'), findsOneWidget); await tester.pageBack(); await tester.pumpAndSettle(); expect(summaryCalls, 2); }); testWidgets('点疫苗卡 → 疫苗记录页(viewer 权限透传隐藏登记入口)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1', overrides: {'myRole': 'viewer'}); repository.getPetSummaryHandler = (petId, tz) async => buildSummary(); repository.listVaccinationsHandler = (petId) async => const []; await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.text('疫苗进度')); await tester.pumpAndSettle(); expect(find.byType(VaccinationRecordsPage), findsOneWidget); // viewer:空态无 CTA、AppBar 无添加入口。 expect(find.text('登记第一针'), findsNothing); expect(find.byIcon(Icons.add), findsNothing); }); }); group('T2-14 · 时间线入口', () { testWidgets('点当月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1'); var summaryCalls = 0; final now = DateTime.now(); final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}'; repository.getPetSummaryHandler = (petId, tz) async { summaryCalls++; return buildSummary( overrides: { 'monthlyExpense': { 'month': thisMonth, 'timezone': 'Asia/Shanghai', 'amountCents': 12850, }, }, ); }; repository.listHealthEventsHandler = (petId, limit, cursor) async => const CursorPage(items: [], nextCursor: null, hasMore: false); await pumpDetail(tester); await tester.pumpAndSettle(); await tester.tap(find.text('${now.month} 月花费')); await tester.pumpAndSettle(); expect(find.byType(HealthEventsPage), findsOneWidget); expect(find.text('健康时间线'), findsOneWidget); await tester.pageBack(); await tester.pumpAndSettle(); expect(summaryCalls, 2); }); testWidgets('记录导航区「健康时间线」入口可达(viewer 透传隐藏录入)', (tester) async { repository.getPetHandler = (petId) async => buildPet('p-1', overrides: {'myRole': 'viewer'}); repository.getPetSummaryHandler = (petId, tz) async => buildSummary(); repository.listHealthEventsHandler = (petId, limit, cursor) async => const CursorPage(items: [], nextCursor: null, hasMore: false); await pumpDetail(tester); await tester.pumpAndSettle(); final tile = find.widgetWithText(ListTile, '健康时间线'); expect(tile, findsOneWidget); await tester.ensureVisible(tile); await tester.tap(tile); await tester.pumpAndSettle(); expect(find.byType(HealthEventsPage), findsOneWidget); // viewer:空态无 CTA、AppBar 无添加入口。 expect(find.text('记录第一条'), findsNothing); 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 = []; 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); }); }); }