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 _values = {}; @override Future read(String key) async => _values[key]; @override Future write(String key, String value) async => _values[key] = value; @override Future 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('ux-live-root'); Future shot(WidgetTester tester, String name) async { final boundary = tester.renderObject( 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 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 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> postJson( String url, Map 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; expect(envelope['code'], 0, reason: 'POST $url 业务码非 0:$text'); return envelope['data'] as Map; } 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(find.byType(Ink)) .map((ink) => ink.decoration) .whereType() .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); }