- 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>
179 lines
7.1 KiB
Python
179 lines
7.1 KiB
Python
"""Synthetic exhaustive scoring and calendar/diary boundary checks."""
|
|
|
|
from datetime import date, timedelta
|
|
from itertools import product
|
|
|
|
import pytest
|
|
|
|
from app.core.intake_runtime import diary_flags, due_scales, load_runtime, month_before
|
|
from app.core.questionnaire import IntakePolicy, alcohol_expansion, load_policy, score_alcohol
|
|
from app.core.scales import score
|
|
|
|
|
|
def test_release_is_closed():
|
|
assert not load_runtime().released
|
|
assert load_policy().approval is None
|
|
|
|
|
|
def test_all_tsq_patterns():
|
|
for answers in product((0, 1), repeat=10):
|
|
result = score("tsq", {f"tsq_{i + 1}": v for i, v in enumerate(answers)})
|
|
assert result.complete and result.total == sum(answers)
|
|
assert (result.severity_code == "positive") == (sum(answers) >= 6)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"day,expected",
|
|
[
|
|
(date(2024, 3, 31), date(2024, 2, 29)),
|
|
(date(2025, 3, 31), date(2025, 2, 28)),
|
|
(date(2026, 1, 1), date(2025, 12, 1)),
|
|
],
|
|
)
|
|
def test_calendar_month(day, expected):
|
|
assert month_before(day) == expected
|
|
|
|
|
|
def test_medication_schedule_overlapping_and_delayed_completion():
|
|
start = date(2026, 1, 1)
|
|
last = {}
|
|
for day in (6, 7, 13, 14, 27, 28, 55, 56):
|
|
due = due_scales(
|
|
today=start + timedelta(days=day), enrolled=start, last=last, route="self_help", medication_changed=start
|
|
)
|
|
assert ("phq9" in due) == (day in (7, 14, 28, 56))
|
|
if "phq9" in due:
|
|
last.update(phq9=start + timedelta(days=day), pcl5=start + timedelta(days=day))
|
|
# A missed tick yields one form, not each historical milestone.
|
|
assert (
|
|
due_scales(
|
|
today=start + timedelta(days=80), enrolled=start, last={}, route="self_help", medication_changed=start
|
|
).count("phq9")
|
|
== 1
|
|
)
|
|
assert "phq9" in due_scales(
|
|
today=start + timedelta(days=7), enrolled=start, last={}, route="specialist", after_crisis=start
|
|
)
|
|
|
|
|
|
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_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)
|
|
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():
|
|
data = load_policy().model_dump()
|
|
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
|
|
data["approval"] = dict(
|
|
reviewer="Synthetic fixture",
|
|
decision_reference="test",
|
|
approved_at="2026-10-03T00:00:00Z",
|
|
policy_version=data["version"],
|
|
)
|
|
policy = IntakePolicy.model_validate(data)
|
|
r = score_alcohol({"rus_audit_3": 1, "rus_audit_9": 0, "rus_audit_10": 0}, short=True, sex="m")
|
|
assert r.zone == 1 and alcohol_expansion(r, policy)
|
|
with pytest.raises(ValueError):
|
|
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
|
|
|
|
|
|
def test_diary_parts_and_module_rotation():
|
|
from app.core.intake_questions import diary_questions
|
|
|
|
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"]
|