feat(backend): patient garden — deterministic growth from activities
CI / ruff · pytest (pull_request) Successful in 2m17s
CI / ruff · pytest (pull_request) Successful in 2m17s
GET /api/v1/me/{token}/garden computes the garden on the fly from taken doses,
check-ins/diary and finished questionnaires (no new table). Rules and texts in
config/garden.yaml: per-day caps, points only add up, answers never count.
Refs #118
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""Garden (#118): deterministic growth from activities, daily caps, never shrinks, ignores answers."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import clock
|
||||
from app.db import session
|
||||
from app.db.clinical import DailyCheckin, Dose
|
||||
from app.main import app
|
||||
from app.services.garden import compute, load_config
|
||||
from scripts.seed_demo import seed
|
||||
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
NOW = datetime(2026, 10, 5, 14, 0)
|
||||
D = date(2026, 10, 5)
|
||||
|
||||
|
||||
def test_points_steps_and_daily_caps() -> None:
|
||||
cfg = load_config()
|
||||
one_diary = compute([("diary", D)], D)
|
||||
assert one_diary.points == cfg.activities["diary"].points
|
||||
# Five diary entries on one day count as one; on five days — five times.
|
||||
assert compute([("diary", D)] * 5, D).points == one_diary.points
|
||||
five_days = compute([("diary", D - timedelta(days=i)) for i in range(5)], D)
|
||||
assert five_days.points == 5 * one_diary.points
|
||||
assert five_days.steps == five_days.points // cfg.step_points
|
||||
assert five_days.points_in_step == five_days.points % cfg.step_points
|
||||
|
||||
|
||||
def test_never_shrinks_and_future_ignored() -> None:
|
||||
events = [("meds", D), ("diary", D), ("questionnaire", D)]
|
||||
now = compute(events, D)
|
||||
# A week without any activity takes nothing away.
|
||||
assert compute(events, D + timedelta(days=7)).points == now.points
|
||||
# Rows dated after the (demo) today are not counted yet.
|
||||
assert compute([*events, ("diary", D + timedelta(days=1))], D).points == now.points
|
||||
|
||||
|
||||
def test_plants_grow_one_after_another() -> None:
|
||||
cfg = load_config()
|
||||
per_plant = cfg.stages_per_plant
|
||||
g = compute([("questionnaire", D - timedelta(days=i)) for i in range(100)], D)
|
||||
stages = [p.stage for p in g.plants]
|
||||
assert stages == sorted(stages, reverse=True) and stages[0] == per_plant
|
||||
assert sum(stages) == min(g.steps, per_plant * cfg.max_plants)
|
||||
assert compute([], D).title == cfg.titles[0].title
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
|
||||
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
|
||||
clock.set_now(NOW)
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
await session.configure()
|
||||
|
||||
|
||||
async def test_andrey_has_a_living_garden_that_grows_and_ignores_mood(client: AsyncClient) -> None:
|
||||
token = (await seed(12)).andrey_token
|
||||
url = f"/api/v1/me/{token}/garden"
|
||||
before = (await client.get(url)).json()
|
||||
assert before["steps"] >= 4 and before["plants"][0]["stage"] > 0
|
||||
|
||||
# Mood is an answer: a bad day and a good day grow the garden the same way.
|
||||
async with session.get_clinical_session() as s:
|
||||
rows = (await s.scalars(select(DailyCheckin).where(DailyCheckin.token == token))).all()
|
||||
for r in rows:
|
||||
r.mood = 1
|
||||
assert (await client.get(url)).json()["points"] == before["points"]
|
||||
|
||||
# Taking a dose today adds points.
|
||||
async with session.get_clinical_session() as s:
|
||||
dose = Dose(token=token, prescription_id=1, planned_at=clock.now(), status="taken", answered_at=clock.now())
|
||||
s.add(dose)
|
||||
after = (await client.get(url)).json()
|
||||
assert after["points"] >= before["points"]
|
||||
assert any(i["activity"] == "diary" for i in after["today"])
|
||||
|
||||
|
||||
async def test_unknown_patient_404(client: AsyncClient) -> None:
|
||||
await seed(0)
|
||||
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
|
||||
Reference in New Issue
Block a user