"""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)) == []