Files
lifetrack/apps/api/app/tests/modules/test_health_calculations.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

365 lines
13 KiB
Python

"""Unit tests for the pure health calculations (datamodel-health-vape.md §5
and the planning addendum). Exact formula values, no database."""
import datetime as dt
from dataclasses import dataclass
import pytest
from app.modules.health import calculations as calc
D = dt.date
# --- BMR / TDEE / budget ------------------------------------------------------
def test_bmr_mifflin_exact_values_per_sex() -> None:
# 10*80 + 6.25*180 - 5*30 = 1775
assert calc.bmr_mifflin(80, 180, 30, "male") == pytest.approx(1780.0)
assert calc.bmr_mifflin(80, 180, 30, "female") == pytest.approx(1614.0)
assert calc.bmr_mifflin(80, 180, 30, "other") == pytest.approx(1697.0)
def test_age_on_handles_birthday_not_reached() -> None:
assert calc.age_on(D(2026, 8, 13), D(1990, 8, 13)) == 36
assert calc.age_on(D(2026, 8, 12), D(1990, 8, 13)) == 35
assert calc.age_on(D(2026, 8, 14), D(1990, 8, 13)) == 36
def test_tdee_three_tier_preference() -> None:
bmr = 1780.0
measured = calc.tdee_effective(bmr, "sedentary", total_kcal=2600, active_kcal=400)
assert (measured.kcal, measured.method) == (2600.0, "measured_total")
# 1000 kcal is below the 0.8 x BMR plausibility guard -> fall through
partial = calc.tdee_effective(bmr, "sedentary", total_kcal=1000, active_kcal=400)
assert partial.method == "bmr_plus_active"
assert partial.kcal == pytest.approx(2180.0)
estimated = calc.tdee_effective(bmr, "moderate")
assert estimated.method == "estimated"
assert estimated.kcal == pytest.approx(1780.0 * 1.55)
def test_activity_factors_table() -> None:
assert calc.ACTIVITY_FACTORS == {
"sedentary": 1.2,
"light": 1.375,
"moderate": 1.55,
"active": 1.725,
"very_active": 1.9,
}
def test_moving_average_trailing_window_skips_none() -> None:
values = [None, 100.0, 200.0, None, 300.0]
assert calc.moving_average(values, 3) == [
None,
100.0,
150.0,
150.0,
250.0,
]
def test_daily_budget_deficit_and_floor() -> None:
rate = calc.GoalRate(0.5)
budget = calc.daily_budget(2400.0, rate, "male")
assert budget.deficit_target == pytest.approx(550.0) # 0.5 * 7700 / 7
assert budget.kcal == pytest.approx(1850.0)
assert budget.floor_applied is False
floored = calc.daily_budget(1800.0, calc.GoalRate(1.0), "male")
assert floored.kcal == pytest.approx(1500.0)
assert floored.floor_applied is True
female = calc.daily_budget(1500.0, calc.GoalRate(1.0), "female")
assert female.kcal == pytest.approx(1200.0)
custom = calc.daily_budget(1500.0, calc.GoalRate(1.0), "male", 1400)
assert custom.kcal == pytest.approx(1400.0)
def test_daily_budget_surplus_when_rate_is_negative() -> None:
budget = calc.daily_budget(2400.0, calc.GoalRate(-0.25), "male")
assert budget.kcal == pytest.approx(2675.0)
def test_resolve_goal_rate_target_date_is_clamped() -> None:
rate = calc.resolve_goal_rate(
"target_date", D(2026, 8, 13), 92.0, 78.0, None, D(2026, 9, 13)
)
assert rate.weekly_rate_kg == pytest.approx(1.0)
assert rate.rate_clamped is True
steady = calc.resolve_goal_rate(
"target_date", D(2026, 8, 13), 92.0, 88.0, None, D(2026, 10, 22)
)
assert steady.rate_clamped is False
assert steady.weekly_rate_kg == pytest.approx(4.0 / 10.0)
assert (
calc.resolve_goal_rate(
"maintain", D(2026, 8, 13), 92.0, 78.0, 0.5, None
).weekly_rate_kg
== 0.0
)
assert calc.resolve_goal_rate(
"weekly_rate", D(2026, 8, 13), 92.0, 78.0, 0.75, None
).weekly_rate_kg == pytest.approx(0.75)
# --- Weight trend / slope / projection ----------------------------------------
def test_weight_trend_ema_alpha_and_gap_correction() -> None:
trend = calc.weight_trend([(D(2026, 8, 1), 92.0), (D(2026, 8, 2), 91.0)])
assert trend[0] == (D(2026, 8, 1), 92.0)
assert trend[1][1] == pytest.approx(91.9) # 92 + 0.1 * (91 - 92)
gapped = calc.weight_trend([(D(2026, 8, 1), 92.0), (D(2026, 8, 6), 90.0)])
# alpha_eff = 1 - 0.9**5 = 0.40951 -> 92 - 0.40951 * 2 = 91.18098
assert gapped[1][1] == pytest.approx(91.18, abs=1e-2)
def test_weight_trend_empty_and_single_point() -> None:
assert calc.weight_trend([]) == []
assert calc.weight_trend([(D(2026, 8, 1), 88.5)]) == [(D(2026, 8, 1), 88.5)]
def test_regression_slope_exact_and_guards() -> None:
points = [(D(2026, 8, 1), 90.0), (D(2026, 8, 2), 89.0), (D(2026, 8, 3), 88.0)]
assert calc.regression_slope(points) == pytest.approx(-1.0)
assert calc.regression_slope(points[:2]) is None
flat = [(D(2026, 8, 1), 90.0)] * 3
assert calc.regression_slope(flat) is None # denom == 0
def test_trend_at_day_carries_forward() -> None:
trend = [(D(2026, 8, 1), 92.0), (D(2026, 8, 5), 91.0)]
assert calc.trend_at_day(trend, D(2026, 7, 31)) is None
assert calc.trend_at_day(trend, D(2026, 8, 3)) == 92.0
assert calc.trend_at_day(trend, D(2026, 8, 9)) == 91.0
def test_projection_ok_reached_and_not_converging() -> None:
today = D(2026, 8, 13)
ok = calc.project_target_date(90.0, 85.0, -0.05, today)
assert ok.status == "ok"
assert ok.date == today + dt.timedelta(days=100)
assert calc.project_target_date(85.05, 85.0, -0.05, today).status == "reached"
# slope below MIN_SLOPE_KG_PER_DAY
assert (
calc.project_target_date(90.0, 85.0, -0.001, today).status == "not_converging"
)
# gaining while a loss is needed
assert calc.project_target_date(90.0, 85.0, 0.05, today).status == "not_converging"
assert calc.project_target_date(90.0, 85.0, None, today).status == "not_converging"
# more than 10 years away
assert (
calc.project_target_date(90.0, 85.0, -0.001_2, today).status == "not_converging"
)
def test_projection_upwards_when_gaining_is_the_goal() -> None:
today = D(2026, 8, 13)
result = calc.project_target_date(70.0, 75.0, 0.05, today)
assert result.status == "ok"
assert result.date == today + dt.timedelta(days=100)
# --- Energy balance & calibration ---------------------------------------------
def test_energy_balance_is_none_on_untracked_days() -> None:
assert calc.energy_balance(1800.0, 2300.0) == pytest.approx(-500.0)
assert calc.energy_balance(None, 2300.0) is None
assert calc.energy_balance(1800.0, None) is None
def test_tdee_calibration_requires_21_tracked_days() -> None:
short = calc.tdee_calibration([(2000.0, 2500.0)] * 20, 92.0, 91.0)
assert short.status == "insufficient_data"
assert short.tracked_days == 20
def test_tdee_calibration_exact_values() -> None:
result = calc.tdee_calibration([(2000.0, 2500.0)] * 21, 92.0, 91.0)
assert result.status == "ok"
assert result.tracked_days == 21
assert result.expected_change_kg == pytest.approx(21 * -500 / 7700)
assert result.actual_change_kg == pytest.approx(-1.0)
assert result.gap_kg == pytest.approx(-1.0 - (21 * -500 / 7700))
assert result.tdee_adaptive_kcal == pytest.approx(2000 + 7700 / 21)
assert result.tdee_correction_kcal == pytest.approx(2000 + 7700 / 21 - 2500)
def test_constants_match_the_spec() -> None:
assert calc.KCAL_PER_KG_FAT == 7700
assert calc.EMA_ALPHA == 0.1
assert calc.MIN_SLOPE_KG_PER_DAY == 0.005
assert calc.CALORIE_FLOOR_MALE == 1500
assert calc.CALORIE_FLOOR_FEMALE == 1200
assert calc.WORKOUT_OVERLAP_THRESHOLD == 0.8
assert calc.ADAPTIVE_TDEE_MIN_DAYS == 21
# --- Activity merge & workout overlap -----------------------------------------
@dataclass
class Row:
date: dt.date
source: str
steps: int | None = None
active_kcal: float | None = None
total_kcal: float | None = None
distance_m: int | None = None
active_minutes: int | None = None
floors: int | None = None
def test_merge_activity_is_field_by_field_by_priority() -> None:
day = D(2026, 8, 12)
merged = calc.merge_activity_day(
[
Row(day, "csv_import", steps=5000, distance_m=3000),
Row(day, "health_connect", steps=9421, active_kcal=520.0),
Row(day, "manual", steps=10000),
]
)
assert merged.steps == 10000 # manual wins
assert merged.active_kcal == pytest.approx(520.0) # only health_connect has it
assert merged.distance_m == 3000 # only csv_import has it
assert merged.field_sources == {
"steps": "manual",
"active_kcal": "health_connect",
"distance_m": "csv_import",
}
def test_merge_puts_unknown_sources_last() -> None:
day = D(2026, 8, 12)
merged = calc.merge_activity_day(
[Row(day, "some_bridge", steps=1), Row(day, "api", steps=2)]
)
assert merged.steps == 2
assert calc.source_priority("manual") < calc.source_priority("health_connect")
assert calc.source_priority("api") < calc.source_priority("unknown")
def test_overlap_ratio_uses_the_shorter_session() -> None:
base = dt.datetime(2026, 8, 12, 18, 0, tzinfo=dt.UTC)
a_end = base + dt.timedelta(minutes=60)
b_start = base + dt.timedelta(minutes=5)
b_end = base + dt.timedelta(minutes=55)
assert calc.overlap_ratio(base, a_end, b_start, b_end) == pytest.approx(1.0)
far = base + dt.timedelta(hours=5)
assert calc.overlap_ratio(base, a_end, far, far + dt.timedelta(hours=1)) == 0.0
# --- Body composition ---------------------------------------------------------
def test_bmi_and_navy_body_fat() -> None:
assert calc.bmi(80.0, 180.0) == pytest.approx(24.69, abs=1e-2)
assert calc.bmi(80.0, 0) is None
male = calc.navy_body_fat_pct("male", 180.0, 90.0, 38.0)
assert male == pytest.approx(19.8, abs=0.2)
assert calc.navy_body_fat_pct("female", 165.0, 75.0, 32.0) is None # hips missing
assert calc.navy_body_fat_pct("female", 165.0, 75.0, 32.0, 98.0) is not None
assert calc.navy_body_fat_pct("male", 180.0, None, 38.0) is None
# --- Planning: schedules, adherence, streaks ----------------------------------
def test_schedule_kinds_are_the_three_habits() -> None:
assert calc.SCHEDULE_KINDS == ("weigh_in", "workout", "food_log")
def test_is_planned_uses_monday_zero_convention() -> None:
monday = D(2026, 7, 6)
assert monday.weekday() == 0
assert calc.is_planned([0, 2, 4], monday) is True
assert calc.is_planned([1, 3], monday) is False
assert calc.is_planned([0], monday, enabled=False) is False
assert calc.is_planned([], monday) is False
assert calc.is_planned(None, monday) is False
def test_build_adherence_marks_planned_done_and_missed() -> None:
start, end = D(2026, 7, 6), D(2026, 7, 12) # a full Monday-Sunday week
days = calc.build_adherence(start, end, [0, 2], True, {D(2026, 7, 6)})
assert len(days) == 7
statuses = {day.day: day.status for day in days}
assert statuses[D(2026, 7, 6)] == "done" # Monday planned + done
assert statuses[D(2026, 7, 8)] == "missed" # Wednesday planned, not done
assert statuses[D(2026, 7, 7)] == "rest"
assert calc.adherence_pct(days) == pytest.approx(50.0)
def test_adherence_pct_is_none_without_planned_days() -> None:
days = calc.build_adherence(D(2026, 7, 6), D(2026, 7, 12), [], True, set())
assert calc.adherence_pct(days) is None
def test_streaks_count_planned_days_only_across_weeks() -> None:
start, end = D(2026, 7, 6), D(2026, 8, 3) # 5 Mondays
mondays = [
D(2026, 7, 6),
D(2026, 7, 13),
D(2026, 7, 20),
D(2026, 7, 27),
D(2026, 8, 3),
]
assert all(day.weekday() == 0 for day in mondays)
done = {mondays[0], mondays[1], mondays[3], mondays[4]} # 07-20 missed
days = calc.build_adherence(start, end, [0], True, done)
streak = calc.compute_streaks(days, today=end)
assert streak.best == 2
assert streak.current == 2
def test_streak_grace_period_on_today() -> None:
mondays = [
D(2026, 7, 6),
D(2026, 7, 13),
D(2026, 7, 20),
D(2026, 7, 27),
D(2026, 8, 3),
]
days = calc.build_adherence(
D(2026, 7, 6), D(2026, 8, 3), [0], True, set(mondays[:-1])
)
# Today is a planned day that is not done yet: the streak is not broken.
streak = calc.compute_streaks(days, today=D(2026, 8, 3))
assert streak.current == 4
assert streak.best == 4
# One day later the missed Monday does break it.
assert calc.compute_streaks(days, today=D(2026, 8, 4)).current == 0
def test_streaks_ignore_unplanned_done_days() -> None:
days = calc.build_adherence(
D(2026, 7, 6),
D(2026, 7, 12),
[0],
True,
{D(2026, 7, 7), D(2026, 7, 8)}, # done on two unplanned days
)
streak = calc.compute_streaks(days, today=D(2026, 7, 12))
assert streak.best == 0
assert streak.current == 0
def test_date_range_is_inclusive_and_safe() -> None:
assert calc.date_range(D(2026, 8, 1), D(2026, 8, 3)) == [
D(2026, 8, 1),
D(2026, 8, 2),
D(2026, 8, 3),
]
assert calc.date_range(D(2026, 8, 3), D(2026, 8, 1)) == []