卡片标签硬编码「本月花费」,而服务端 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>
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' show ImageByteFormat;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:patbond_flutter/app/app.dart';
|
||||||
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_detail_page.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_form_page.dart';
|
||||||
|
|
||||||
|
/// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。
|
||||||
|
class _InMemoryTokenStore implements TokenStore {
|
||||||
|
final Map<String, String> _values = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read(String key) async => _values[key];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> write(String key, String value) async => _values[key] = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(String key) async => _values.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M3.5-01/02/03 compose 真链路桌面实测(默认跳过,不计入常规测试套件):
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # 先起后端六容器(patbond-api 仓库根 docker compose up -d --build),再:
|
||||||
|
/// PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 驱动**真实 App**(Linux 桌面 GTK 渲染管线 + 真实 HTTP)走用户实测那条路:
|
||||||
|
/// 注册 → UI 登录 → 档案页建档 → 打开生日选择器(校验全中文 + 品牌配色 +
|
||||||
|
/// 手输可用 + 一键今天)→ 保存进详情页(校验花费卡展示实际月份 + chevron)。
|
||||||
|
/// 逐步截图落到 `build/ux-live/`:本机是 Wayland 会话,X11 的
|
||||||
|
/// `import -window root` 取不到根窗口,改为把整棵 App 包一层
|
||||||
|
/// [RepaintBoundary] 后 `toImage()` 直出真实渲染像素(含 Overlay 里的弹窗)。
|
||||||
|
void main() {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||||
|
|
||||||
|
final enabled = Platform.environment['PATBOND_UX_LIVE'] == '1';
|
||||||
|
const authBase = 'http://127.0.0.1:8081';
|
||||||
|
const petBase = 'http://127.0.0.1:8083';
|
||||||
|
final shotDir = Directory('build/ux-live');
|
||||||
|
const rootKey = ValueKey<String>('ux-live-root');
|
||||||
|
|
||||||
|
Future<void> shot(WidgetTester tester, String name) async {
|
||||||
|
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||||
|
find.byKey(rootKey),
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
if (!shotDir.existsSync()) shotDir.createSync(recursive: true);
|
||||||
|
final image = await boundary.toImage(pixelRatio: 1.5);
|
||||||
|
final bytes = await image.toByteData(format: ImageByteFormat.png);
|
||||||
|
final path = '${shotDir.path}/$name.png';
|
||||||
|
File(path).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
debugPrint('[shot] $name → $path (${bytes.lengthInBytes} bytes)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 25),
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
if (finder.evaluate().isNotEmpty) return;
|
||||||
|
}
|
||||||
|
fail('等待超时:$finder');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpUntilGone(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 25),
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
if (finder.evaluate().isEmpty) return;
|
||||||
|
}
|
||||||
|
fail('等待消失超时:$finder');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 直连服务端下单个 JSON 请求(种子数据走真实 HTTP,不绕 DB)。
|
||||||
|
Future<Map<String, dynamic>> postJson(
|
||||||
|
String url,
|
||||||
|
Map<String, Object?> body, {
|
||||||
|
String? bearer,
|
||||||
|
}) async {
|
||||||
|
final client = HttpClient();
|
||||||
|
final request = await client.postUrl(Uri.parse(url));
|
||||||
|
request.headers.contentType = ContentType.json;
|
||||||
|
if (bearer != null) {
|
||||||
|
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearer');
|
||||||
|
}
|
||||||
|
request.add(utf8.encode(jsonEncode(body)));
|
||||||
|
final response = await request.close();
|
||||||
|
final text = await response.transform(utf8.decoder).join();
|
||||||
|
client.close();
|
||||||
|
expect(response.statusCode, anyOf(200, 201), reason: 'POST $url 失败:$text');
|
||||||
|
final envelope = jsonDecode(text) as Map<String, dynamic>;
|
||||||
|
expect(envelope['code'], 0, reason: 'POST $url 业务码非 0:$text');
|
||||||
|
return envelope['data'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('桌面真链路:日期选择器中文/品牌色/手输/今天 + 花费卡实际月份', (tester) async {
|
||||||
|
// ---- 注册一次性账号 + 种子数据(宠物 + 当月一笔就医支出)----
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final username = 'uxlive$seed';
|
||||||
|
final password = 'Live1234!$seed';
|
||||||
|
await postJson('$authBase/api/v1/auth/register', {
|
||||||
|
'username': username,
|
||||||
|
'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}',
|
||||||
|
'password': password,
|
||||||
|
});
|
||||||
|
final tokens = await postJson('$authBase/api/v1/auth/login', {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
});
|
||||||
|
final accessToken = tokens['accessToken'] as String;
|
||||||
|
final pet = await postJson('$petBase/api/v1/pets', {
|
||||||
|
'name': '实测豆豆',
|
||||||
|
'species': 'dog',
|
||||||
|
'sex': 'male',
|
||||||
|
'customBreedName': '中华田园犬',
|
||||||
|
}, bearer: accessToken);
|
||||||
|
final petId = pet['id'] as String;
|
||||||
|
// 当月一笔 128.50 元就医支出:花费卡应显示「N 月花费 ¥128.50」——
|
||||||
|
// 用户那次误录(记到 4 月)看到 ¥0 正是因为记录不在当月窗口内。
|
||||||
|
await postJson('$petBase/api/v1/pets/$petId/health-events', {
|
||||||
|
'eventType': 'medical',
|
||||||
|
'occurredAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'title': '皮肤检查',
|
||||||
|
'amountCents': 12850,
|
||||||
|
}, bearer: accessToken);
|
||||||
|
|
||||||
|
// ---- 启动真实 App ----
|
||||||
|
await tester.pumpWidget(
|
||||||
|
RepaintBoundary(
|
||||||
|
key: rootKey,
|
||||||
|
child: App(
|
||||||
|
sessionManager: SessionManager(store: _InMemoryTokenStore()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await pumpUntil(tester, find.text('登录'));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).at(0), username);
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), password);
|
||||||
|
await tester.tap(find.text('登录'));
|
||||||
|
|
||||||
|
// ---- 档案 Tab → 建档表单 ----
|
||||||
|
await pumpUntil(tester, find.text('档案'));
|
||||||
|
await tester.tap(find.text('档案'));
|
||||||
|
// 列表已有种子宠物 → 走虚线「+ 添加宠物」卡(空态 CTA 文案不同)。
|
||||||
|
await pumpUntil(tester, find.textContaining('添加宠物'));
|
||||||
|
await tester.tap(find.textContaining('添加宠物').first);
|
||||||
|
await pumpUntil(tester, find.byType(PetFormPage));
|
||||||
|
await shot(tester, '01-pet-form');
|
||||||
|
|
||||||
|
// ---- 生日日期选择器:全中文 + 品牌配色 ----
|
||||||
|
await tester.tap(find.widgetWithText(ListTile, '生日(可选)'));
|
||||||
|
await pumpUntil(tester, find.byType(DatePickerDialog));
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await shot(tester, '02-date-picker-zh');
|
||||||
|
|
||||||
|
expect(find.text('选择日期'), findsOneWidget, reason: '标题非中文');
|
||||||
|
expect(find.text('确定'), findsOneWidget);
|
||||||
|
expect(find.text('取消'), findsOneWidget);
|
||||||
|
expect(find.text('Select date'), findsNothing);
|
||||||
|
expect(find.text('OK'), findsNothing);
|
||||||
|
|
||||||
|
final inkColors = tester
|
||||||
|
.widgetList<Ink>(find.byType(Ink))
|
||||||
|
.map((ink) => ink.decoration)
|
||||||
|
.whereType<ShapeDecoration>()
|
||||||
|
.map((d) => d.color)
|
||||||
|
.toList();
|
||||||
|
expect(
|
||||||
|
inkColors,
|
||||||
|
contains(AppColors.primaryStrong),
|
||||||
|
reason: '选中日仍是 fromSeed 派生的暗红棕,未取品牌 primaryStrong',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 手输模式:中文标签 + 敲入已知日期 ----
|
||||||
|
await tester.tap(find.byIcon(Icons.edit_outlined));
|
||||||
|
await pumpUntil(tester, find.text('输入日期'));
|
||||||
|
await shot(tester, '03-date-input-zh');
|
||||||
|
final field = find.descendant(
|
||||||
|
of: find.byType(DatePickerDialog),
|
||||||
|
matching: find.byType(TextField),
|
||||||
|
);
|
||||||
|
await tester.enterText(field, '2024/03/15');
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(find.text('确定'));
|
||||||
|
await pumpUntil(tester, find.text('2024-03-15'));
|
||||||
|
await shot(tester, '04-date-typed');
|
||||||
|
|
||||||
|
// ---- 「今天」快捷键:一键落今天,不开弹窗 ----
|
||||||
|
final todayText =
|
||||||
|
'${DateTime.now().year}-'
|
||||||
|
'${DateTime.now().month.toString().padLeft(2, '0')}-'
|
||||||
|
'${DateTime.now().day.toString().padLeft(2, '0')}';
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '今天'));
|
||||||
|
await pumpUntil(tester, find.text(todayText));
|
||||||
|
expect(find.byType(DatePickerDialog), findsNothing, reason: '「今天」不该开弹窗');
|
||||||
|
await shot(tester, '05-today-shortcut');
|
||||||
|
|
||||||
|
// ---- 退出建档表单(本单不验建档链路,宠物与支出已由 API 种下)----
|
||||||
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||||
|
await pumpUntilGone(tester, find.byType(PetFormPage));
|
||||||
|
|
||||||
|
// ---- 详情页:花费卡展示实际月份 + chevron 可点提示 ----
|
||||||
|
await pumpUntil(tester, find.text('实测豆豆'));
|
||||||
|
await shot(tester, '06-pets-list');
|
||||||
|
await tester.tap(find.text('实测豆豆'));
|
||||||
|
await pumpUntil(tester, find.byType(PetDetailPage));
|
||||||
|
final monthLabel = '${DateTime.now().month} 月花费';
|
||||||
|
await pumpUntil(tester, find.text(monthLabel));
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await shot(tester, '07-pet-detail-expense-card');
|
||||||
|
|
||||||
|
expect(find.text('本月花费'), findsNothing, reason: '仍是硬编码「本月花费」');
|
||||||
|
// 当月真有支出 → 卡片给出金额(月份口径自证:记录落在当月才计入)。
|
||||||
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text(monthLabel),
|
||||||
|
matching: find.widgetWithIcon(Card, Icons.chevron_right),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
reason: '花费卡缺 chevron 可点提示',
|
||||||
|
);
|
||||||
|
}, skip: !enabled);
|
||||||
|
}
|
||||||
@@ -125,6 +125,27 @@ String healthEventMonthHeader(DateTime occurredAt) {
|
|||||||
return '${local.year} 年 ${local.month} 月';
|
return '${local.year} 年 ${local.month} 月';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 月度花费卡标签(M3.5-03)。
|
||||||
|
///
|
||||||
|
/// 根因备忘:卡片标签此前硬编码「本月花费」,用户无法自证「本月」到底是
|
||||||
|
/// 哪个月——他把当月记录误录到 2026-04 后看到「本月花费 ¥0」,以为聚合坏了。
|
||||||
|
/// 服务端 `summary.monthlyExpense.month` 本就返回 ISO year-month(如 `2026-09`,
|
||||||
|
/// 按 `tz` 归月),直接展示即可自查,无需改后端。
|
||||||
|
///
|
||||||
|
/// [month] 形如 `2026-09`。窄卡(一行四卡)只放得下 4~5 个字,故取「9 月花费」
|
||||||
|
/// 而非「2026-09 花费」;跨年(服务端归月的年份与设备当前年份不一致,如设备
|
||||||
|
/// 已跨到 1 月而窗口仍是去年 12 月)时补年份消歧。解析失败退回「本月花费」。
|
||||||
|
String monthlyExpenseCardLabel(String month, {DateTime? now}) {
|
||||||
|
final match = RegExp(r'^(\d{4})-(\d{2})$').firstMatch(month);
|
||||||
|
if (match == null) return '本月花费';
|
||||||
|
final year = int.parse(match.group(1)!);
|
||||||
|
final monthNo = int.parse(match.group(2)!);
|
||||||
|
if (monthNo < 1 || monthNo > 12) return '本月花费';
|
||||||
|
final currentYear = (now ?? DateTime.now()).year;
|
||||||
|
if (year != currentYear) return '$year/$monthNo 花费';
|
||||||
|
return '$monthNo 月花费';
|
||||||
|
}
|
||||||
|
|
||||||
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
/// 设备时区 → summary `tz` 参数(契约接受固定偏移形如 `+08:00`;
|
||||||
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
/// Flutter 无 IANA 名可取,固定偏移语义等价——只作用于月度窗口)。
|
||||||
String tzOffsetQueryValue(Duration offset) {
|
String tzOffsetQueryValue(Duration offset) {
|
||||||
|
|||||||
@@ -532,7 +532,9 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
|
// monthlyExpense 恒非 null(契约);金额整数分 → 元展示。
|
||||||
value: '¥${formatCentsAsYuan(expense.amountCents)}',
|
value: '¥${formatCentsAsYuan(expense.amountCents)}',
|
||||||
emphasized: expense.amountCents > 0,
|
emphasized: expense.amountCents > 0,
|
||||||
label: '本月花费',
|
// M3.5-03:标签展示服务端归月的实际月份(此前硬编码
|
||||||
|
// 「本月花费」,用户无法自证记录落在哪个月)。
|
||||||
|
label: monthlyExpenseCardLabel(expense.month),
|
||||||
onTap: () => _openTimeline(pet),
|
onTap: () => _openTimeline(pet),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -545,6 +547,13 @@ class _PetDetailPageState extends State<PetDetailPage> {
|
|||||||
|
|
||||||
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
/// 数据卡(正典 stat-card 形态):图标 + 数值 15/w800 + 标签 12 inkSoft;
|
||||||
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
/// 空态数值降级 inkSoft 常规字重(区分「有数据」与「空态」两种视觉)。
|
||||||
|
///
|
||||||
|
/// M3.5-03:可点卡([onTap] 非空)右上角补 `chevron_right`——四张卡本都可点进
|
||||||
|
/// 明细页,但此前无任何视觉提示,用户实测反馈不知道能点。提示形态沿用项目
|
||||||
|
/// 既有可点行/卡(宠物列表卡、健康提醒卡、资料页设置行)的 `chevron_right`,
|
||||||
|
/// 不自创。同时 [MergeSemantics] 把「数值 + 标签」并进 InkWell 的 button 节点,
|
||||||
|
/// 读屏一次读全「¥0,9 月花费,按钮」,而不是两段孤立文字(tap 动作仍在
|
||||||
|
/// InkWell 上,不用 `excludeSemantics` 以免连带丢掉可激活性)。
|
||||||
class _SummaryCard extends StatelessWidget {
|
class _SummaryCard extends StatelessWidget {
|
||||||
const _SummaryCard({
|
const _SummaryCard({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -564,35 +573,51 @@ class _SummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return MergeSemantics(
|
||||||
child: InkWell(
|
child: Card(
|
||||||
onTap: onTap,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
onTap: onTap,
|
||||||
child: Padding(
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Icon(icon, size: 18, color: iconColor),
|
children: [
|
||||||
const SizedBox(height: 8),
|
Row(
|
||||||
Text(
|
children: [
|
||||||
value,
|
Icon(icon, size: 18, color: iconColor),
|
||||||
maxLines: 1,
|
const Spacer(),
|
||||||
overflow: TextOverflow.ellipsis,
|
if (onTap != null)
|
||||||
style: TextStyle(
|
const Icon(
|
||||||
color: emphasized ? AppColors.ink : AppColors.inkSoft,
|
Icons.chevron_right,
|
||||||
fontSize: emphasized ? 15 : 13,
|
size: 16,
|
||||||
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
|
color: AppColors.muted,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 4),
|
Text(
|
||||||
Text(
|
value,
|
||||||
label,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(
|
||||||
style: const TextStyle(color: AppColors.inkSoft, fontSize: 11),
|
color: emphasized ? AppColors.ink : AppColors.inkSoft,
|
||||||
),
|
fontSize: emphasized ? 15 : 13,
|
||||||
],
|
fontWeight: emphasized ? FontWeight.w800 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.inkSoft,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -277,4 +277,30 @@ void main() {
|
|||||||
expect(reminderStatusColor(dismissed, now), AppColors.muted);
|
expect(reminderStatusColor(dismissed, now), AppColors.muted);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('M3.5-03 · monthlyExpenseCardLabel(花费卡展示实际月份)', () {
|
||||||
|
test('同年只给月份,卡片一行放得下(不再硬编码「本月花费」)', () {
|
||||||
|
final now = DateTime(2026, 9, 10);
|
||||||
|
expect(monthlyExpenseCardLabel('2026-09', now: now), '9 月花费');
|
||||||
|
// 用户误录场景:记录落在 4 月,卡片就该明说是 4 月,而不是「本月」。
|
||||||
|
expect(monthlyExpenseCardLabel('2026-04', now: now), '4 月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-12', now: now), '12 月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-01', now: now), '1 月花费');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跨年(服务端归月年份 ≠ 设备当前年份)补年份消歧', () {
|
||||||
|
// 设备已跨到 2027-01,服务端 tz 窗口仍落在 2026-12。
|
||||||
|
final now = DateTime(2027, 1, 1);
|
||||||
|
expect(monthlyExpenseCardLabel('2026-12', now: now), '2026/12 花费');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('月份串非法退回「本月花费」(不崩、不显示脏值)', () {
|
||||||
|
final now = DateTime(2026, 9, 10);
|
||||||
|
expect(monthlyExpenseCardLabel('', now: now), '本月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-9', now: now), '本月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-13', now: now), '本月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-00', now: now), '本月花费');
|
||||||
|
expect(monthlyExpenseCardLabel('2026-09-01', now: now), '本月花费');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,12 +188,24 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('T2-13 · 数据卡行接 summary', () {
|
group('T2-13 · 数据卡行接 summary', () {
|
||||||
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 本月花费(tz 透传)', (tester) async {
|
testWidgets('四卡取数:最新体重 / 疫苗进度 / 下一针 / 当月花费(tz 透传)', (tester) async {
|
||||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
String? capturedTz;
|
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 {
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
capturedTz = tz;
|
capturedTz = tz;
|
||||||
return buildSummary();
|
return buildSummary(
|
||||||
|
overrides: {
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': thisMonth,
|
||||||
|
'timezone': 'Asia/Shanghai',
|
||||||
|
'amountCents': 12850,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
await pumpDetail(tester);
|
await pumpDetail(tester);
|
||||||
@@ -208,12 +220,45 @@ void main() {
|
|||||||
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
expect(find.text('下一针·狂犬疫苗'), findsOneWidget);
|
||||||
// T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
// T2-14:月度花费卡接 monthlyExpense(12850 分 → 元展示)。
|
||||||
expect(find.text('¥128.50'), findsOneWidget);
|
expect(find.text('¥128.50'), findsOneWidget);
|
||||||
expect(find.text('本月花费'), findsOneWidget);
|
// M3.5-03:不再硬编码「本月花费」,展示实际月份便于用户自查。
|
||||||
|
expect(find.text('${now.month} 月花费'), findsOneWidget);
|
||||||
|
expect(find.text('本月花费'), findsNothing);
|
||||||
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
|
// T2-13 遗留③:tz 透传设备时区固定偏移(月度窗口随设备时区)。
|
||||||
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
|
expect(capturedTz, tzOffsetQueryValue(DateTime.now().timeZoneOffset));
|
||||||
expect(capturedTz, matches(RegExp(r'^[+-]\d{2}:\d{2}$')));
|
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 {
|
testWidgets('null 语义:无登记显示空态而非 0/0', (tester) async {
|
||||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
|
repository.getPetSummaryHandler = (petId, tz) async => buildSummary(
|
||||||
@@ -302,12 +347,22 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('T2-14 · 时间线入口', () {
|
group('T2-14 · 时间线入口', () {
|
||||||
testWidgets('点本月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
|
testWidgets('点当月花费卡 → 健康时间线页;返回后重拉摘要', (tester) async {
|
||||||
repository.getPetHandler = (petId) async => buildPet('p-1');
|
repository.getPetHandler = (petId) async => buildPet('p-1');
|
||||||
var summaryCalls = 0;
|
var summaryCalls = 0;
|
||||||
|
final now = DateTime.now();
|
||||||
|
final thisMonth = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
||||||
repository.getPetSummaryHandler = (petId, tz) async {
|
repository.getPetSummaryHandler = (petId, tz) async {
|
||||||
summaryCalls++;
|
summaryCalls++;
|
||||||
return buildSummary();
|
return buildSummary(
|
||||||
|
overrides: {
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': thisMonth,
|
||||||
|
'timezone': 'Asia/Shanghai',
|
||||||
|
'amountCents': 12850,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
repository.listHealthEventsHandler = (petId, limit, cursor) async =>
|
||||||
const CursorPage(items: [], nextCursor: null, hasMore: false);
|
const CursorPage(items: [], nextCursor: null, hasMore: false);
|
||||||
@@ -315,7 +370,7 @@ void main() {
|
|||||||
await pumpDetail(tester);
|
await pumpDetail(tester);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('本月花费'));
|
await tester.tap(find.text('${now.month} 月花费'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(HealthEventsPage), findsOneWidget);
|
expect(find.byType(HealthEventsPage), findsOneWidget);
|
||||||
|
|||||||
Reference in New Issue
Block a user