强宝健康模块

This commit is contained in:
2026-09-28 16:40:32 +08:00
parent d52775ec10
commit 2dd89bfde0
17 changed files with 4271 additions and 1 deletions
+265
View File
@@ -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));
}
+253
View File
@@ -0,0 +1,253 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:qiangbao_accounting/models/health.dart';
/// 「强宝健康」模型的解析测试。
///
/// 这些 fromJson 全部要能扛住后端字段缺失/类型不符 —— 它们被用在
/// 首屏三个卡片上,任何一处抛异常都会让整个首页变成错误态。
/// 所以每个测试都在验证「给脏数据也不炸」。
void main() {
group('体重折线图解析', () {
test('正常数据:点位、当前体重、差值都对', () {
final chart = WeightChart.fromJson({
'points': [
{'date': '2026-09-01', 'weight': 65.0},
{'date': '2026-09-05', 'weight': 64.2},
],
'currentWeight': 64.2,
'delta': -0.8,
'maxWeight': 66.0,
'minWeight': 58.0,
});
expect(chart.points.length, 2);
expect(chart.points.first.weight, 65.0);
expect(chart.points.last.date.day, 5);
expect(chart.currentWeight, 64.2);
expect(chart.delta, -0.8);
expect(chart.hasData, isTrue);
});
test('空数据不炸,hasData 为 false', () {
final chart = WeightChart.fromJson(const {});
expect(chart.points, isEmpty);
expect(chart.hasData, isFalse);
expect(chart.currentWeight, isNull);
expect(chart.delta, isNull);
});
test('字段类型不符(字符串数字)也能解析', () {
final chart = WeightChart.fromJson({
'points': [
{'date': '2026-09-01', 'weight': '65.5'},
],
'currentWeight': '65.5',
});
expect(chart.points.first.weight, 65.5);
expect(chart.currentWeight, 65.5);
});
test('日期解析不出来时不抛异常', () {
final chart = WeightChart.fromJson({
'points': [
{'date': '不是日期', 'weight': 65},
],
});
expect(chart.points.length, 1);
expect(chart.points.first.weight, 65);
});
test('delta 为 null(只记过一次)时保持 null,不退化成 0', () {
// 0 会被显示成「没变化」,而实际含义是「还没有上一次可比」——
// 这两件事在界面上必须能区分
final chart = WeightChart.fromJson({
'points': [
{'date': '2026-09-01', 'weight': 65},
],
'delta': null,
});
expect(chart.delta, isNull);
});
});
group('减重目标解析', () {
test('没设过目标时 hasGoal=false,字段全是安全的默认值', () {
final goal = WeightGoal.fromJson({'hasGoal': false});
expect(goal.hasGoal, isFalse);
expect(goal.idealCurve, isEmpty);
expect(goal.weeklyLoss, 0);
expect(goal.currentWeight, isNull);
});
test('正常目标:每周应减 + 理想曲线 + 进度', () {
final goal = WeightGoal.fromJson({
'hasGoal': true,
'id': 3,
'startWeight': 65.0,
'targetWeight': 58.0,
'startDate': '2026-01-01',
'targetDate': '2026-04-01',
'totalWeeks': 13,
'weeklyLoss': 0.54,
'weeklyLossUnhealthy': false,
'idealCurve': [
{'date': '2026-01-01', 'weight': 65.0},
{'date': '2026-01-08', 'weight': 64.46},
],
'currentWeight': 63.2,
'lostWeight': 1.8,
'remainWeight': 5.2,
'weightProgress': 0.257,
'timeProgress': 0.4,
});
expect(goal.hasGoal, isTrue);
expect(goal.weeklyLoss, 0.54);
expect(goal.idealCurve.length, 2);
expect(goal.idealCurve.first.weight, 65.0);
expect(goal.lostWeight, 1.8);
expect(goal.weightProgress, closeTo(0.257, 0.001));
expect(goal.weeklyLossUnhealthy, isFalse);
});
test('每周超过 1kg 时标成不健康', () {
final goal = WeightGoal.fromJson({
'hasGoal': true,
'startWeight': 65.0,
'targetWeight': 58.0,
'weeklyLoss': 2.33,
'weeklyLossUnhealthy': true,
});
expect(goal.weeklyLossUnhealthy, isTrue);
});
test('缺 hasGoal 字段时按「没设过目标」处理', () {
final goal = WeightGoal.fromJson(const {});
expect(goal.hasGoal, isFalse);
});
});
group('食物与份量', () {
test('份量列表解析,并保证有兜底', () {
final food = FoodItem.fromJson({
'id': 1,
'name': '米饭(蒸)',
'energyKcal': 116.0,
'portions': [
{'label': '一碗', 'grams': 150},
{'label': '小碗', 'grams': 100},
],
});
expect(food.portions.length, 2);
expect(food.portions.first.label, '一碗');
expect(food.portions.first.grams, 150);
});
test('kcalFor 按份量换算', () {
final food = FoodItem.fromJson({
'id': 1,
'name': '米饭',
'energyKcal': 116.0,
});
// 每 100g 116 kcal,吃 150g 就是 174 kcal
expect(food.kcalFor(150), closeTo(174.0, 0.01));
expect(food.kcalFor(100), closeTo(116.0, 0.01));
});
test('kcalFor 遇到 0 / 负数 / 缺能量值都返回 0,不抛不返回 NaN', () {
final food = FoodItem.fromJson({'id': 1, 'name': '未知食物'});
expect(food.kcalFor(150), 0);
expect(food.kcalFor(0), 0);
expect(food.kcalFor(-10), 0);
});
test('食物名为空时给占位,不显示空白', () {
final food = FoodItem.fromJson({'id': 1});
expect(food.name, '未命名食物');
});
});
group('饮食记录解析', () {
test('某天的饮食:合计 + 按餐次分组', () {
final day = 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': 5,
},
'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,
},
],
},
],
});
expect(day.totals.energyKcal, 1280.5);
expect(day.totals.itemCount, 5);
expect(day.meals.length, 1);
expect(day.meals.first.mealType, 'breakfast');
expect(day.meals.first.items.length, 2);
expect(day.meals.first.items.first.foodName, '鸡蛋');
});
test('空的一天不炸,合计归零', () {
final day = DietDay.fromJson(const {});
expect(day.totals.energyKcal, 0);
expect(day.totals.itemCount, 0);
expect(day.meals, isEmpty);
});
test('明细缺字段时用安全默认值', () {
final day = DietDay.fromJson({
'meals': [
{
'items': [
{'id': 1},
],
},
],
});
final item = day.meals.first.items.first;
expect(item.foodName, '未命名食物');
expect(item.energyKcal, 0);
expect(item.mealType, 'snack');
});
});
group('餐次标签', () {
test('四个餐次都有中文名', () {
expect(mealLabel('breakfast'), '早餐');
expect(mealLabel('lunch'), '午餐');
expect(mealLabel('dinner'), '晚餐');
expect(mealLabel('snack'), '加餐');
});
test('未知餐次兜底到加餐,不返回空字符串', () {
expect(mealLabel('brunch'), '加餐');
expect(mealLabel(''), '加餐');
});
});
}