chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
CI / ruff · pytest (pull_request) Successful in 22s
CI / ruff · pytest (pull_request) Successful in 22s
Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub, integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5, tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010. Refs #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.dialog.contracts import Event, get_engine
|
||||
from app.main import app
|
||||
from app.services.contracts import get_monitoring
|
||||
|
||||
|
||||
async def test_stubs_are_usable() -> None:
|
||||
replies = await get_engine().handle("tok", Event(type="text", text="привет"))
|
||||
assert replies and replies[0].text
|
||||
assert await get_monitoring().after_data_change("tok") == []
|
||||
|
||||
|
||||
def test_api_fixture_routes() -> None:
|
||||
with TestClient(app) as c:
|
||||
patients = c.get("/api/v1/doctors/doc-smirnova/patients").json()
|
||||
assert patients[0]["status_color"] == "red"
|
||||
card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json()
|
||||
assert card["safety_plan"] and card["series"]
|
||||
assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200
|
||||
assert c.get("/api/v1/demo/clock").json()["virtual"] is True
|
||||
+4
-12
@@ -73,9 +73,7 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
||||
|
||||
async def test_identity_roundtrip_and_connect_code(dbs: None) -> None:
|
||||
async with session.get_identity_session() as s:
|
||||
await identity_repo.upsert_doctor(
|
||||
s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True
|
||||
)
|
||||
await identity_repo.upsert_doctor(s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True)
|
||||
await identity_repo.create_patient(
|
||||
s,
|
||||
token=TOKEN,
|
||||
@@ -147,9 +145,7 @@ async def test_scales_profiles_prescriptions(dbs: None) -> None:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="d1", modules=["veteran", "depression"])
|
||||
await repo.upsert_profile(s, token=TOKEN2, doctor_id="d2")
|
||||
await repo.upsert_profile(s, token=TOKEN, next_visit=date(2026, 10, 20))
|
||||
await repo.add_item_response(
|
||||
s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0
|
||||
)
|
||||
await repo.add_item_response(s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0)
|
||||
await repo.add_scale_result(
|
||||
s,
|
||||
token=TOKEN,
|
||||
@@ -218,9 +214,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None:
|
||||
async with session.get_clinical_session() as s:
|
||||
assert await repo.get_dialog_state(s, TOKEN) is None
|
||||
await repo.set_dialog_state(s, token=TOKEN, state="checkin_mood", data={"step": 1}, now=T0)
|
||||
await repo.add_message(
|
||||
s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle"
|
||||
)
|
||||
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle")
|
||||
with pytest.raises(ValueError):
|
||||
await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0)
|
||||
assert await repo.load_clock_offset(s) == 0.0
|
||||
@@ -237,9 +231,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None:
|
||||
async def test_naive_datetime_rejected(dbs: None) -> None:
|
||||
with pytest.raises(Exception, match="naive datetime"):
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.add_alert(
|
||||
s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1)
|
||||
)
|
||||
await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1))
|
||||
|
||||
|
||||
async def test_session_rolls_back_on_error(dbs: None) -> None:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.core.modules import due_scales, load_modules
|
||||
from app.core.scales import load_all
|
||||
|
||||
TODAY = date(2026, 10, 3)
|
||||
|
||||
|
||||
def test_modules_reference_existing_scales() -> None:
|
||||
cfg, scales = load_modules(), load_all()
|
||||
for mod in cfg.modules.values():
|
||||
for s in mod.start_scales + [e.scale for e in mod.schedule]:
|
||||
assert s in scales, s
|
||||
|
||||
|
||||
def test_veteran_schedule() -> None:
|
||||
last = {s: TODAY - timedelta(days=1) for s in ["pcl5", "phq9", "dar5", "auditc"]}
|
||||
assert due_scales({"veteran"}, 10, last, TODAY) == []
|
||||
last["pcl5"] = TODAY - timedelta(days=14)
|
||||
assert due_scales({"veteran"}, 20, last, TODAY) == ["pcl5"]
|
||||
|
||||
|
||||
def test_depression_weekly_then_biweekly() -> None:
|
||||
last = {"phq9": TODAY - timedelta(days=7), "cssrs_screen": TODAY}
|
||||
assert due_scales({"depression"}, 20, last, TODAY) == ["phq9"]
|
||||
assert due_scales({"depression"}, 50, last, TODAY) == []
|
||||
|
||||
|
||||
def test_dar5_scoring() -> None:
|
||||
from app.core.scales import score
|
||||
|
||||
r = score("dar5", {f"dar5_{i}": 3 for i in range(1, 6)})
|
||||
assert r.total == 15 and "dar5_significant" in r.flags
|
||||
@@ -191,9 +191,7 @@ def test_pseudo_id_default_key_from_settings() -> None:
|
||||
assert pseudo_id("11223344595") == pseudo_id("11223344595", key=get_settings().pseudo_key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"]
|
||||
)
|
||||
@pytest.mark.parametrize("bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"])
|
||||
def test_pseudo_id_rejects_invalid_snils(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
pseudo_id(bad, key=KEY)
|
||||
|
||||
+5
-15
@@ -60,9 +60,7 @@ def test_yaml_loads_and_every_rule_has_evaluator() -> None:
|
||||
|
||||
def test_unknown_rule_id_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RulesConfig.model_validate(
|
||||
{"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]}
|
||||
)
|
||||
RulesConfig.model_validate({"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]})
|
||||
|
||||
|
||||
def test_alert_without_level_rejected() -> None:
|
||||
@@ -134,9 +132,7 @@ def test_cssrs_levels() -> None:
|
||||
(None, "moderate", None, False),
|
||||
],
|
||||
)
|
||||
def test_weapon_access_with_risk(
|
||||
weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool
|
||||
) -> None:
|
||||
def test_weapon_access_with_risk(weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool) -> None:
|
||||
scales = [] if item9 is None else [ScalePoint(scale="phq9", ts=at(5, 20), total=10, item9=item9)]
|
||||
s = snap(5, weapon_access=weapon, cssrs_risk=cssrs, scale_results=scales)
|
||||
assert ("weapon_access_risk" in codes(evaluate(s), "crisis")) is fires
|
||||
@@ -188,9 +184,7 @@ def test_future_scale_results_ignored() -> None:
|
||||
assert evaluate(s) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)]
|
||||
)
|
||||
@pytest.mark.parametrize(("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)])
|
||||
def test_auditc_by_sex(sex: str, total: int, fires: bool) -> None:
|
||||
s = snap(1, sex=sex, scale_results=[ScalePoint(scale="auditc", ts=at(0), total=total)])
|
||||
acts = evaluate(s)
|
||||
@@ -212,9 +206,7 @@ def test_quit_intent_voiced() -> None:
|
||||
|
||||
|
||||
def test_quit_intent_followed_by_skipped_dose() -> None:
|
||||
s = snap(
|
||||
7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)]
|
||||
)
|
||||
s = snap(7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)])
|
||||
assert "quit_intent" in codes(evaluate(s), "alert")
|
||||
no_intent = snap(7, checkins=[ci(6, med_taken=True), ci(7, med_taken=False)])
|
||||
assert "quit_intent" not in codes(evaluate(no_intent))
|
||||
@@ -303,9 +295,7 @@ def test_silence_over_3_days() -> None:
|
||||
assert evaluate(snap(10, last_patient_message_at=at(7, 21))) == [] # exactly 3 days
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)]
|
||||
)
|
||||
@pytest.mark.parametrize(("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)])
|
||||
def test_young_adult_enhanced_monitoring(age: int, day: int, fires: bool) -> None:
|
||||
acts = evaluate(snap(day, age=age))
|
||||
assert (acts == [a for a in acts if a.type == "enhanced_monitoring"] and bool(acts)) is fires
|
||||
|
||||
@@ -29,7 +29,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
|
||||
|
||||
def test_all_scales_load_with_expected_item_counts() -> None:
|
||||
counts = {k: len(v.items) for k, v in load_all().items()}
|
||||
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4}
|
||||
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5}
|
||||
assert counts == expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user