Files
lifetrack/apps/api/app/tests/modules/test_vape_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

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)