Files
patbond-flutter/integration_test/client_ux_live_test.dart
T
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

244 lines
10 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: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);
}