Files
patbond-flutter/test/features/pets/pet_detail_page_test.dart
T
lixi ba503327f5
CI / flutter-gates (push) Successful in 2m12s
新增:照护提醒接入与疫苗流转收尾(T2-14 提醒半边 + T2-13 遗留)
- 照护提醒页:status 过滤(服务端白名单视图)、due_at ASC、逾期红标
  双通道标识,四态齐备;创建表单(四类)+ 完成(completedAt 必带、
  支持补记)/ 忽略(禁带 completedAt)流转,42202/40902 兜底重拉
- 档案页:「健康提醒」卡改真实待办数据驱动(取代 demo 硬编码文案,
  最近到期一条 + 逾期警示形态),照护提醒导航入口带待办数副行
- 疫苗列表(25 号报告遗留①②):scheduled 行「标记完成/取消登记」
  PATCH 流转;完成对话框补录厂商/批号(契约可选字段);
  42201/40902/40402 兜底
- 埋点:create 三事件 + viewed(recordType=reminder)挂通;
  edit_succeeded/failed 挂 vaccine 流转(failureReason 含 conflict);
  提醒完成/忽略按 06 §7 缺口 3 既定取舍不埋
- 测试 250 → 272 全绿(+22,较基线 +48);analyze 0 问题;format 无 diff

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

456 lines
16 KiB
Dart
Raw 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/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<void> 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<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
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<Pet>();
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<Pet>();
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;
repository.getPetSummaryHandler = (petId, tz) async {
capturedTz = tz;
return buildSummary();
};
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:月度花费卡接 monthlyExpense12850 分 → 元展示)。
expect(find.text('¥128.50'), findsOneWidget);
expect(find.text('本月花费'), findsOneWidget);
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
});
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;
repository.getPetSummaryHandler = (petId, tz) async {
summaryCalls++;
return buildSummary();
};
repository.listHealthEventsHandler = (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(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 = <CareReminderStatus?>[];
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);
});
});
}