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>
317 lines
12 KiB
Python
317 lines
12 KiB
Python
"""Unit tests of the pure vape calculations (datamodel-health-vape.md §7)."""
|
||
|
||
from datetime import UTC, date, datetime, timedelta
|
||
from decimal import Decimal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pytest
|
||
|
||
from app.modules.vape import calculations as calc
|
||
|
||
PARIS = ZoneInfo("Europe/Paris")
|
||
|
||
|
||
def _component(
|
||
quantity: str, price_cents: int, size_value: str, **kwargs: object
|
||
) -> calc.ComponentInput:
|
||
return calc.ComponentInput(
|
||
quantity=Decimal(quantity),
|
||
price_cents=price_cents,
|
||
size_value=Decimal(size_value),
|
||
**kwargs, # type: ignore[arg-type]
|
||
)
|
||
|
||
|
||
# --- Recipe costing (§6.3, §7.1) -------------------------------------------
|
||
|
||
|
||
def _recipe() -> list[calc.ComponentInput]:
|
||
"""260 ml at 6 mg/ml: 156 ml base + 78 ml booster (20 mg/ml) + 26 ml aroma."""
|
||
return [
|
||
# 1 L base bottle at 12,00 € -> 1.2 cents/ml
|
||
_component("156", 1200, "1000", vg_pct=Decimal(50)),
|
||
# 10 ml booster at 0,90 € -> 9 cents/ml
|
||
_component("78", 90, "10", nicotine_mg_ml=Decimal(20), vg_pct=Decimal(50)),
|
||
# 30 ml aroma at 6,00 € -> 20 cents/ml
|
||
_component("26", 600, "30", vg_pct=Decimal(0)),
|
||
]
|
||
|
||
|
||
def test_mix_cost_total_and_per_ml_are_exact() -> None:
|
||
components = _recipe()
|
||
# 156×1.2 + 78×9 + 26×20 = 187.2 + 702 + 520 = 1409.2 cents
|
||
assert calc.mix_cost_total_cents(components) == Decimal("1409.2")
|
||
per_ml = calc.mix_cost_per_ml_cents(components, Decimal(260))
|
||
assert per_ml == Decimal("1409.2") / Decimal(260)
|
||
assert per_ml is not None
|
||
assert round(float(per_ml), 4) == 5.4200
|
||
|
||
|
||
def test_mix_cost_per_ml_is_none_without_volume() -> None:
|
||
assert calc.mix_cost_per_ml_cents(_recipe(), Decimal(0)) is None
|
||
|
||
|
||
def test_nicotine_check_matches_target_and_warns_when_off() -> None:
|
||
components = _recipe()
|
||
check = calc.mix_nicotine_check_mg_ml(components, Decimal(260))
|
||
assert check == Decimal(6) # 78 ml × 20 mg/ml / 260 ml
|
||
assert calc.nicotine_mismatch_warning(Decimal(6), check) is None
|
||
assert calc.nicotine_mismatch_warning(Decimal(3), check) == "nicotine_mismatch"
|
||
# 10 % tolerance boundary: 6.6 for a 6 mg/ml target is still accepted
|
||
assert calc.nicotine_mismatch_warning(Decimal(6), Decimal("6.6")) is None
|
||
assert (
|
||
calc.nicotine_mismatch_warning(Decimal(6), Decimal("6.7"))
|
||
== "nicotine_mismatch"
|
||
)
|
||
|
||
|
||
def test_mix_vg_pct_needs_every_component() -> None:
|
||
components = _recipe()
|
||
vg = calc.mix_vg_pct(components, Decimal(260))
|
||
assert vg == (Decimal(156) * 50 + Decimal(78) * 50) / Decimal(260)
|
||
partial = [*components[:2], _component("26", 600, "30")]
|
||
assert calc.mix_vg_pct(partial, Decimal(260)) is None
|
||
|
||
|
||
def test_recipe_quantities_assistant() -> None:
|
||
result = calc.recipe_quantities(Decimal(260), Decimal(6), Decimal(20), Decimal(10))
|
||
assert result.booster_ml == Decimal(78)
|
||
assert result.aroma_ml == Decimal(26)
|
||
assert result.base_ml == Decimal(156)
|
||
|
||
|
||
def test_recipe_quantities_rejects_impossible_recipes() -> None:
|
||
with pytest.raises(ValueError, match="base volume"):
|
||
calc.recipe_quantities(Decimal(100), Decimal(18), Decimal(20), Decimal(20))
|
||
with pytest.raises(ValueError, match="booster nicotine"):
|
||
calc.recipe_quantities(Decimal(100), Decimal(6), Decimal(0), Decimal(10))
|
||
|
||
|
||
def test_fallback_cost_per_ml_is_volume_weighted() -> None:
|
||
pairs = [(Decimal(1200), Decimal(1000)), (Decimal(540), Decimal(60))]
|
||
assert calc.fallback_cost_per_ml_cents(pairs) == Decimal(1740) / Decimal(1060)
|
||
assert calc.fallback_cost_per_ml_cents([]) is None
|
||
|
||
|
||
# --- Daily consumption (§6.4, §7.2) ----------------------------------------
|
||
|
||
|
||
def test_daily_total_overrides_refills() -> None:
|
||
entries = [
|
||
("refill", Decimal(4)),
|
||
("refill", Decimal(3)),
|
||
("daily_total", Decimal(9)),
|
||
]
|
||
assert calc.effective_daily_ml(entries) == Decimal(9)
|
||
|
||
|
||
def test_refills_are_summed_and_untracked_day_is_none() -> None:
|
||
entries = [("refill", Decimal(4)), ("refill", Decimal("3.5"))]
|
||
assert calc.effective_daily_ml(entries) == Decimal("7.5")
|
||
assert calc.effective_daily_ml([]) is None
|
||
|
||
|
||
def test_mean_and_tracked_ratio_ignore_untracked_days() -> None:
|
||
values = [Decimal(4), None, Decimal(6), None]
|
||
assert calc.mean_ml_per_day(values) == Decimal(5)
|
||
assert calc.tracked_days_ratio(values) == 0.5
|
||
assert calc.mean_ml_per_day([None, None]) is None
|
||
|
||
|
||
def test_sum_ml_between_excludes_the_end_day() -> None:
|
||
daily = {
|
||
date(2026, 8, 1): Decimal(4),
|
||
date(2026, 8, 2): Decimal(5),
|
||
date(2026, 8, 3): Decimal(6),
|
||
}
|
||
assert calc.sum_ml_between(daily, date(2026, 8, 1), date(2026, 8, 3)) == Decimal(9)
|
||
assert calc.sum_ml_between(daily, date(2026, 9, 1), date(2026, 9, 5)) is None
|
||
|
||
|
||
# --- Coils (§7.3) ----------------------------------------------------------
|
||
|
||
|
||
def _dt(day: int) -> datetime:
|
||
return datetime(2026, 1, day, 12, 0, tzinfo=UTC)
|
||
|
||
|
||
def test_coil_intervals_and_average_of_last_five_cycles() -> None:
|
||
# changes every 10, 12, 14, 16, 18 and 20 days
|
||
starts = [0, 10, 22, 36, 52, 70, 90]
|
||
changed = [_dt(1) + timedelta(days=offset) for offset in starts]
|
||
intervals = calc.coil_intervals_days(changed)
|
||
assert intervals == [10.0, 12.0, 14.0, 16.0, 18.0, 20.0]
|
||
avg, is_default = calc.avg_coil_lifespan_days(intervals)
|
||
assert avg == 16.0 # mean of the last five: 12, 14, 16, 18, 20
|
||
assert is_default is False
|
||
|
||
|
||
def test_coil_average_falls_back_to_default_without_two_changes() -> None:
|
||
avg, is_default = calc.avg_coil_lifespan_days([])
|
||
assert avg == calc.DEFAULT_COIL_LIFESPAN_DAYS == 14.0
|
||
assert is_default is True
|
||
|
||
|
||
def test_coil_cost_per_day_amortization() -> None:
|
||
# 19,50 € box of 5 coils -> 390 cents each, 16-day average lifespan
|
||
assert calc.coil_cost_per_day_cents(Decimal(390), 16.0) == 24.375
|
||
assert calc.coil_cost_per_day_cents(None, 16.0) == 0.0
|
||
assert calc.coil_cost_per_day_cents(390, 0.0) == 0.0
|
||
|
||
|
||
# --- Daily cost and cigarette baseline (§7.4, §7.5) ------------------------
|
||
|
||
|
||
def test_vape_cost_per_day_combines_liquid_and_coil() -> None:
|
||
assert calc.vape_cost_per_day_cents(Decimal(4), Decimal(5), 24.375) == 44.375
|
||
assert calc.vape_cost_per_day_cents(Decimal(4), None, 24.375) is None
|
||
assert calc.vape_cost_per_day_cents(None, Decimal(5), 24.375) is None
|
||
|
||
|
||
def test_cig_baseline_per_day() -> None:
|
||
# 15 cig/day, 20 per pack, 12,50 € pack -> 9,375 €/day
|
||
assert calc.cig_cost_per_day_cents(Decimal(15), 20, 1250) == 937.5
|
||
assert calc.savings_per_day_cents(937.5, 44.375) == 893.125
|
||
assert calc.savings_per_day_cents(937.5, None) is None
|
||
|
||
|
||
def test_theoretical_cost_imputes_untracked_days() -> None:
|
||
tracked = calc.theoretical_vape_cost_cents(Decimal(4), Decimal(5), Decimal(5), 10.0)
|
||
assert tracked == 30.0
|
||
imputed = calc.theoretical_vape_cost_cents(None, Decimal(5), Decimal(5), 10.0)
|
||
assert imputed == 35.0 # mean-imputed 5 ml × 5 cents + 10 cents of coil
|
||
|
||
|
||
def test_cumulative_theoretical_savings_with_imputation() -> None:
|
||
quit_date = date(2026, 1, 1)
|
||
today = date(2026, 1, 5)
|
||
daily = {date(2026, 1, 1): Decimal(4), date(2026, 1, 3): Decimal(6)}
|
||
series = calc.cumulative_savings_theoretical(
|
||
quit_date,
|
||
today,
|
||
daily,
|
||
imputed_ml_per_day=Decimal(5),
|
||
cost_per_ml_cents=Decimal(5),
|
||
coil_cpd_cents=0.0,
|
||
cig_cpd_cents=100.0,
|
||
)
|
||
# vape costs: 20, 25, 30, 25, 25 (untracked days imputed at 5 ml)
|
||
assert [round(value, 3) for _, value in series] == [
|
||
-20.0,
|
||
55.0,
|
||
125.0,
|
||
200.0,
|
||
275.0,
|
||
]
|
||
assert [day for day, _ in series] == [date(2026, 1, d) for d in range(1, 6)]
|
||
|
||
|
||
def test_cumulative_real_savings_uses_purchases() -> None:
|
||
quit_date = date(2026, 1, 1)
|
||
spend = {date(2026, 1, 2): 300.0}
|
||
series = calc.cumulative_savings_real(
|
||
quit_date, date(2026, 1, 4), spend, cig_cpd_cents=100.0
|
||
)
|
||
assert [round(value, 3) for _, value in series] == [0.0, -200.0, -100.0, 0.0]
|
||
|
||
|
||
def test_savings_series_is_empty_before_the_quit_date() -> None:
|
||
assert (
|
||
calc.cumulative_savings_real(date(2026, 2, 1), date(2026, 1, 1), {}, 100.0)
|
||
== []
|
||
)
|
||
|
||
|
||
def test_real_cost_per_day() -> None:
|
||
assert calc.real_cost_per_day_cents(3000.0, 30) == 100.0
|
||
assert calc.real_cost_per_day_cents(3000.0, 0) is None
|
||
|
||
|
||
# --- Nicotine and avoided cigarettes (§7.6, §7.7) --------------------------
|
||
|
||
|
||
def test_nicotine_of_a_day() -> None:
|
||
entries = [(Decimal(4), Decimal(6)), (Decimal(2), Decimal(3))]
|
||
assert calc.nicotine_mg_for_day(entries) == 30.0
|
||
assert calc.nicotine_mg_for_day([(Decimal(4), None)]) == 0.0
|
||
assert calc.nicotine_mg_for_day([]) is None
|
||
|
||
|
||
def test_cigarette_equivalent_and_counters() -> None:
|
||
assert calc.cig_equivalent(24.0) == 2.0
|
||
assert calc.days_since_quit(date(2026, 1, 1), date(2026, 1, 11)) == 10
|
||
assert calc.days_since_quit(date(2026, 2, 1), date(2026, 1, 11)) == 0
|
||
avoided = calc.cigarettes_avoided(10, Decimal("15.5"))
|
||
assert avoided == 155
|
||
assert calc.cigarettes_avoided(3, Decimal("15.5")) == 46 # floor(46.5)
|
||
assert calc.packs_avoided(155, 20) == 7.75
|
||
assert calc.time_regained_minutes(155) == 155 * 11
|
||
|
||
|
||
# --- Series helpers --------------------------------------------------------
|
||
|
||
|
||
def test_moving_average_skips_gaps() -> None:
|
||
values: list[float | None] = [3.0, None, 6.0, 9.0]
|
||
assert calc.moving_average(values, 3) == [3.0, 3.0, 4.5, 7.5]
|
||
assert calc.moving_average([None, None], 3) == [None, None]
|
||
|
||
|
||
def test_regression_slope_and_trend_status() -> None:
|
||
points = [(date(2026, 1, 1) + timedelta(days=i), 10.0 - i) for i in range(5)]
|
||
slope = calc.regression_slope(points)
|
||
assert slope == pytest.approx(-1.0)
|
||
assert calc.trend_status(slope) == "down"
|
||
assert calc.trend_status(0.0) == "stable"
|
||
assert calc.trend_status(None) == "stable"
|
||
assert calc.trend_status(0.2) == "up"
|
||
assert calc.regression_slope(points[:2]) is None
|
||
|
||
|
||
# --- Health milestones (§7.8) ---------------------------------------------
|
||
|
||
|
||
def test_twelve_milestones_from_twenty_minutes_to_fifteen_years() -> None:
|
||
assert len(calc.MILESTONES) == 12
|
||
assert calc.MILESTONES[0].offset == timedelta(minutes=20)
|
||
assert calc.MILESTONES[-1].offset == timedelta(days=15 * 365)
|
||
assert calc.MILESTONES[0].code == "hr_bp_normal"
|
||
assert calc.MILESTONES[-1].code == "chd_risk_normal"
|
||
assert all(m.label_fr and m.label_fr[0].isupper() for m in calc.MILESTONES)
|
||
|
||
|
||
def test_milestone_statuses_reached_and_pending() -> None:
|
||
quit_date = date(2026, 1, 1)
|
||
now = datetime(2026, 1, 20, 12, 0, tzinfo=UTC) # 19 days later
|
||
statuses = calc.milestone_statuses(quit_date, PARIS, now)
|
||
by_code = {status.code: status for status in statuses}
|
||
assert by_code["hr_bp_normal"].achieved is True
|
||
assert by_code["circulation"].achieved is True # 14 days
|
||
assert by_code["lung_function"].achieved is False # 90 days
|
||
assert by_code["lung_function"].progress_pct == pytest.approx(
|
||
100 * (19 + 23 / 24) / 90, abs=0.5
|
||
)
|
||
assert by_code["hr_bp_normal"].progress_pct == 100.0
|
||
# local midnight of the quit day, expressed in UTC (Paris = UTC+1 in January)
|
||
assert by_code["hr_bp_normal"].reached_at == datetime(
|
||
2025, 12, 31, 23, 20, tzinfo=UTC
|
||
)
|
||
|
||
|
||
def test_next_milestone_is_the_first_pending_one() -> None:
|
||
statuses = calc.milestone_statuses(
|
||
date(2026, 1, 1), PARIS, datetime(2026, 1, 20, 12, 0, tzinfo=UTC)
|
||
)
|
||
upcoming = calc.next_milestone(statuses)
|
||
assert upcoming is not None
|
||
assert upcoming.code == "lung_function"
|
||
assert calc.next_milestone([]) is None
|
||
|
||
|
||
def test_progress_is_zero_before_the_quit_date() -> None:
|
||
statuses = calc.milestone_statuses(
|
||
date(2026, 6, 1), PARIS, datetime(2026, 1, 1, tzinfo=UTC)
|
||
)
|
||
assert all(status.progress_pct == 0.0 for status in statuses)
|
||
assert all(status.achieved is False for status in statuses)
|