7d5c84d06d
CI / flutter-gates (push) Successful in 3m24s
卡片标签硬编码「本月花费」,而服务端 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>
511 lines
18 KiB
Dart
511 lines
18 KiB
Dart
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:月度花费卡接 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 = <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);
|
||
});
|
||
});
|
||
}
|