强宝健康模块
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user