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>
365 lines
13 KiB
Python
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)) == []
|