Files
lixi 7d5c84d06d
CI / flutter-gates (push) Successful in 3m24s
修复:花费卡展示实际月份 + 四张数据卡补可点提示(M3.5-03)
卡片标签硬编码「本月花费」,而服务端 summary.monthlyExpense 本就返回 month
(ISO year-month,按 tz 归月)。用户看不到实际月份,所以无法自证「我这条记录
到底落在哪个月」,把正确的 ¥0 当成统计故障。

已核实不改后端聚合:用户记录落在 2026-04-09、当天是 2026-09-10,
「本月花费 ¥0」是正确行为。本单只让客户端把口径亮出来。

- health_record_display 新增纯函数 monthlyExpenseCardLabel(month, {now}):
  同年「9 月花费」(窄卡一行放得下,故不取「2026-09 花费」);跨年(服务端
  归月年份 ≠ 设备当前年份)「2026/12 花费」补年份消歧;串非法退回
  「本月花费」不崩不显示脏值。
- 可点提示:_SummaryCard 在 onTap 非空时右上角补 chevron_right。四张卡本都
  可点进明细页却无任何视觉提示(用户反馈不知道能点),提示形态沿用项目既有
  可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 chevron_right,不自创。
- 整卡包 MergeSemantics:读屏一次读全「¥128.50,9 月花费,按钮」而非两段
  孤立文字。没有用 excludeSemantics——那会连带丢掉 InkWell 的可激活性。

既有测试口径调整:pet_detail_page_test 两处断言改为实际月份,且样本
monthlyExpense.month 改用当月串使断言不随年份漂移(跨年格式由纯函数单测覆盖)。

新增 integration_test/client_ux_live_test.dart(环境变量门控,默认 skip,
沿用 M3 既有 live 测试形态):compose 六容器 + 真实 App 走完三项修复——
日期选择器全中文/品牌配色/手输录入/一键今天,以及花费卡实际月份 + chevron。
本机是 Wayland 会话、X11 import -window root 取不到根窗口,改为把整棵 App 包
一层 RepaintBoundary 后 toImage() 直出真实渲染像素落 build/ux-live/。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 18:00:21 +08:00

511 lines
18 KiB
Dart
Raw Permalink 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;
// 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:月度花费卡接 monthlyExpense12850 分 → 元展示)。
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 = <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);
});
});
}