import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:qiangbao_accounting/api/health_api.dart'; import 'package:qiangbao_accounting/models/health.dart'; import 'package:qiangbao_accounting/pages/health/health_home_page.dart'; /// 「强宝健康」的页面布局回归测试。 /// /// 只断言「不溢出、不抛异常」——具体样式靠人眼验收。价值在于: /// 顶部那行「65.0 / 已减 1.8kg / 每周应减 0.54kg」在窄屏上很容易挤爆, /// 而光看代码看不出来(记账页就栽过这个)。 /// /// 两个测试上的坑(在这个项目里踩过): /// 1. 页面上的加载圈是**无限动画**,`pumpAndSettle` 永远等不到静止 → 超时。 /// 所以只能用显式 `pump`。 /// 2. Provider 里的接口调用要换成假的,否则会真的去连 localhost, /// 每个用例白等好几秒。 void main() { /// 常见机型宽度:小屏安卓 / iPhone 13 / 大屏 const sizes = [ ('小屏', Size(320, 568)), ('常规', Size(393, 851)), ('大屏', Size(430, 932)), ]; for (final (label, size) in sizes) { testWidgets('主页 $label 不溢出', (tester) async { await _pumpHome(tester, size); expect(find.text('强宝健康'), findsOneWidget); expect(find.text('减肥进度'), findsOneWidget); expect(find.text('当前体重'), findsOneWidget); expect(find.text('今日饮食'), findsOneWidget); expect(tester.takeException(), isNull); }); } testWidgets('没设过目标时显示「去设置」引导', (tester) async { await _pumpHome(tester, const Size(393, 851), goal: WeightGoal.none, chart: WeightChart.empty); expect(find.text('去设置'), findsOneWidget); expect(find.textContaining('还没设目标'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('有目标时显示进度、已减、每周应减', (tester) async { await _pumpHome(tester, const Size(393, 851), goal: _goalWithPlan()); expect(find.textContaining('已减'), findsOneWidget); expect(find.textContaining('每周应减'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('每周超过 1kg 时显示「节奏偏快」提示', (tester) async { await _pumpHome( tester, const Size(393, 851), goal: _goalWithPlan(weeklyLoss: 2.33, unhealthy: true), ); expect(find.textContaining('节奏偏快'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('没记过体重时不显示脏数字(显示 --)', (tester) async { await _pumpHome( tester, const Size(393, 851), goal: _goalWithPlan(currentWeight: null, lostWeight: null, remainWeight: null), chart: WeightChart.empty, ); expect(find.text('--'), findsWidgets); expect(find.textContaining('还没记体重'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('今日饮食有记录时列出餐次和热量', (tester) async { await _pumpHome( tester, const Size(393, 851), goal: _goalWithPlan(), diet: _dietWithMeals(), ); expect(find.textContaining('鸡蛋'), findsOneWidget); expect(find.text('记一笔'), findsOneWidget); expect(tester.takeException(), isNull); }); testWidgets('接口报错时三个卡片仍可用(不整页变错误态)', (tester) async { await _pumpHome(tester, const Size(393, 851), failing: true); // 卡片还在,只是内容退化成空态 —— 用户该能点进去重试 expect(find.text('减肥进度'), findsOneWidget); expect(find.text('当前体重'), findsOneWidget); expect(find.text('今日饮食'), findsOneWidget); expect(tester.takeException(), isNull); }); } // ================================================================== // 假数据 // ================================================================== WeightGoal _goalWithPlan({ double? currentWeight = 63.2, double? lostWeight = 1.8, double? remainWeight = 5.2, double weeklyLoss = 0.54, bool unhealthy = false, }) { return WeightGoal.fromJson({ 'hasGoal': true, 'id': 1, 'startWeight': 65.0, 'targetWeight': 58.0, 'startDate': '2026-01-01', 'targetDate': '2026-04-01', 'totalWeeks': 13, 'weeklyLoss': weeklyLoss, 'weeklyLossUnhealthy': unhealthy, 'idealCurve': [ {'date': '2026-01-01', 'weight': 65.0}, {'date': '2026-02-01', 'weight': 62.8}, ], 'currentWeight': currentWeight, 'lostWeight': lostWeight, 'remainWeight': remainWeight, 'weightProgress': 0.257, 'timeProgress': 0.4, }); } DietDay _dietWithMeals() { return DietDay.fromJson({ 'date': '2026-09-28', 'totals': { 'energyKcal': 1280.5, 'protein': 62.3, 'fat': 41.0, 'carbohydrate': 168.2, 'dietaryFiber': 12.5, 'sodium': 2100.0, 'itemCount': 2, }, 'meals': [ { 'mealType': 'breakfast', 'totals': {'energyKcal': 300.0, 'itemCount': 2}, 'items': [ { 'id': 1, 'mealType': 'breakfast', 'foodName': '鸡蛋', 'amountGrams': 50, 'energyKcal': 72.0, }, { 'id': 2, 'mealType': 'breakfast', 'foodName': '牛奶', 'amountGrams': 200, 'energyKcal': 228.0, }, ], }, ], }); } WeightChart _chartWithPoints() { return WeightChart.fromJson({ 'points': [ {'date': '2026-09-01', 'weight': 65.0}, {'date': '2026-09-10', 'weight': 64.0}, {'date': '2026-09-20', 'weight': 63.2}, ], 'currentWeight': 63.2, 'delta': -0.8, 'maxWeight': 65.0, 'minWeight': 58.0, }); } // ================================================================== // 假接口 // ================================================================== class _FakeHealthApi extends HealthApi { _FakeHealthApi({ required this.weightGoal, required this.weightChartData, required this.diet, this.failing = false, }) : super(Dio()); final WeightGoal weightGoal; final WeightChart weightChartData; final DietDay diet; final bool failing; @override Future goal() async { if (failing) throw Exception('boom'); return weightGoal; } @override Future weightChart({int days = 90}) async { if (failing) throw Exception('boom'); return weightChartData; } @override Future dietDay({DateTime? date}) async { if (failing) throw Exception('boom'); return diet; } @override Future todayTotals() async { if (failing) throw Exception('boom'); return diet.totals; } } /// 起页面:把三个 provider 都换成假数据,然后显式 pump 两帧 Future _pumpHome( WidgetTester tester, Size size, { WeightGoal? goal, WeightChart? chart, DietDay? diet, bool failing = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); final fake = _FakeHealthApi( weightGoal: goal ?? _goalWithPlan(), weightChartData: chart ?? _chartWithPoints(), diet: diet ?? _dietWithMeals(), failing: failing, ); await tester.pumpWidget( ProviderScope( overrides: [healthApiProvider.overrideWithValue(fake)], child: const MaterialApp(home: HealthHomePage()), ), ); // 不用 pumpAndSettle:页面里的加载圈是无限动画,等不到静止会超时。 // 显式 pump 两帧足够让 FutureProvider 完成并重建。 await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50)); }