"""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