Clinical review of the questionnaires (ADR-013):
- Red-flag block first in stage 1 (config/instruments/red_flags.yaml); any «Да» stops the
questionnaire with an acute alert and the help screen of its category.
- One crisis rule: the crisis screen opens at a HIGH C-SSRS risk only; moderate stops and goes
to a psychiatrist (24 h as a guide), low is a flag with the safety plan. PHQ-9 item 9 with a
negative screener is a discrepancy flag; without a completed screener it asks the C-SSRS at once.
config/questionnaire.yaml: cssrs_positive_policy graded (C01).
- Every crisis leaves a callback task for the doctor on duty (post_crisis_call, one per patient)
and the app asks how the person is the next day, the safety plan one tap away.
- Routing: risk and flags first, scales second; PCL-5 >= 50 no longer means a priority
appointment (pcl5_severe is moderate on the dashboard too).
- Crisis numbers live only in config/texts/crisis.yaml; texts and the agent prompt refer to them
via {phone:<key>}. Fixes the children's helpline offered to adults by the chat agent.
Refs #92
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
532 lines
24 KiB
Python
532 lines
24 KiB
Python
"""Synthetic released-config integration tests. Production approvals stay empty."""
|
|
|
|
import asyncio
|
|
import shutil
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
import yaml
|
|
from sqlalchemy import select
|
|
|
|
from app.config import get_settings
|
|
from app.core import clock
|
|
from app.core.scales import clear_cache
|
|
from app.db import repo, session
|
|
from app.db.bootstrap import init_db
|
|
from app.db.clinical import Alert
|
|
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
|
|
from app.dialog.contracts import Event
|
|
from app.dialog.engine import Engine
|
|
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
|
|
from app.llm.gateway import LLMGateway
|
|
from app.llm.mock import MockTasks
|
|
from app.scheduler import jobs
|
|
from app.services import questionnaire as service
|
|
|
|
TOKEN = "b" * 32
|
|
NOW = datetime(2026, 10, 3, 17, tzinfo=UTC)
|
|
|
|
|
|
@pytest.fixture
|
|
async def released(tmp_path, monkeypatch):
|
|
config = tmp_path / "config"
|
|
shutil.copytree(get_settings().config_dir, config)
|
|
for name in ("questionnaire", "intake_runtime"):
|
|
path = config / f"{name}.yaml"
|
|
data = yaml.safe_load(path.read_text())
|
|
approval = dict(
|
|
reviewer="Synthetic test",
|
|
decision_reference="fixture-only",
|
|
approved_at=NOW.isoformat(),
|
|
policy_version=data["version"],
|
|
)
|
|
data["approval"] = approval
|
|
if name == "questionnaire":
|
|
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
|
|
else:
|
|
data.update(enabled=True, content_review=approval, contact_review=approval)
|
|
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
|
monkeypatch.setattr(get_settings(), "config_dir", config)
|
|
monkeypatch.setattr(clock, "now", lambda: NOW)
|
|
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
|
|
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
|
|
clear_cache()
|
|
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
|
|
await init_db()
|
|
async with session.get_clinical_session() as s:
|
|
await repo.upsert_profile(s, token=TOKEN, doctor_id="synthetic-doctor", modules=["veteran"], sex="m")
|
|
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW)
|
|
yield Engine(gateway=LLMGateway(MockTasks()))
|
|
await session.configure()
|
|
clear_cache()
|
|
|
|
|
|
async def rows(cls=QuestionnaireRecord):
|
|
async with session.get_clinical_session() as s:
|
|
return list((await s.scalars(select(cls))).all())
|
|
|
|
|
|
async def press(e, data):
|
|
return await e.handle(TOKEN, Event(type="button", button=data))
|
|
|
|
|
|
def answer_button(replies, choices=None):
|
|
choices = choices or {}
|
|
for reply in reversed(replies):
|
|
for line in reply.buttons:
|
|
for b in line:
|
|
if b.data.startswith("iq:"):
|
|
item = b.data.split(":")[2]
|
|
# «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise.
|
|
index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0)
|
|
for line2 in reply.buttons:
|
|
for other in line2:
|
|
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
|
|
return other.data
|
|
return None
|
|
|
|
|
|
async def complete_intake(e, choices=None):
|
|
replies = await press(e, "intake:start")
|
|
for _ in range(100):
|
|
button = answer_button(replies, choices)
|
|
if button is None:
|
|
return replies
|
|
replies = await press(e, button)
|
|
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()
|
|
assert row.status == "complete"
|
|
assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers)
|
|
assert row.details["totals"]["tsq"]["total"] is None
|
|
assert row.details["totals"]["phq9"]["total"] is None
|
|
assert row.details["totals"]["rus_audit_s"]["total"] == 0
|
|
assert row.details["totals"]["cssrs_recent"]["risk"] == "none"
|
|
assert row.details["decision"]["primary_route"] == "self_help"
|
|
|
|
|
|
async def test_pause_restart_and_duplicate_callbacks(released):
|
|
replies = await press(released, "intake:start")
|
|
old = answer_button(replies)
|
|
await asyncio.gather(press(released, old), press(released, old))
|
|
(row,) = await rows()
|
|
assert row.answers == {"rf_psychosis": 0}
|
|
await press(released, "intake:pause")
|
|
restarted = Engine(gateway=LLMGateway(MockTasks()))
|
|
replies = await press(restarted, "intake:resume")
|
|
assert "rf_harm_others" in answer_button(replies)
|
|
await press(restarted, old)
|
|
assert (await rows())[0].answers == {"rf_psychosis": 0}
|
|
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
|
|
|
|
|
|
@pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"])
|
|
async def test_immediate_stop_preserves_partial_answers(released, signal):
|
|
replies = await press(released, "intake:start")
|
|
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:
|
|
replies = await press(released, answer_button(replies, {"rf_psychosis": 0}))
|
|
assert any(r.kind == "crisis" for r in replies)
|
|
(row,) = await rows()
|
|
assert row.status == "interrupted" and row.completed_at is None
|
|
notices = await rows(QuestionnaireNotice)
|
|
assert len(notices) == 1 and notices[0].status == "test_sink"
|
|
assert "уведомлён" not in replies[0].text
|
|
# Every crisis leaves a callback task for the doctor on duty (one per patient while it is open).
|
|
calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"]
|
|
assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty"
|
|
|
|
|
|
async def test_red_flag_violence_uses_its_crisis_variant(released):
|
|
replies = await press(released, "intake:start")
|
|
for _ in range(3): # psychosis, harm to others, weapons: «Нет»
|
|
replies = await press(released, answer_button(replies))
|
|
replies = await press(released, answer_button(replies, {"rf_violence": 0}))
|
|
crisis = next(r for r in replies if r.kind == "crisis")
|
|
assert "насили" in crisis.text.lower()
|
|
(row,) = await rows()
|
|
assert row.details["stop_reason"] == "red_flag_violence"
|
|
|
|
|
|
async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released):
|
|
# One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor.
|
|
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
|
|
choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1)
|
|
replies = await press(released, "intake:start")
|
|
for _ in range(60):
|
|
button = answer_button(replies, choices)
|
|
if button is None or any(r.kind == "safety_plan" for r in replies):
|
|
break
|
|
replies = await press(released, button)
|
|
assert not any(r.kind == "crisis" for r in replies)
|
|
assert any(r.kind == "safety_plan" for r in replies)
|
|
codes = {a.code for a in await rows(Alert)}
|
|
assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes
|
|
(row,) = await rows()
|
|
assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"]
|
|
|
|
|
|
async def complete_existing(e, replies, choices):
|
|
for _ in range(100):
|
|
button = answer_button(replies, choices)
|
|
if not button:
|
|
return replies
|
|
replies = await press(e, button)
|
|
raise AssertionError("not terminated")
|
|
|
|
|
|
async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released):
|
|
choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)}
|
|
choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1)
|
|
await complete_intake(released, choices)
|
|
(row,) = await rows()
|
|
assert row.status == "complete"
|
|
assert row.details["totals"]["tsq"]["total"] == 6
|
|
assert row.details["totals"]["pcl5"]["total"] == 0
|
|
assert row.details["totals"]["rus_audit"]["complete"]
|
|
assert row.answers["rus_audit_3"] == 1
|
|
assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10
|
|
|
|
|
|
async def test_alcohol_conflict_keeps_positive_short_answer(released):
|
|
await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0})
|
|
(row,) = await rows()
|
|
assert row.status == "review_required" and row.answers["rus_audit_3"] == 1
|
|
assert row.details["stop_reason"] == "answer_conflict"
|
|
|
|
|
|
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
|
|
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)
|
|
# 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):
|
|
class Failing:
|
|
async def send(self, *args, **kwargs):
|
|
raise OSError("sensitive error content must not be stored")
|
|
|
|
monkeypatch.setattr(integrations, "notifier", Failing())
|
|
await press(released, "intake:start")
|
|
await press(released, "intake:help")
|
|
(notice,) = await rows(QuestionnaireNotice)
|
|
assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1
|
|
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3))
|
|
notifier = MemoryNotifier()
|
|
monkeypatch.setattr(integrations, "notifier", notifier)
|
|
await service.dispatch_notices()
|
|
await service.dispatch_notices()
|
|
assert len(notifier.sent) == 1
|
|
assert (await rows(QuestionnaireNotice))[0].attempts == 2
|
|
await press(released, "consent:revoke")
|
|
async with session.get_clinical_session() as s:
|
|
state = await repo.get_dialog_state(s, TOKEN)
|
|
assert state.data["consent"] is False and "questionnaire_record" not in state.data
|
|
|
|
|
|
async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch):
|
|
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
|
|
|
class Failing:
|
|
async def push(self, *args):
|
|
raise OSError()
|
|
|
|
monkeypatch.setattr(integrations, "outbox", Failing())
|
|
await jobs.run_until_now()
|
|
tasks = await rows(QuestionnaireTask)
|
|
# 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()
|
|
await jobs.run_until_now()
|
|
assert len(await rows(QuestionnaireTask)) == 2
|
|
assert len(outbox.drain(TOKEN)) == 1
|
|
assert len(await rows()) == 2
|
|
|
|
|
|
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})
|
|
)
|
|
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):
|
|
async with session.get_clinical_session() as s:
|
|
state = await repo.get_dialog_state(s, TOKEN)
|
|
state.data = {"consent": True}
|
|
await press(released, "intake:start")
|
|
assert await rows() == []
|
|
|
|
|
|
async def review_risk_record(e):
|
|
from app.api.questionnaire import ReviewIn, review
|
|
|
|
risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact")
|
|
return await review(
|
|
TOKEN,
|
|
risk.id,
|
|
ReviewIn(
|
|
doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment"
|
|
),
|
|
)
|
|
|
|
|
|
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
|
|
|
|
from app.api.questionnaire import ReviewIn, history, review
|
|
from app.db.audit import AuditLog
|
|
|
|
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
|
|
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,
|
|
risk.id,
|
|
ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"),
|
|
)
|
|
assert exc.value.status_code == 404
|
|
await released.handle(TOKEN, Event(type="system", system="doctor_ack"))
|
|
async with session.get_clinical_session() as s:
|
|
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_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_morning_then_evening_with_the_ptsd_module(released):
|
|
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
|
replies = await press(released, "menu:checkin")
|
|
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_depression_module_item9_asks_the_screener_at_once(released, monkeypatch):
|
|
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
|
async with session.get_clinical_session() as s:
|
|
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 press(released, "menu:checkin")
|
|
for _ in range(10):
|
|
button = answer_button(replies, {"medication": 2, "dphq_9": 1})
|
|
replies = await press(released, button)
|
|
if ":cssrs_" in (answer_button(replies) or ""):
|
|
break
|
|
# Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high.
|
|
assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies)
|
|
evening = next(r for r in await rows() if r.kind == "diary_evening")
|
|
assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs"
|
|
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0})
|
|
assert any(r.kind == "crisis" for r in replies)
|
|
|
|
|
|
async def test_unknown_sex_goes_to_review_without_a_zone(released):
|
|
async with session.get_clinical_session() as s:
|
|
(await repo.get_profile(s, TOKEN)).sex = None
|
|
await complete_intake(released, {"trauma": 1, "sex": 2})
|
|
(row,) = await rows()
|
|
assert row.status == "review_required" and row.answers["sex"] == "unknown"
|
|
|
|
|
|
async def test_version_change_blocks_resume_and_preserves_answers(released):
|
|
replies = await press(released, "intake:start")
|
|
await press(released, answer_button(replies))
|
|
await press(released, "intake:pause")
|
|
path = get_settings().config_dir / "instruments/intake_forms.yaml"
|
|
data = yaml.safe_load(path.read_text())
|
|
data["version"] = "synthetic-next-version"
|
|
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 == {"rf_psychosis": 0}
|
|
|
|
|
|
async def test_followup_delta_uses_comparable_complete_baseline(released):
|
|
await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1})
|
|
async with session.get_clinical_session() as s:
|
|
task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW)
|
|
task_id = task.id
|
|
replies = await released.handle(
|
|
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
|
|
)
|
|
replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1})
|
|
repeat = next(r for r in await rows() if r.kind == "phq9")
|
|
assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5
|
|
assert repeat.details["delta"]["retriage"] is True
|
|
|
|
|
|
async def test_silence_uses_completed_checkins_not_empty_records(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
|
|
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
|
|
await jobs.plan()
|
|
await jobs.plan()
|
|
notices = await rows(QuestionnaireNotice)
|
|
assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1
|
|
|
|
|
|
async def test_stop_any_policy_interrupts_first_positive(released):
|
|
path = get_settings().config_dir / "questionnaire.yaml"
|
|
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 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
|
|
|
|
|
|
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=4, minute=59))
|
|
await jobs.plan()
|
|
assert await rows(QuestionnaireTask) == []
|
|
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
|
|
await jobs.plan()
|
|
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):
|
|
replies = await press(released, "intake:start")
|
|
for _ in range(5): # the red-flag block
|
|
replies = await press(released, answer_button(replies))
|
|
await press(released, answer_button(replies))
|
|
await press(released, "intake:pause")
|
|
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
|
|
replies = await press(released, "intake:resume")
|
|
await press(released, answer_button(replies))
|
|
(record,) = await rows()
|
|
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 == []
|
|
|
|
|
|
async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch):
|
|
replies = await press(released, "intake:start")
|
|
await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis
|
|
outbox = MemoryOutbox()
|
|
monkeypatch.setattr(integrations, "outbox", outbox)
|
|
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21))
|
|
await jobs.plan()
|
|
await jobs.plan()
|
|
sent = outbox.drain(TOKEN)
|
|
assert len(sent) == 1 and "Как вы сегодня" in sent[0].text
|
|
assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line)
|