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

281 lines
9.4 KiB
Python

"""Planning (addendum-planning.md) at service level: derived "done" days,
adherence over crafted weeks, streaks across week boundaries, timezone edges."""
import datetime as dt
from decimal import Decimal
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.modules.auth.models import User
from app.modules.health import service, stats
from app.modules.health.models import (
FoodEntry,
ScheduleKind,
TrackingSchedule,
WeightEntry,
Workout,
)
PARIS = ZoneInfo("Europe/Paris")
def utc_at(day: dt.date, hour: int, minute: int = 0) -> dt.datetime:
"""UTC instant for a Paris wall-clock time."""
return dt.datetime(
day.year, day.month, day.day, hour, minute, tzinfo=PARIS
).astimezone(dt.UTC)
def schedule(db: Session, user: User, kind: str, weekdays: list[int], enabled=True):
db.add(
TrackingSchedule(
user_id=user.id,
kind=ScheduleKind(kind),
weekdays=weekdays,
enabled=enabled,
)
)
db.commit()
def add_weight(db: Session, user: User, day: dt.date, kg: float, hour: int = 7) -> None:
db.add(
WeightEntry(
user_id=user.id,
source="manual",
measured_at=utc_at(day, hour),
weight_kg=Decimal(str(kg)),
)
)
db.commit()
def add_workout(db: Session, user: User, day: dt.date, hidden: bool = False) -> None:
start = utc_at(day, 18)
db.add(
Workout(
user_id=user.id,
source="manual",
started_at=start,
ended_at=start + dt.timedelta(hours=1),
sport_type="running",
is_hidden=hidden,
)
)
db.commit()
def add_food(
db: Session, user: User, day: dt.date, kcal: float, hour: int = 13
) -> None:
db.add(
FoodEntry(
user_id=user.id,
source="manual",
eaten_at=utc_at(day, hour),
meal="lunch",
name="Repas",
quantity=Decimal(100),
unit="g",
kcal=Decimal(str(kcal)),
)
)
db.commit()
# --- Derived "done" days -------------------------------------------------------
def test_done_days_are_derived_from_the_three_tables(db: Session, user: User) -> None:
day = dt.date(2026, 8, 12)
add_weight(db, user, day, 91.6)
add_weight(db, user, day, 92.4, hour=20) # second weigh-in of the day
add_workout(db, user, day)
add_food(db, user, day, 620.0)
add_food(db, user, day, 380.0)
done = service.done_days_by_kind(db, user.id, day, day, PARIS)
assert done["weigh_in"] == {day: 91.6} # FIRST weigh-in of the local day
assert done["workout"] == {day: 1.0}
assert done["food_log"] == {day: 1000.0}
def test_hidden_workouts_do_not_count_as_done(db: Session, user: User) -> None:
day = dt.date(2026, 8, 12)
add_workout(db, user, day, hidden=True)
done = service.done_days_by_kind(db, user.id, day, day, PARIS)
assert done["workout"] == {}
def test_local_day_boundary_uses_the_profile_timezone(db: Session, user: User) -> None:
# 22:30 UTC on 12/08 is 00:30 on 13/08 in Paris (CEST = UTC+2).
db.add(
WeightEntry(
user_id=user.id,
source="manual",
measured_at=dt.datetime(2026, 8, 12, 22, 30, tzinfo=dt.UTC),
weight_kg=Decimal("91.6"),
)
)
db.commit()
paris = service.done_days_by_kind(
db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), PARIS
)
assert dt.date(2026, 8, 13) in paris["weigh_in"]
assert dt.date(2026, 8, 12) not in paris["weigh_in"]
utc = service.done_days_by_kind(
db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), ZoneInfo("UTC")
)
assert dt.date(2026, 8, 12) in utc["weigh_in"]
# --- Schedules -----------------------------------------------------------------
def test_missing_schedules_are_returned_disabled(db: Session, user: User) -> None:
rows = service.list_schedules(db, user.id)
assert [row.kind.value for row in rows] == ["weigh_in", "workout", "food_log"]
assert all(row.enabled is False and row.weekdays == [] for row in rows)
def test_upsert_schedule_is_idempotent_per_kind(db: Session, user: User) -> None:
from app.modules.health.schemas import ScheduleUpdate
first = service.upsert_schedule(
db,
user.id,
ScheduleKind.WEIGH_IN,
ScheduleUpdate(weekdays=[4, 0], enabled=True),
)
assert first.weekdays == [0, 4]
second = service.upsert_schedule(
db, user.id, ScheduleKind.WEIGH_IN, ScheduleUpdate(weekdays=[2], enabled=False)
)
assert first.id == second.id
assert second.weekdays == [2]
assert second.enabled is False
# --- Adherence over crafted weeks ---------------------------------------------
def test_adherence_over_four_weeks_of_mondays(db: Session, user: User) -> None:
today = service.today_local(PARIS)
weekday = today.weekday()
schedule(db, user, "weigh_in", [weekday])
planned = [today - dt.timedelta(days=7 * n) for n in range(4)]
for day in planned[:3]: # the oldest planned day is missed
add_weight(db, user, day, 92.0)
body = stats.adherence_stats(db, user.id, planned[-1], today, PARIS, "weigh_in")
assert [kind.kind.value for kind in body.kinds] == ["weigh_in"]
weigh_in = body.kinds[0]
assert weigh_in.weekdays == [weekday]
assert weigh_in.planned_days == 4
assert weigh_in.done_days == 3
assert weigh_in.missed_days == 1
assert weigh_in.adherence_pct == 75.0
assert weigh_in.streak.current == 3
assert weigh_in.streak.best == 3
assert len(weigh_in.days) == 22 # inclusive range
statuses = {row.date: row.status for row in weigh_in.days}
assert statuses[today] == "done"
assert statuses[planned[-1]] == "missed"
assert statuses[today - dt.timedelta(days=1)] == "rest"
def test_streak_spans_week_boundaries_with_two_days_a_week(
db: Session, user: User
) -> None:
today = service.today_local(PARIS)
# Two consecutive weekdays: for a Sunday anchor the pair straddles two ISO weeks.
first_day, second_day = today.weekday(), (today.weekday() + 1) % 7
schedule(db, user, "workout", sorted({first_day, second_day}))
start = today - dt.timedelta(days=21)
for offset in range((today - start).days + 1):
day = start + dt.timedelta(days=offset)
if day.weekday() in {first_day, second_day}:
add_workout(db, user, day)
body = stats.adherence_stats(db, user.id, start, today, PARIS, "workout")
workout = body.kinds[0]
assert workout.planned_days == workout.done_days
assert workout.missed_days == 0
assert workout.adherence_pct == 100.0
assert workout.streak.current == workout.planned_days
assert workout.streak.best >= workout.planned_days
def test_adherence_without_schedule_reports_no_planned_day(
db: Session, user: User
) -> None:
today = service.today_local(PARIS)
add_weight(db, user, today, 92.0)
body = stats.adherence_stats(
db, user.id, today - dt.timedelta(days=6), today, PARIS
)
kinds = {kind.kind.value: kind for kind in body.kinds}
assert set(kinds) == {"weigh_in", "workout", "food_log"}
weigh_in = kinds["weigh_in"]
assert weigh_in.planned_days == 0
assert weigh_in.adherence_pct is None
assert weigh_in.streak.current == 0
# An unplanned weigh-in is still reported for the heatmap.
statuses = {row.date: row.status for row in weigh_in.days}
assert statuses[today] == "done_unplanned"
def test_disabled_schedule_plans_nothing(db: Session, user: User) -> None:
today = service.today_local(PARIS)
schedule(db, user, "weigh_in", [today.weekday()], enabled=False)
body = stats.adherence_stats(
db, user.id, today - dt.timedelta(days=6), today, PARIS, "weigh_in"
)
assert body.kinds[0].planned_days == 0
assert body.kinds[0].enabled is False
# --- Today ---------------------------------------------------------------------
def test_today_reports_planned_done_and_values(db: Session, user: User) -> None:
today = service.today_local(PARIS)
weekday = today.weekday()
schedule(db, user, "weigh_in", [weekday])
schedule(db, user, "workout", [(weekday + 3) % 7]) # not today
schedule(db, user, "food_log", [weekday])
add_weight(db, user, today, 91.6)
add_food(db, user, today, 620.0)
body = stats.today_view(db, user.id, PARIS)
assert body.date == today
items = {item.kind.value: item for item in body.items}
assert items["weigh_in"].planned is True
assert items["weigh_in"].done is True
assert items["weigh_in"].value == 91.6
assert items["food_log"].value == 620.0
assert items["workout"].planned is False
assert items["workout"].done is False
assert items["workout"].value is None
assert body.streaks["weigh_in"].current == 1
assert body.streaks["workout"].current == 0
def test_today_streak_survives_a_planned_but_unfinished_day(
db: Session, user: User
) -> None:
today = service.today_local(PARIS)
schedule(db, user, "weigh_in", [today.weekday()])
for offset in (7, 14):
add_weight(db, user, today - dt.timedelta(days=offset), 92.0)
body = stats.today_view(db, user.id, PARIS)
items = {item.kind.value: item for item in body.items}
assert items["weigh_in"].planned is True
assert items["weigh_in"].done is False
# Today is not over yet: the two previous planned days still count.
assert body.streaks["weigh_in"].current == 2
assert body.streaks["weigh_in"].best == 2