Files
Aq-Accounting-Flutter/test/health_layout_test.dart
T
2026-09-28 16:40:32 +08:00

266 lines
7.8 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 '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<WeightGoal> goal() async {
if (failing) throw Exception('boom');
return weightGoal;
}
@override
Future<WeightChart> weightChart({int days = 90}) async {
if (failing) throw Exception('boom');
return weightChartData;
}
@override
Future<DietDay> dietDay({DateTime? date}) async {
if (failing) throw Exception('boom');
return diet;
}
@override
Future<NutritionTotals> todayTotals() async {
if (failing) throw Exception('boom');
return diet.totals;
}
}
/// 起页面:把三个 provider 都换成假数据,然后显式 pump 两帧
Future<void> _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));
}