feat(web): two-stage questionnaires, screening result and diary by result
- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,15 @@ async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient)
|
||||
assert await repo.get_profile(s, first["token"]) is None
|
||||
|
||||
|
||||
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
|
||||
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
|
||||
async with session.get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
assert profile.modules == ["veteran"] and not profile.doctor_id
|
||||
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
|
||||
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
|
||||
|
||||
|
||||
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
|
||||
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
|
||||
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
|
||||
@@ -142,7 +151,7 @@ async def test_questionnaires_are_off_without_release_or_demo_flag(
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
|
||||
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
|
||||
assert q == {"enabled": False, "intake": "none", "diary_done": False}
|
||||
assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"}
|
||||
|
||||
|
||||
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -158,7 +167,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp
|
||||
await press()
|
||||
await press("consent:yes")
|
||||
buttons = await press("age:adult")
|
||||
assert any(b.startswith("iq:") and ":cssrs_q1:" in b for b in buttons)
|
||||
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire.
|
||||
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons)
|
||||
assert "intake:help" in buttons and "intake:pause" in buttons
|
||||
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
|
||||
assert q["enabled"] and q["intake"] == "active"
|
||||
@@ -169,7 +179,8 @@ async def test_instrument_catalog_describes_intake_items(client: AsyncClient) ->
|
||||
phq = body["items"]["phq9_3"]
|
||||
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
|
||||
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
|
||||
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["comment"]["optional"]
|
||||
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"]
|
||||
assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary"
|
||||
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
|
||||
drinks = body["standard_drinks"]
|
||||
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
|
||||
@@ -186,3 +197,19 @@ async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClie
|
||||
)
|
||||
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
|
||||
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
|
||||
|
||||
|
||||
async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules.
|
||||
monkeypatch.setenv("INTAKE_DEMO", "1")
|
||||
token = "c" * 32
|
||||
|
||||
async def press(button: str | None = None) -> list[str]:
|
||||
event = {"type": "button", "button": button} if button else {"type": "start"}
|
||||
replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"]
|
||||
return [b["data"] for r in replies for row in r["buttons"] for b in row]
|
||||
|
||||
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
|
||||
await press()
|
||||
await press("consent:yes")
|
||||
assert any(":phq9_1:" in b for b in await press("age:adult"))
|
||||
|
||||
+125
-58
@@ -94,6 +94,17 @@ async def complete_intake(e, choices=None):
|
||||
raise AssertionError("intake did not terminate")
|
||||
|
||||
|
||||
async def to_cssrs(e, choices=None):
|
||||
"""Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
|
||||
replies = await press(e, "intake:start")
|
||||
for _ in range(20):
|
||||
button = answer_button(replies, {"trauma": 1, **(choices or {})})
|
||||
if ":cssrs_" in button:
|
||||
return replies
|
||||
replies = await press(e, button)
|
||||
raise AssertionError("C-SSRS not reached")
|
||||
|
||||
|
||||
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
(row,) = await rows()
|
||||
@@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released):
|
||||
old = answer_button(replies)
|
||||
await asyncio.gather(press(released, old), press(released, old))
|
||||
(row,) = await rows()
|
||||
assert row.answers == {"cssrs_q1": 0}
|
||||
assert row.answers == {"phq9_1": 0}
|
||||
await press(released, "intake:pause")
|
||||
restarted = Engine(gateway=LLMGateway(MockTasks()))
|
||||
replies = await press(restarted, "intake:resume")
|
||||
assert "cssrs_q2" in answer_button(replies)
|
||||
assert "phq9_2" in answer_button(replies)
|
||||
await press(restarted, old)
|
||||
assert (await rows())[0].answers == {"cssrs_q1": 0}
|
||||
assert (await rows())[0].answers == {"phq9_1": 0}
|
||||
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
|
||||
|
||||
|
||||
@@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
|
||||
if signal == "help":
|
||||
replies = await press(released, "intake:help")
|
||||
elif signal == "cssrs":
|
||||
replies = await to_cssrs(released)
|
||||
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
|
||||
replies = await press(released, answer_button(replies, {item: 0}))
|
||||
else:
|
||||
@@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
|
||||
|
||||
|
||||
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
|
||||
replies = await press(released, "intake:start")
|
||||
replies = await to_cssrs(released)
|
||||
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
|
||||
replies = await press(released, answer_button(replies))
|
||||
replies = await press(released, answer_button(replies))
|
||||
assert any(r.kind == "safety_plan" for r in replies)
|
||||
assert "phq9_1" in answer_button(replies)
|
||||
# The screener ends stage 1; the context questions follow (sex is known from the profile).
|
||||
assert ":tbi:" in answer_button(replies)
|
||||
|
||||
|
||||
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
|
||||
@@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
|
||||
monkeypatch.setattr(integrations, "outbox", Failing())
|
||||
await jobs.run_until_now()
|
||||
tasks = await rows(QuestionnaireTask)
|
||||
assert len(tasks) == 2
|
||||
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
|
||||
assert risk.delivery and risk.sent_at is None
|
||||
# 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
|
||||
assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
|
||||
morning = next(t for t in tasks if t.kind == "diary_morning")
|
||||
assert morning.delivery and morning.sent_at is None
|
||||
outbox = MemoryOutbox()
|
||||
monkeypatch.setattr(integrations, "outbox", outbox)
|
||||
await jobs.run_until_now()
|
||||
@@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
|
||||
assert len(await rows()) == 2
|
||||
|
||||
|
||||
async def test_missing_since_contact_is_manual_review_not_recent(released):
|
||||
async def test_since_contact_risk_check_opens_the_evening_diary(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
|
||||
task_id = task.id
|
||||
replies = await released.handle(
|
||||
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
|
||||
)
|
||||
(row,) = await rows()
|
||||
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
|
||||
assert row.details["stop_reason"] == "cssrs_since_contact_required"
|
||||
assert not answer_button(replies)
|
||||
assert any("последнего ответа" in r.text for r in replies)
|
||||
for _ in range(5):
|
||||
button = answer_button(replies)
|
||||
if ":cssrs_" not in (button or ""):
|
||||
break
|
||||
replies = await press(released, button)
|
||||
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
|
||||
assert risk.status == "complete" and risk.answers == {
|
||||
"cssrs_q1": 0,
|
||||
"cssrs_q2": 0,
|
||||
"cssrs_q6_lifetime": 0,
|
||||
"cssrs_q6_recent": 0,
|
||||
}
|
||||
assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
|
||||
assert ":mood:" in answer_button(replies)
|
||||
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
|
||||
|
||||
|
||||
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
|
||||
@@ -256,6 +282,20 @@ async def review_risk_record(e):
|
||||
)
|
||||
|
||||
|
||||
async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
|
||||
"""Answers the open diary questions; numeric items take `numbers` in turn."""
|
||||
numbers = list(numbers)
|
||||
for _ in range(30):
|
||||
button = answer_button(replies, choices)
|
||||
if button:
|
||||
replies = await press(e, button)
|
||||
elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
|
||||
replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
|
||||
else:
|
||||
return replies
|
||||
raise AssertionError("diary did not terminate")
|
||||
|
||||
|
||||
async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
from app.db.audit import AuditLog
|
||||
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
await jobs.run_until_now()
|
||||
async with session.get_clinical_session() as s:
|
||||
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"))
|
||||
# Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
|
||||
replies = await press(released, "menu:checkin")
|
||||
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
|
||||
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
|
||||
assert risk.status == "review_required"
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await review(
|
||||
TOKEN,
|
||||
@@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
|
||||
await review_risk_record(released)
|
||||
await jobs.run_until_now()
|
||||
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
|
||||
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
|
||||
await history(TOKEN, "synthetic-doctor")
|
||||
audits = await rows(AuditLog)
|
||||
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
|
||||
|
||||
|
||||
async def test_diary_numeric_skip_and_not_prescribed(released):
|
||||
async def test_diary_morning_then_evening_with_the_ptsd_module(released):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
await press(released, "menu:checkin")
|
||||
await review_risk_record(released)
|
||||
replies = await press(released, "menu:checkin")
|
||||
for _ in range(30):
|
||||
q = next(
|
||||
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
|
||||
None,
|
||||
)
|
||||
if q == "comment":
|
||||
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
|
||||
replies = await press(released, button)
|
||||
elif q:
|
||||
replies = await press(released, answer_button(replies, {"medication": 3}))
|
||||
elif any("часов" in r.text for r in replies):
|
||||
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
|
||||
assert any("число часов" in r.text for r in invalid)
|
||||
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
|
||||
else:
|
||||
break
|
||||
diary = next(r for r in await rows() if r.kind == "diary")
|
||||
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
|
||||
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
|
||||
assert diary.answers["comment"] is None
|
||||
assert any("часов" in r.text for r in replies)
|
||||
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
|
||||
assert any("число часов" in r.text for r in invalid)
|
||||
replies = await run_diary(released, invalid, {"sleep_quality": 3})
|
||||
assert any("утро" in r.text for r in replies)
|
||||
morning = next(r for r in await rows() if r.kind == "diary_morning")
|
||||
assert morning.status == "complete"
|
||||
assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
|
||||
evening = next(r for r in await rows() if r.kind == "diary_evening")
|
||||
assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
|
||||
assert "medication_reason" not in evening.answers
|
||||
assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
|
||||
assert evening.details["interpretation"]["kind"] == "diary_evening"
|
||||
replies = await press(released, "menu:checkin")
|
||||
assert any("уже заполнен" in r.text for r in replies)
|
||||
|
||||
|
||||
async def test_high_risk_diary_asks_safety_first_and_stops(released):
|
||||
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
async with session.get_clinical_session() as s:
|
||||
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
|
||||
await press(released, "menu:checkin")
|
||||
await review_risk_record(released)
|
||||
replies = await press(released, "menu:checkin")
|
||||
button = answer_button(replies)
|
||||
assert "death_thoughts" in button
|
||||
replies = await press(released, button)
|
||||
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
|
||||
await run_diary(released, await press(released, "menu:checkin"))
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
|
||||
assert any(r.kind == "crisis" for r in replies)
|
||||
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
|
||||
diary = next(r for r in await rows() if r.kind == "diary")
|
||||
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
|
||||
evening = next(r for r in await rows() if r.kind == "diary_evening")
|
||||
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
|
||||
|
||||
|
||||
async def test_unknown_sex_goes_to_review_without_a_zone(released):
|
||||
@@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
||||
await press(released, "intake:resume")
|
||||
(row,) = await rows()
|
||||
assert row.status == "review_required" and row.answers == {"cssrs_q1": 0}
|
||||
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
|
||||
|
||||
|
||||
async def test_followup_delta_uses_comparable_complete_baseline(released):
|
||||
@@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released):
|
||||
data = yaml.safe_load(path.read_text())
|
||||
data["cssrs_positive_policy"] = "stop_any"
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
||||
replies = await press(released, "intake:start")
|
||||
replies = await to_cssrs(released)
|
||||
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
|
||||
assert any(r.kind == "crisis" for r in replies)
|
||||
assert (await rows())[0].answers == {"cssrs_q1": 1}
|
||||
assert (await rows())[0].answers["cssrs_q1"] == 1
|
||||
|
||||
|
||||
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
|
||||
async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
|
||||
await jobs.plan()
|
||||
assert await rows(QuestionnaireTask) == []
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
|
||||
await jobs.plan()
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
|
||||
await jobs.plan()
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
|
||||
|
||||
|
||||
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
|
||||
@@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch):
|
||||
replies = await press(released, "intake:resume")
|
||||
await press(released, answer_button(replies))
|
||||
(record,) = await rows()
|
||||
metadata = record.details["answer_metadata"]["cssrs_q2"]
|
||||
assert metadata["period_start"] == "2026-09-03"
|
||||
metadata = record.details["answer_metadata"]["phq9_2"]
|
||||
assert metadata["period_start"] == "2026-09-19"
|
||||
assert metadata["period_end"] == "2026-10-03"
|
||||
assert metadata["answered_at"].startswith("2026-10-04")
|
||||
|
||||
|
||||
async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
|
||||
from app.api.patient_web import my_results
|
||||
|
||||
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
|
||||
choices.update(trauma=1, tbi=1, psychotropics=1)
|
||||
replies = await complete_intake(released, choices)
|
||||
assert any("Предположение депрессии" in r.text for r in replies)
|
||||
assert any("не диагноз" in r.text for r in replies)
|
||||
(row,) = await rows()
|
||||
interpretation = row.details["interpretation"]
|
||||
assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
|
||||
async with session.get_clinical_session() as s:
|
||||
# A doctor's modules (diagnosis) stay; the screening only adds to them.
|
||||
assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
|
||||
results = await my_results(TOKEN)
|
||||
assert results.conclusion.title == interpretation["title"]
|
||||
assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
|
||||
|
||||
|
||||
async def test_without_a_doctor_the_screening_defines_the_modules(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
|
||||
replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
assert any("не выявлено" in r.text for r in replies)
|
||||
async with session.get_clinical_session() as s:
|
||||
assert (await repo.get_profile(s, TOKEN)).modules == []
|
||||
|
||||
@@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None:
|
||||
assert (await checkin()).side_effects == ["headache", "other"]
|
||||
|
||||
|
||||
async def test_side_effect_description_is_optional(engine: Engine) -> None:
|
||||
reminder = await system(engine, "med_reminder")
|
||||
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
|
||||
assert r[0].kind == "question" # the client shows an (optional) input for it
|
||||
await press(engine, "text:skip")
|
||||
assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle"
|
||||
|
||||
|
||||
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_checkin(
|
||||
|
||||
@@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion():
|
||||
)
|
||||
|
||||
|
||||
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
|
||||
today = date(2026, 10, 3)
|
||||
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
|
||||
for i in range(3):
|
||||
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
|
||||
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
|
||||
history[today - timedelta(days=1)] = {}
|
||||
assert diary_flags(history, today) == ()
|
||||
history[today]["death_thoughts"] = 1
|
||||
assert diary_flags(history, today) == ("suicide_assessment_now",)
|
||||
def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)):
|
||||
"""14 days of personal norm starting 27 days ago, then the last 7 days with other values."""
|
||||
start = today - timedelta(days=27)
|
||||
history = {start + timedelta(days=i): dict(norm) for i in range(14)}
|
||||
history.update({today - timedelta(days=i): dict(week) for i in range(7)})
|
||||
return history, today
|
||||
|
||||
|
||||
def test_sleep_alternation_counts_but_threshold_is_strict():
|
||||
def test_weekly_mood_against_personal_norm():
|
||||
history, today = norm_and_week({"mood": 8}, {"mood": 5})
|
||||
assert "phq9_due_mood" in diary_flags(history, today)
|
||||
history, today = norm_and_week({"mood": 8}, {"mood": 6})
|
||||
assert "phq9_due_mood" not in diary_flags(history, today)
|
||||
# Inside the norm window there is no trend yet.
|
||||
early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)}
|
||||
assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10))
|
||||
|
||||
|
||||
def test_module_trends_bring_full_scales_forward():
|
||||
ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1}
|
||||
history, today = norm_and_week(ptsd, {k: 2 for k in ptsd})
|
||||
assert "pcl5_due_ptsd" in diary_flags(history, today)
|
||||
history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1})
|
||||
assert "phq9_due_depression" in diary_flags(history, today)
|
||||
|
||||
|
||||
def test_sleep_alcohol_and_medication_flags():
|
||||
today = date(2026, 10, 3)
|
||||
h = {
|
||||
today: {"nightmares": 3},
|
||||
today - timedelta(days=1): {"sleep_hours": 3.5},
|
||||
today - timedelta(days=2): {"sleep_hours": 3.5},
|
||||
}
|
||||
assert "sleep_review" in diary_flags(h, today)
|
||||
h[today - timedelta(days=1)]["sleep_hours"] = 4
|
||||
assert "sleep_review" not in diary_flags(h, today)
|
||||
nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)}
|
||||
nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2}
|
||||
assert "sleep_review" in diary_flags(nights, today)
|
||||
nights[today - timedelta(days=1)]["sleep_hours"] = 4
|
||||
assert "sleep_review" not in diary_flags(nights, today)
|
||||
|
||||
drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}}
|
||||
assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"}
|
||||
assert "alcohol_on_medication" not in diary_flags(drinks, today)
|
||||
|
||||
missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)}
|
||||
assert "medication_contact" in diary_flags(missed, today)
|
||||
week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)}
|
||||
assert "adherence_review" in diary_flags(week, today)
|
||||
assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == (
|
||||
"side_effects_review",
|
||||
)
|
||||
|
||||
|
||||
def test_total_threshold_and_zone_are_separate_decisions():
|
||||
@@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions():
|
||||
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
|
||||
|
||||
|
||||
def test_module_rotation_never_drops_suicide_question():
|
||||
def test_diary_parts_and_module_rotation():
|
||||
from app.core.intake_questions import diary_questions
|
||||
|
||||
first = {
|
||||
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
|
||||
}
|
||||
second = {
|
||||
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
|
||||
}
|
||||
assert "death_thoughts" in first & second
|
||||
assert "nightmares" in first and "nightmares" not in second
|
||||
assert "burden" in first and "burden" not in second
|
||||
assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [
|
||||
"sleep_hours",
|
||||
"sleep_quality",
|
||||
"alcohol_portions",
|
||||
]
|
||||
evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)]
|
||||
assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [
|
||||
"dpcl_1",
|
||||
"dpcl_7",
|
||||
"dpcl_10",
|
||||
"dpcl_18",
|
||||
]
|
||||
assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [
|
||||
"dphq_7",
|
||||
"dphq_8",
|
||||
"dphq_9",
|
||||
]
|
||||
assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"]
|
||||
|
||||
|
||||
def test_demo_policy_is_separate_and_marked(monkeypatch):
|
||||
from app.config import get_settings
|
||||
|
||||
monkeypatch.setenv("INTAKE_DEMO", "1")
|
||||
monkeypatch.setattr(get_settings(), "demo_clock", True)
|
||||
demo = load_policy()
|
||||
assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded"
|
||||
monkeypatch.delenv("INTAKE_DEMO")
|
||||
assert load_policy().approval is None
|
||||
|
||||
|
||||
def test_full_scale_change_against_the_start_of_observation():
|
||||
from app.core.interpretation import scale_interpretation
|
||||
|
||||
def phq(total, baseline, previous=None):
|
||||
totals = {"phq9": {"complete": True, "total": total}}
|
||||
return scale_interpretation(
|
||||
"phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5
|
||||
)["codes"]
|
||||
|
||||
assert phq(4, 16) == ["minimal", "remission"]
|
||||
assert phq(8, 16) == ["mild", "response"]
|
||||
assert phq(11, 16) == ["moderate", "improved"]
|
||||
assert phq(15, 16) == ["moderately_severe", "stable"]
|
||||
assert phq(21, 10, previous=16) == ["severe", "worse"]
|
||||
pcl = scale_interpretation(
|
||||
"pcl5",
|
||||
{"pcl5": {"complete": True, "total": 27}},
|
||||
baseline=45,
|
||||
previous=40,
|
||||
policy=load_policy(),
|
||||
retriage_delta=10,
|
||||
)
|
||||
assert pcl["codes"] == ["below", "response"]
|
||||
|
||||
Reference in New Issue
Block a user