CI / ruff · pytest (pull_request) Successful in 2m21s
Refs #120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
5.7 KiB
Python
133 lines
5.7 KiB
Python
"""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 Alert, 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_honest_skip_grows_like_taken_and_missed_does_not(client: AsyncClient) -> None:
|
|
"""#120: the garden rewards an honest answer, never a «taken» mark — otherwise it would push to false marks."""
|
|
token = (await seed(12)).andrey_token
|
|
url = f"/api/v1/me/{token}/garden"
|
|
base = (await client.get(url)).json()["points"]
|
|
async with session.get_clinical_session() as s:
|
|
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
|
|
await s.delete(d)
|
|
none = (await client.get(url)).json()["points"]
|
|
|
|
async def with_dose(status: str) -> int:
|
|
async with session.get_clinical_session() as s:
|
|
for d in (await s.scalars(select(Dose).where(Dose.token == token))).all():
|
|
await s.delete(d)
|
|
s.add(Dose(token=token, prescription_id=1, planned_at=clock.now(), status=status, answered_at=clock.now()))
|
|
return (await client.get(url)).json()["points"]
|
|
|
|
assert await with_dose("taken") == await with_dose("skipped") > none
|
|
assert await with_dose("missed") == none
|
|
assert base >= none
|
|
|
|
|
|
async def test_quiet_after_recent_high_alert(client: AsyncClient) -> None:
|
|
"""#120: no celebration after a crisis signal from any source; an old or moderate alert does not silence it."""
|
|
token = (await seed(12)).andrey_token
|
|
url = f"/api/v1/me/{token}/garden"
|
|
async with session.get_clinical_session() as s:
|
|
for a in (await s.scalars(select(Alert).where(Alert.token == token))).all():
|
|
await s.delete(a)
|
|
assert (await client.get(url)).json()["quiet"] is False
|
|
|
|
def alert(level: str, hours_ago: int) -> Alert:
|
|
ts = clock.now() - timedelta(hours=hours_ago)
|
|
return Alert(token=token, ts=ts, level=level, code="test", reason="test", params={})
|
|
|
|
async with session.get_clinical_session() as s:
|
|
s.add_all([alert("moderate", 1), alert("acute", 24 * 10)])
|
|
assert (await client.get(url)).json()["quiet"] is False
|
|
async with session.get_clinical_session() as s:
|
|
s.add(alert("high", 2))
|
|
quiet = (await client.get(url)).json()
|
|
assert quiet["quiet"] is True and quiet["points"] > 0
|
|
|
|
|
|
async def test_unknown_patient_404(client: AsyncClient) -> None:
|
|
await seed(0)
|
|
assert (await client.get("/api/v1/me/nobody/garden")).status_code == 404
|