fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
CI / ruff · pytest (pull_request) Successful in 1m19s
CI / ruff · pytest (pull_request) Successful in 1m19s
- No same-day C-SSRS repeat: a risk screen completed within cssrs_cooldown_hours is not queued again after PHQ-9 item 9 >= 1 (scale crisis path, rule schedule_scale, scale_due); crisis script and alerts stay. - Scale intro registry: register_scale_intro(scale_id, text_or_fn); start_scale(intro=None) uses it. veteran_daily migrated from per-scale on_enter hooks. - Prefix triggers "button:<prefix>:*" with trigger_payload(event); medication buttons carry the dose (med:taken:<dose_id>, ...), med_pending dropped; payload-less legacy buttons answer as stale. - Abandoned-step timeout: settings.abandon_after_hours (default 6 h) returns abandonable states (daily.*, meds.*, scale.* not in onboarding) to idle before the next event; never in crisis or with intake on. Refs #56 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
"""Engine gaps found by the 13-day run (#56): no same-day C-SSRS repeat, scale intro registry, prefix triggers
|
||||
with a button payload (medication buttons carry the dose), abandoned-step timeout."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import clock
|
||||
from app.core.rules import Action
|
||||
from app.core.scales import load_scale
|
||||
from app.db import repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import Dose, ItemResponse, Prescription
|
||||
from app.dialog import engine as engine_module
|
||||
from app.dialog import fsm, scale_session
|
||||
from app.dialog.contracts import Event, Reply
|
||||
from app.dialog.engine import STEP_AT, Engine
|
||||
from app.dialog.flows.meds import load_meds_texts
|
||||
from app.dialog.flows.veteran_daily import load_daily_texts
|
||||
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, trigger, trigger_payload
|
||||
from app.dialog.texts import load_dialog_texts
|
||||
from app.llm.gateway import LLMGateway
|
||||
from app.llm.mock import MockTasks
|
||||
|
||||
TOKEN = "g" * 32
|
||||
OTHER = "h" * 32
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
NOW = datetime(2026, 10, 7, 9, 0)
|
||||
TEXTS = load_dialog_texts()
|
||||
MEDS = load_meds_texts()
|
||||
DAILY = load_daily_texts()
|
||||
SCREEN = TEXTS.settings.risk_screen_scale
|
||||
|
||||
|
||||
class ScriptedMonitoring:
|
||||
def __init__(self) -> None:
|
||||
self.actions: list[Action] = []
|
||||
|
||||
async def after_data_change(self, token: str) -> list[Action]:
|
||||
out, self.actions = self.actions, []
|
||||
return out
|
||||
|
||||
async def is_in_crisis(self, token: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
|
||||
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)
|
||||
await init_db()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(
|
||||
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
|
||||
)
|
||||
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
|
||||
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
|
||||
yield ScriptedMonitoring()
|
||||
clock.reset()
|
||||
await session.configure()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mon: ScriptedMonitoring) -> Engine:
|
||||
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
|
||||
|
||||
|
||||
async def press(e: Engine, data: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="button", button=data))
|
||||
|
||||
|
||||
async def text(e: Engine, t: str) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="text", text=t))
|
||||
|
||||
|
||||
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
|
||||
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def answer(e: Engine, scale_id: str, values: list[int]) -> list[Reply]:
|
||||
out: list[Reply] = []
|
||||
for item, value in zip(load_scale(scale_id).items, values, strict=False):
|
||||
out += await press(e, f"scale:{scale_id}:{item.id}:{value}")
|
||||
return out
|
||||
|
||||
|
||||
def buttons(replies: list[Reply]) -> list[str]:
|
||||
return [b.data for r in replies for row in r.buttons for b in row]
|
||||
|
||||
|
||||
async def get_state() -> tuple[str, dict[str, Any]]:
|
||||
async with session.get_clinical_session() as s:
|
||||
row = await repo.get_dialog_state(s, TOKEN)
|
||||
assert row is not None
|
||||
return row.state, dict(row.data)
|
||||
|
||||
|
||||
async def results(scale: str) -> list[Any]:
|
||||
async with session.get_clinical_session() as s:
|
||||
return list(await repo.list_scale_results(s, TOKEN, scale))
|
||||
|
||||
|
||||
async def checkin() -> Any:
|
||||
async with session.get_clinical_session() as s:
|
||||
rows = await repo.list_checkins(s, TOKEN)
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
async def doses() -> list[Dose]:
|
||||
async with session.get_clinical_session() as s:
|
||||
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 1. C-SSRS repeat
|
||||
|
||||
|
||||
async def test_item9_right_after_cssrs_does_not_repeat_screen(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
assert (await get_state())[0] == f"scale.{SCREEN}"
|
||||
await answer(engine, SCREEN, [0] * 6)
|
||||
clock.advance(hours=1)
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
# The rule phq9_item9 also asks for the screen — neither path may repeat it within the cooldown.
|
||||
mon.actions = [Action(type="schedule_scale", code="phq9_item9", params={"scale": SCREEN, "scheduled": False})]
|
||||
r = await answer(engine, "phq9", [1] * 8 + [1])
|
||||
assert any(x.kind == "crisis" for x in r) # the fixed crisis script still goes out
|
||||
state, data = await get_state()
|
||||
assert state == "crisis" and data["crisis"]["open"] is True and not data.get("pending_scales")
|
||||
assert len(await results(SCREEN)) == 1
|
||||
|
||||
|
||||
async def test_item9_after_old_cssrs_asks_screen_again(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
await answer(engine, SCREEN, [0] * 6)
|
||||
clock.advance(hours=TEXTS.settings.cssrs_cooldown_hours + 1)
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
r = await answer(engine, "phq9", [1] * 8 + [1])
|
||||
assert any(x.kind == "crisis" for x in r)
|
||||
assert (await get_state())[0] == f"scale.{SCREEN}" and TEXTS.scale.risk_screen_intro in r[-1].text
|
||||
|
||||
|
||||
async def test_unfinished_cssrs_does_not_count(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
|
||||
await answer(engine, SCREEN, [0, 0]) # left halfway: no complete result
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False) # queued behind the screen
|
||||
async with session.get_clinical_session() as s:
|
||||
assert not await scale_session.recent_risk_screen(_ctx(s), SCREEN)
|
||||
|
||||
|
||||
def _ctx(s: Any, state: str = "idle", data: dict[str, Any] | None = None) -> Ctx:
|
||||
return Ctx(
|
||||
token=TOKEN,
|
||||
state=state,
|
||||
data=data or {},
|
||||
now=clock.now(),
|
||||
session=s,
|
||||
gateway=LLMGateway(MockTasks()),
|
||||
monitoring=ScriptedMonitoring(),
|
||||
texts=TEXTS,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 2. intros
|
||||
|
||||
|
||||
async def test_registered_intro_text_and_callable(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: list[tuple[str, bool, str]] = []
|
||||
|
||||
def intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
|
||||
seen.append((scale_id, scheduled, return_state))
|
||||
return "Вступление к GAD-7"
|
||||
|
||||
monkeypatch.setattr(scale_session, "_intros", dict(scale_session._intros))
|
||||
scale_session.register_scale_intro("gad7", intro)
|
||||
with pytest.raises(ValueError):
|
||||
scale_session.register_scale_intro("gad7", "второе")
|
||||
r = await system(engine, "scale_due", scale="gad7", scheduled=True)
|
||||
assert r[0].text.startswith("Вступление к GAD-7") and seen == [("gad7", True, "idle")]
|
||||
|
||||
scale_session.register_scale_intro("gad7", "Текстовое вступление", replace=True)
|
||||
async with session.get_clinical_session() as s:
|
||||
step = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle")
|
||||
assert step.data["scales"]["gad7"]["intro"] == "Текстовое вступление"
|
||||
explicit = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle", intro="Своё")
|
||||
assert explicit.data["scales"]["gad7"]["intro"] == "Своё"
|
||||
|
||||
|
||||
def test_daily_intros_use_registry_not_enter_hooks() -> None:
|
||||
for scale_id in DAILY.settings.intro_scales:
|
||||
assert not is_registered(f"scale.{scale_id}") # no per-scale enter hooks any more
|
||||
assert scale_id in scale_session._intros
|
||||
|
||||
|
||||
async def test_unscheduled_intro_from_registry(engine: Engine) -> None:
|
||||
r = await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
assert DAILY.scale_intros.unscheduled["phq9"] in r[0].text
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 3. payload buttons
|
||||
|
||||
|
||||
async def test_prefix_trigger_payload_and_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(fsm, "_triggers", dict(fsm._triggers))
|
||||
monkeypatch.setattr(fsm, "_prefix_triggers", dict(fsm._prefix_triggers))
|
||||
|
||||
def handler(tag: str) -> Any:
|
||||
async def fn(ctx: Ctx, event: Event) -> Step:
|
||||
return Step(data={"tag": tag, "payload": trigger_payload(event)})
|
||||
|
||||
return fn
|
||||
|
||||
trigger("button:zz:a:*")(handler("short"))
|
||||
trigger("button:zz:a:b:*")(handler("long"))
|
||||
trigger("button:zz:a:exact")(handler("exact"))
|
||||
with pytest.raises(ValueError):
|
||||
trigger("button:zz:a:*")(handler("dup"))
|
||||
|
||||
async def run(key: str) -> dict[str, Any] | None:
|
||||
fn = get_trigger(key)
|
||||
if fn is None:
|
||||
return None
|
||||
return (await fn(None, Event(type="button", button=key.split(":", 1)[1]))).data # type: ignore[arg-type]
|
||||
|
||||
assert await run("button:zz:a:42") == {"tag": "short", "payload": "42"}
|
||||
assert await run("button:zz:a:b:7:x") == {"tag": "long", "payload": "7:x"}
|
||||
assert await run("button:zz:a:exact") == {"tag": "exact", "payload": None}
|
||||
assert await run("button:zz:a:") is None and await run("button:zz:a") is None
|
||||
|
||||
|
||||
async def test_reminder_buttons_carry_dose_and_no_state_key(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="idle", data={"consent": True, "med_pending": {"dose_id": 1}}, now=clock.now()
|
||||
)
|
||||
r = await system(engine, "med_reminder")
|
||||
[dose] = await doses()
|
||||
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
|
||||
assert "med_pending" not in (await get_state())[1]
|
||||
skip = await press(engine, f"med:skip:{dose.id}")
|
||||
assert f"med:skip_reason:side_effects:{dose.id}" in buttons(skip)
|
||||
se = await press(engine, f"med:skip_reason:side_effects:{dose.id}")
|
||||
await press(engine, f"med:se:nausea:{dose.id}")
|
||||
[dose] = await doses()
|
||||
assert dose.status == "skipped" and dose.side_effects == "nausea" and "med:se:nausea:" in buttons(se)[0]
|
||||
|
||||
|
||||
async def test_two_reminders_each_button_answers_its_own_dose(engine: Engine) -> None:
|
||||
first = await system(engine, "med_reminder")
|
||||
second = await system(engine, "med_reminder")
|
||||
await press(engine, buttons(first)[0]) # «Принял» of the FIRST reminder, pressed after the second arrived
|
||||
d1, d2 = await doses()
|
||||
assert (d1.status, d2.status) == ("taken", "pending")
|
||||
await press(engine, buttons(second)[1])
|
||||
assert [d.status for d in await doses()] == ["taken", "skipped"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", ["med:taken", "med:skip", "med:se:nausea", "med:dose:yes", "med:skip_reason:quit"])
|
||||
async def test_legacy_payloadless_buttons_are_stale(engine: Engine, data: str) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
r = await press(engine, data)
|
||||
assert r[0].text == MEDS.stale and await checkin() is None
|
||||
assert [d.status for d in await doses()] == ["pending"]
|
||||
|
||||
|
||||
async def test_foreign_unknown_and_old_dose_buttons_are_stale(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
rx = Prescription(token=OTHER, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})
|
||||
s.add(rx)
|
||||
await s.flush()
|
||||
foreign = Dose(token=OTHER, prescription_id=rx.id, planned_at=clock.now(), status="pending")
|
||||
s.add(foreign)
|
||||
await s.flush()
|
||||
foreign_id = foreign.id
|
||||
for data in (f"med:taken:{foreign_id}", "med:taken:9999", "med:taken:garbage", "med:taken:2026-10-06"):
|
||||
assert (await press(engine, data))[0].text == MEDS.stale
|
||||
old = await system(engine, "med_reminder")
|
||||
clock.advance(days=1)
|
||||
assert (await press(engine, buttons(old)[0]))[0].text == MEDS.stale
|
||||
assert await checkin() is None
|
||||
|
||||
|
||||
async def test_reminder_without_dose_row_uses_day_payload(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
for row in await s.scalars(select(Prescription)):
|
||||
await s.delete(row)
|
||||
r = await system(engine, "med_reminder")
|
||||
assert buttons(r)[0] == f"med:taken:{NOW.date().isoformat()}"
|
||||
r = await press(engine, buttons(r)[0])
|
||||
assert r[0].text == MEDS.taken_ack and (await checkin()).med_taken is True and await doses() == []
|
||||
|
||||
|
||||
async def test_checkin_med_step_answers_todays_dose(engine: Engine) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
await system(engine, "daily_checkin")
|
||||
await press(engine, "daily:mood:5")
|
||||
await press(engine, "daily:med:yes")
|
||||
assert [d.status for d in await doses()] == ["taken"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------- 4. abandon timeout
|
||||
|
||||
|
||||
async def test_abandoned_checkin_returns_to_idle(engine: Engine) -> None:
|
||||
await system(engine, "daily_checkin")
|
||||
await press(engine, "daily:mood:3")
|
||||
assert (await get_state())[0].startswith("daily.")
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours + 1)
|
||||
r = await system(engine, "med_reminder")
|
||||
state, data = await get_state()
|
||||
assert state == "idle" and "checkin" not in data
|
||||
assert len(r) == 1 and r[0].text.startswith(MEDS.reminder.text.split("{")[0]) # no check-in question repeated
|
||||
assert (await checkin()).mood == 3 # stored answer survives
|
||||
|
||||
|
||||
async def test_short_pause_keeps_checkin(engine: Engine) -> None:
|
||||
await system(engine, "daily_checkin")
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours - 1)
|
||||
r = await press(engine, "daily:mood:6")
|
||||
assert (await get_state())[0] != "idle" and r
|
||||
|
||||
|
||||
async def test_abandoned_scale_lets_queued_scale_start(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=True)
|
||||
await answer(engine, "phq9", [1, 2])
|
||||
clock.advance(hours=4)
|
||||
await system(engine, "scale_due", scale="pcl5", scheduled=True) # queued; system events don't extend the step
|
||||
clock.advance(hours=3)
|
||||
r = await text(engine, "добрый вечер")
|
||||
state, data = await get_state()
|
||||
assert state == "scale.pcl5" and "phq9" not in data["scales"] and not data.get("pending_scales")
|
||||
assert DAILY.scale_intros.scheduled["pcl5"] in r[-1].text
|
||||
assert await results("phq9") == [] # unfinished: no total, no severity
|
||||
async with session.get_clinical_session() as s:
|
||||
stored = (await s.scalars(select(ItemResponse).where(ItemResponse.scale == "phq9"))).all()
|
||||
assert [x.score for x in stored] == [1, 2]
|
||||
|
||||
|
||||
async def test_timeout_never_applies_in_crisis(engine: Engine) -> None:
|
||||
await system(engine, "scale_due", scale="phq9", scheduled=False)
|
||||
await answer(engine, "phq9", [0] * 8 + [2]) # item 9 → crisis + C-SSRS (none done before)
|
||||
state, data = await get_state()
|
||||
assert state == f"scale.{SCREEN}" and data["crisis"]["open"] is True
|
||||
clock.advance(hours=TEXTS.settings.abandon_after_hours * 3)
|
||||
await press(engine, f"scale:{SCREEN}:cssrs_1:0")
|
||||
state, data = await get_state()
|
||||
assert state == f"scale.{SCREEN}" and data["scales"][SCREEN]["idx"] == 1
|
||||
|
||||
|
||||
async def test_timeout_never_skips_onboarding_scale(engine: Engine) -> None:
|
||||
item = load_scale("pcl5").items[0]
|
||||
old = (clock.now() - timedelta(hours=48)).isoformat()
|
||||
session_data = {
|
||||
"idx": 0,
|
||||
"answers": {},
|
||||
"return_state": "veteran.onboarding.phq9",
|
||||
"scheduled": True,
|
||||
"intro": None,
|
||||
"intro_shown": True,
|
||||
}
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s,
|
||||
token=TOKEN,
|
||||
state="scale.pcl5",
|
||||
data={"consent": True, "scales": {"pcl5": session_data}, STEP_AT: old},
|
||||
now=clock.now(),
|
||||
)
|
||||
await press(engine, f"scale:pcl5:{item.id}:1")
|
||||
state, data = await get_state()
|
||||
assert state == "scale.pcl5" and data["scales"]["pcl5"]["idx"] == 1
|
||||
|
||||
|
||||
async def test_timeout_disabled_same_day(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock.set_now(NOW.replace(hour=1))
|
||||
off = TEXTS.model_copy(update={"settings": TEXTS.settings.model_copy(update={"abandon_after_hours": 0})})
|
||||
monkeypatch.setattr(engine_module, "load_dialog_texts", lambda: off)
|
||||
await system(engine, "daily_checkin")
|
||||
clock.advance(hours=20)
|
||||
await press(engine, "daily:mood:4")
|
||||
assert (await get_state())[0] != "idle" and (await checkin()).mood == 4
|
||||
+35
-30
@@ -101,6 +101,17 @@ def buttons(replies: list[Reply]) -> list[str]:
|
||||
return [b.data for r in replies for row in r.buttons for b in row]
|
||||
|
||||
|
||||
def kinds(replies: list[Reply]) -> list[str]:
|
||||
"""Button data without the dose payload (#56): "med:taken:7" → "med:taken"."""
|
||||
return [d.rsplit(":", 1)[0] if d.startswith("med:") else d for d in buttons(replies)]
|
||||
|
||||
|
||||
async def tap(e: Engine, replies: list[Reply], kind: str) -> list[Reply]:
|
||||
"""Press the button of `replies` whose data (without payload) is `kind` — as the patient would."""
|
||||
[data] = [d for d in buttons(replies) if d.startswith("med:") and d.rsplit(":", 1)[0] == kind]
|
||||
return await press(e, data)
|
||||
|
||||
|
||||
async def get_state() -> tuple[str, dict[str, Any]]:
|
||||
async with session.get_clinical_session() as s:
|
||||
row = await repo.get_dialog_state(s, TOKEN)
|
||||
@@ -125,10 +136,10 @@ async def doses() -> list[Dose]:
|
||||
async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||
r = await system(engine, "med_reminder")
|
||||
assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг")
|
||||
assert buttons(r) == ["med:taken", "med:skip", "med:side_effects"]
|
||||
[dose] = await doses()
|
||||
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
|
||||
assert dose.status == "pending"
|
||||
r = await press(engine, "med:taken")
|
||||
r = await tap(engine, r, "med:taken")
|
||||
assert r[0].text == MEDS.taken_ack and mon.calls == 1
|
||||
[dose] = await doses()
|
||||
assert dose.status == "taken" and dose.answered_at is not None
|
||||
@@ -136,18 +147,17 @@ async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, m
|
||||
|
||||
|
||||
async def test_stale_reminder_button(engine: Engine) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
reminder = await system(engine, "med_reminder")
|
||||
clock.advance(days=1)
|
||||
r = await press(engine, "med:taken")
|
||||
r = await tap(engine, reminder, "med:taken")
|
||||
assert r[0].text == MEDS.stale and await checkin() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ["forgot", "other"])
|
||||
async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
r = await press(engine, "med:skip")
|
||||
assert buttons(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
|
||||
r = await press(engine, f"med:skip_reason:{reason}")
|
||||
r = await tap(engine, await system(engine, "med_reminder"), "med:skip")
|
||||
assert kinds(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
|
||||
r = await tap(engine, r, f"med:skip_reason:{reason}")
|
||||
assert r[0].text == MEDS.skip.ack[reason]
|
||||
c = await checkin()
|
||||
assert c.med_taken is False and not c.quit_intent
|
||||
@@ -155,44 +165,39 @@ async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
|
||||
|
||||
|
||||
async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
await press(engine, "med:skip")
|
||||
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
|
||||
mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})]
|
||||
r = await press(engine, "med:skip_reason:quit")
|
||||
r = await tap(engine, skip, "med:skip_reason:quit")
|
||||
assert r[0].text == MEDS.skip.ack["quit"]
|
||||
assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text
|
||||
assert (await checkin()).quit_intent is True
|
||||
|
||||
|
||||
async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
await press(engine, "med:skip")
|
||||
r = await press(engine, "med:skip_reason:side_effects")
|
||||
assert "med:se:nausea" in buttons(r) and "med:se:other" in buttons(r)
|
||||
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
|
||||
r = await tap(engine, skip, "med:skip_reason:side_effects")
|
||||
assert "med:se:nausea" in kinds(r) and "med:se:other" in kinds(r)
|
||||
|
||||
|
||||
async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
r = await press(engine, "med:side_effects")
|
||||
assert buttons(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
|
||||
r = await press(engine, "med:se:nausea")
|
||||
r = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
|
||||
assert kinds(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
|
||||
r = await tap(engine, r, "med:se:nausea")
|
||||
assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt
|
||||
assert r[1].text == MEDS.side_effects.ask_dose and buttons(r[1:]) == ["med:dose:yes", "med:dose:no"]
|
||||
assert r[1].text == MEDS.side_effects.ask_dose and kinds(r[1:]) == ["med:dose:yes", "med:dose:no"]
|
||||
assert (await checkin()).side_effects == ["nausea"]
|
||||
assert (await doses())[0].side_effects == "nausea"
|
||||
r = await press(engine, "med:dose:yes")
|
||||
r = await tap(engine, r, "med:dose:yes")
|
||||
assert (await checkin()).med_taken is True and (await doses())[0].status == "taken"
|
||||
|
||||
|
||||
async def test_side_effect_other_free_text(engine: Engine) -> None:
|
||||
await system(engine, "med_reminder")
|
||||
await press(engine, "med:side_effects")
|
||||
r = await press(engine, "med:se:other")
|
||||
reminder = await system(engine, "med_reminder")
|
||||
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
|
||||
assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text"
|
||||
r = await text(engine, "голова болит весь день")
|
||||
assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle"
|
||||
await press(engine, "med:side_effects")
|
||||
await press(engine, "med:se:other")
|
||||
await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
|
||||
await text(engine, "какое-то странное ощущение")
|
||||
assert (await checkin()).side_effects == ["headache", "other"]
|
||||
|
||||
@@ -202,8 +207,8 @@ async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> Non
|
||||
await repo.upsert_checkin(
|
||||
s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"]
|
||||
)
|
||||
await system(engine, "med_reminder")
|
||||
r = await press(engine, "med:se:nausea")
|
||||
se = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
|
||||
r = await tap(engine, se, "med:se:nausea")
|
||||
assert r[0].text == MEDS.side_effects.repeat
|
||||
|
||||
|
||||
@@ -276,8 +281,8 @@ async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine
|
||||
async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None:
|
||||
await system(engine, "daily_checkin")
|
||||
r = await system(engine, "med_reminder")
|
||||
assert buttons(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
|
||||
r = await press(engine, "med:taken")
|
||||
assert kinds(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
|
||||
r = await tap(engine, r, "med:taken")
|
||||
assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood"
|
||||
await press(engine, "daily:mood:5")
|
||||
assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question
|
||||
|
||||
@@ -292,13 +292,13 @@ async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
|
||||
dose_id = await new_dose(clock.today())
|
||||
reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг")
|
||||
assert "сертралин 50 мг" in reminder[0].text
|
||||
assert buttons(reminder) == ["med:taken", "med:skip", "med:side_effects"]
|
||||
assert buttons(reminder) == [f"med:taken:{dose_id}", f"med:skip:{dose_id}", f"med:side_effects:{dose_id}"]
|
||||
if taken:
|
||||
r += await a.press("med:taken")
|
||||
r += await a.press(f"med:taken:{dose_id}")
|
||||
else:
|
||||
skip = await a.press("med:skip")
|
||||
assert "med:skip_reason:quit" in buttons(skip)
|
||||
r += skip + await a.press("med:skip_reason:quit")
|
||||
skip = await a.press(f"med:skip:{dose_id}")
|
||||
assert f"med:skip_reason:quit:{dose_id}" in buttons(skip)
|
||||
r += skip + await a.press(f"med:skip_reason:quit:{dose_id}")
|
||||
|
||||
# --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros
|
||||
if day == 7:
|
||||
@@ -359,7 +359,10 @@ async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
|
||||
r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2
|
||||
assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r)
|
||||
st, data = await state()
|
||||
assert data["crisis"]["open"] is True and st in ("crisis", "scale.cssrs_screen")
|
||||
# C-SSRS was completed minutes ago: item 9 opens the crisis but does not ask the same screen again (#56).
|
||||
assert data["crisis"]["open"] is True and st == "crisis" and not data.get("pending_scales")
|
||||
async with session.get_clinical_session() as s:
|
||||
assert len(await repo.list_scale_results(s, TOKEN, "cssrs_screen")) == 1
|
||||
acute = [x for x in await open_alerts() if x.level == "acute"]
|
||||
assert [x.code for x in acute] == ["phq9_item9"]
|
||||
assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12]
|
||||
|
||||
Reference in New Issue
Block a user