feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
CI / ruff · pytest (pull_request) Successful in 2m1s
- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows, 7-day adherence bars, discontinuation-risk badge with factor tooltip. - Patient card: scales with severity bands from config/scales (unscheduled marked), mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack, safety plan, AI summary with disclaimer. - Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on every page; full rerun on new alert only on doctor pages. - Connect form -> POST /patients/connect -> code, QR, deep link. - Thin httpx ApiClient over the frozen schemas with graceful "API недоступен". - app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages of #17 plug in by adding a file. Refs #16 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI)."""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import ConnectPatientIn
|
||||
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
|
||||
|
||||
|
||||
def _client(handler) -> ApiClient:
|
||||
return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _json(models) -> list | dict:
|
||||
if isinstance(models, list):
|
||||
return [m.model_dump(mode="json") for m in models]
|
||||
return models.model_dump(mode="json")
|
||||
|
||||
|
||||
def test_base_url_gets_api_prefix_once():
|
||||
assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1"
|
||||
assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1"
|
||||
|
||||
|
||||
def test_doctor_id_from_env(monkeypatch):
|
||||
monkeypatch.setenv("DOCTOR_ID", "doc-other")
|
||||
assert ApiClient("http://x").doctor_id == "doc-other"
|
||||
monkeypatch.delenv("DOCTOR_ID")
|
||||
assert ApiClient("http://x").doctor_id == "doc-smirnova"
|
||||
|
||||
|
||||
def test_patients_and_card_are_parsed():
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(req.url))
|
||||
if req.url.path.endswith("/patients"):
|
||||
return httpx.Response(200, json=_json(fixtures.PATIENTS))
|
||||
return httpx.Response(200, json=_json(fixtures.andrey_card()))
|
||||
|
||||
c = _client(handler)
|
||||
patients = c.patients()
|
||||
assert patients[0].display_name == "Андрей С."
|
||||
card = c.patient_card("tok-andrey")
|
||||
assert card.patient.status_color == "red"
|
||||
assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients"
|
||||
assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova"
|
||||
|
||||
|
||||
def test_alerts_params_and_ack_body():
|
||||
captured: dict = {}
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.method == "POST":
|
||||
captured["body"] = json.loads(req.content)
|
||||
return httpx.Response(200, json=_json(fixtures.all_alerts()[0]))
|
||||
captured["params"] = dict(req.url.params)
|
||||
return httpx.Response(200, json=_json(fixtures.all_alerts()))
|
||||
|
||||
c = _client(handler)
|
||||
assert len(c.alerts(since_id=2, open_only=True)) == 4
|
||||
assert captured["params"] == {"since_id": "2", "open_only": "true"}
|
||||
c.ack(3)
|
||||
assert captured["body"] == {"doctor_id": "doc-smirnova"}
|
||||
|
||||
|
||||
def test_connect_posts_payload():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(req.content)
|
||||
assert body["drug"] == "сертралин" and body["times"] == ["08:00"]
|
||||
return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"})
|
||||
|
||||
body = ConnectPatientIn(
|
||||
doctor_id="doc-smirnova",
|
||||
display_name="Андрей С.",
|
||||
birth_year=1992,
|
||||
sex="m",
|
||||
snils="000-000-000 00",
|
||||
drug="сертралин",
|
||||
dose="50 мг",
|
||||
)
|
||||
assert _client(handler).connect(body).code == "DEMO-1"
|
||||
|
||||
|
||||
def test_connection_error_becomes_unavailable():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused", request=req)
|
||||
|
||||
with pytest.raises(ApiUnavailable) as exc:
|
||||
_client(handler).patients()
|
||||
assert "API недоступен" in exc.value.message
|
||||
|
||||
|
||||
def test_timeout_becomes_unavailable():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("slow", request=req)
|
||||
|
||||
with pytest.raises(ApiUnavailable):
|
||||
_client(handler).alerts()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")])
|
||||
def test_http_errors(status, fragment):
|
||||
c = _client(lambda req: httpx.Response(status, json={"detail": "x"}))
|
||||
with pytest.raises(ApiError) as exc:
|
||||
c.patient_card("tok-x")
|
||||
assert fragment in exc.value.message
|
||||
assert exc.value.status == status
|
||||
assert not isinstance(exc.value, ApiUnavailable)
|
||||
|
||||
|
||||
def test_bad_payload_is_api_error():
|
||||
c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}]))
|
||||
with pytest.raises(ApiError, match="неожиданный"):
|
||||
c.patients()
|
||||
c = _client(lambda req: httpx.Response(200, content=b"<html>"))
|
||||
with pytest.raises(ApiError):
|
||||
c.patients()
|
||||
|
||||
|
||||
def test_clock_failure_is_none():
|
||||
c = _client(lambda req: httpx.Response(503))
|
||||
assert c.clock() is None
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery."""
|
||||
|
||||
import random
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint
|
||||
from dashboard.app import discover_pages, slugify
|
||||
from dashboard.components import charts, ui
|
||||
from dashboard.components import helpers as h
|
||||
|
||||
TZ = ZoneInfo("Europe/Moscow")
|
||||
|
||||
|
||||
def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk:
|
||||
return DiscontinuationRisk(score=score, level=level, factors=factors or [])
|
||||
|
||||
|
||||
def test_sort_red_yellow_grey_green_then_risk():
|
||||
items = list(reversed(fixtures.PATIENTS))
|
||||
items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"}))
|
||||
out = h.sort_patients(items)
|
||||
colors = [p.status_color for p in out]
|
||||
assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__)
|
||||
assert out[0].token == "tok-andrey"
|
||||
yellows = [p for p in out if p.status_color == "yellow"]
|
||||
assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow
|
||||
assert [p.discontinuation_risk.score for p in yellows] == sorted(
|
||||
(p.discontinuation_risk.score for p in yellows), reverse=True
|
||||
)
|
||||
|
||||
|
||||
def test_filter_and_kpis():
|
||||
items = fixtures.PATIENTS
|
||||
assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"}
|
||||
assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."]
|
||||
assert len(h.filter_patients(items, query="пароксетин")) == 1
|
||||
k = h.kpis(items)
|
||||
assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1)
|
||||
|
||||
|
||||
def test_trend_arrows():
|
||||
assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red"
|
||||
assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green"
|
||||
assert h.trend_view("flat").text == "→"
|
||||
assert h.trend_view("unknown").tone == "grey"
|
||||
|
||||
|
||||
def test_risk_badge_and_tooltip():
|
||||
v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"]))
|
||||
assert v.text == "Высокий · 70" and v.tone == "red"
|
||||
assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint
|
||||
assert h.risk_view(_risk(35, "medium")).tone == "yellow"
|
||||
assert "значимых факторов нет" in h.risk_view(_risk(5)).hint
|
||||
|
||||
|
||||
def test_adherence_thresholds():
|
||||
assert h.adherence_view(None).text == "—"
|
||||
assert h.adherence_view(1.0).tone == "green"
|
||||
assert h.adherence_view(0.8).tone == "green"
|
||||
assert h.adherence_view(0.7).tone == "yellow"
|
||||
assert h.adherence_view(0.57) == h.View("57%", "red")
|
||||
|
||||
|
||||
def test_level_tone_matches_adr006():
|
||||
assert h.level_tone("acute") == "red"
|
||||
assert h.level_tone("high") == h.level_tone("moderate") == "yellow"
|
||||
assert h.level_tone("info") == "grey"
|
||||
|
||||
|
||||
def test_severity_bands_come_from_config():
|
||||
bands = h.severity_bands("phq9")
|
||||
assert [b.lo for b in bands] == [0, 5, 10, 15, 20]
|
||||
assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая"
|
||||
assert h.severity_label("pcl5", 44) == "Вероятное ПТСР"
|
||||
assert h.severity_label("pcl5", None) == ""
|
||||
assert h.severity_bands("no_such_scale") == ()
|
||||
|
||||
|
||||
def test_last_and_prev():
|
||||
card = fixtures.andrey_card()
|
||||
assert h.last_and_prev(card.series, "phq9") == (18, 13)
|
||||
assert h.last_and_prev(card.series, "gad7") == (None, None)
|
||||
|
||||
|
||||
def test_dose_grid_fills_gaps_and_counts():
|
||||
d0 = datetime(2026, 9, 21, 8, tzinfo=TZ)
|
||||
doses = [
|
||||
DoseOut(planned_at=d0, drug="сертралин", status="taken"),
|
||||
DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"),
|
||||
DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"),
|
||||
DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"),
|
||||
]
|
||||
g = h.dose_grid(doses)
|
||||
assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)]
|
||||
assert g.slots == ["08:00", "20:00"]
|
||||
assert g.cells[("20:00", date(2026, 9, 21))] == "missed"
|
||||
assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell
|
||||
assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1}
|
||||
assert g.adherence == 1 / 3 # pending is not counted
|
||||
|
||||
|
||||
def test_dose_grid_empty():
|
||||
g = h.dose_grid([])
|
||||
assert g.days == [] and g.adherence is None
|
||||
|
||||
|
||||
def test_dose_grid_converts_to_moscow_time():
|
||||
utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC"))
|
||||
g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")])
|
||||
assert g.slots == ["08:00"]
|
||||
|
||||
|
||||
def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut:
|
||||
ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours)
|
||||
return AlertOut(
|
||||
id=i,
|
||||
token="t",
|
||||
patient_name="П",
|
||||
ts=ts,
|
||||
level=level,
|
||||
color=h.level_tone(level),
|
||||
code="c",
|
||||
reason="r",
|
||||
acked_by="d" if acked else None,
|
||||
acked_at=ts if acked else None,
|
||||
)
|
||||
|
||||
|
||||
def test_alert_ordering_new_and_local_acks():
|
||||
alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)]
|
||||
assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3]
|
||||
assert [a.id for a in h.split_new(alerts, {1})] == [2, 3]
|
||||
at = datetime(2026, 10, 3, 22, tzinfo=TZ)
|
||||
out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)})
|
||||
assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at
|
||||
assert out[0].acked_at is None
|
||||
|
||||
|
||||
def test_russian_formatting():
|
||||
assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [
|
||||
"21 год",
|
||||
"23 года",
|
||||
"34 года",
|
||||
"41 год",
|
||||
"45 лет",
|
||||
"11 лет",
|
||||
"112 лет",
|
||||
]
|
||||
assert h.age_label(0) == "возраст не указан"
|
||||
assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X"
|
||||
assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X"
|
||||
today = date(2026, 10, 3)
|
||||
assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней"
|
||||
assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день"
|
||||
assert h.visit_label(today, today) == "визит сегодня"
|
||||
assert "просрочен" in h.visit_label(date(2026, 10, 1), today)
|
||||
assert h.visit_label(None, today) == "визит не назначен"
|
||||
assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00"
|
||||
|
||||
|
||||
def test_snils_checksum():
|
||||
assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95
|
||||
assert h.is_valid_snils("112-233-445 95")
|
||||
assert not h.is_valid_snils("112-233-445 96")
|
||||
rng = random.Random(1)
|
||||
for _ in range(50):
|
||||
s = h.synthetic_snils(rng)
|
||||
assert s.startswith("000-") and h.is_valid_snils(s)
|
||||
|
||||
|
||||
def test_ui_row_escapes_and_shows_values():
|
||||
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
|
||||
html = ui.patient_row(p)
|
||||
assert "<script>" not in html and "<script>" in html
|
||||
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
|
||||
|
||||
|
||||
def test_scale_figure_marks_unscheduled_and_bands():
|
||||
card = fixtures.andrey_card()
|
||||
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
|
||||
assert fig is not None
|
||||
trace = fig.data[0]
|
||||
assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"]
|
||||
assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml
|
||||
assert charts.scale_figure([], "phq9", "PHQ-9") is None
|
||||
|
||||
|
||||
def test_wellbeing_figure_small_multiples():
|
||||
card = fixtures.andrey_card()
|
||||
fig = charts.wellbeing_figure(card.series)
|
||||
assert fig is not None and len(fig.data) == 3
|
||||
assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None
|
||||
|
||||
|
||||
def test_page_discovery_orders_and_sections(tmp_path: Path):
|
||||
for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"):
|
||||
(tmp_path / name).write_text("", encoding="utf-8")
|
||||
(tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8")
|
||||
specs = discover_pages(tmp_path)
|
||||
assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"]
|
||||
assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"]
|
||||
assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее"
|
||||
assert specs[2].url_path == "demo-konsol"
|
||||
assert slugify("Сплит-экран") == "split-ekran"
|
||||
|
||||
|
||||
def test_real_pages_dir_has_doctor_pages():
|
||||
titles = [s.title for s in discover_pages()]
|
||||
assert titles[:2] == ["Пациенты", "Подключить пациента"]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from app.api import fixtures
|
||||
from dashboard.api_client import ApiClient
|
||||
from dashboard.components import state
|
||||
|
||||
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
|
||||
LIST_PAGE = str(PAGES / "1_Пациенты.py")
|
||||
CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py")
|
||||
|
||||
|
||||
def _dump(obj):
|
||||
return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json")
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
self.extra_alerts: list = []
|
||||
|
||||
def __call__(self, req: httpx.Request) -> httpx.Response:
|
||||
path = req.url.path.removeprefix("/api/v1")
|
||||
self.calls.append((req.method, path))
|
||||
if path == "/doctors/doc-smirnova/patients":
|
||||
return httpx.Response(200, json=_dump(fixtures.PATIENTS))
|
||||
if path.startswith("/patients/tok-"):
|
||||
card = fixtures.card(path.rsplit("/", 1)[1])
|
||||
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
|
||||
if path == "/doctors/doc-smirnova/alerts":
|
||||
return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts()))
|
||||
if path.startswith("/alerts/") and path.endswith("/ack"):
|
||||
alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2]))
|
||||
acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts})
|
||||
return httpx.Response(200, json=_dump(acked))
|
||||
if path == "/patients/connect":
|
||||
return httpx.Response(
|
||||
200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"}
|
||||
)
|
||||
if path == "/demo/clock":
|
||||
return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0})
|
||||
return httpx.Response(404)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch) -> FakeApi:
|
||||
fake = FakeApi()
|
||||
client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake))
|
||||
monkeypatch.setattr(state, "client", lambda: client)
|
||||
return fake
|
||||
|
||||
|
||||
def _html(at: AppTest) -> str:
|
||||
return "\n".join(str(el.proto.body) for el in at.get("html"))
|
||||
|
||||
|
||||
def test_list_page_renders_sorted_patients(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
html = _html(at)
|
||||
assert "Кабинет д-ра Смирновой Е. А." in html
|
||||
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
|
||||
assert "Высокий · 70" in html # discontinuation-risk badge
|
||||
open_buttons = [b for b in at.button if b.label == "Открыть"]
|
||||
assert len(open_buttons) == len(fixtures.PATIENTS)
|
||||
|
||||
|
||||
def test_list_filter_and_open_card(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
at.segmented_control(key="status_filter").set_value("🔴 Красные").run()
|
||||
assert len([b for b in at.button if b.label == "Открыть"]) == 1
|
||||
at.button(key="open-tok-andrey").click().run()
|
||||
assert not at.exception
|
||||
assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"])
|
||||
html = _html(at)
|
||||
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
|
||||
assert "Решения принимает врач" in html
|
||||
assert ("GET", "/patients/tok-andrey") in api.calls
|
||||
|
||||
|
||||
def test_card_ack_posts_and_marks_alert(api):
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20)
|
||||
at.query_params["patient"] = "tok-andrey"
|
||||
at.run()
|
||||
assert not at.exception
|
||||
assert at.button(key="ack-3").label == "Подтвердить"
|
||||
at.button(key="ack-3").click().run()
|
||||
assert ("POST", "/alerts/3/ack") in api.calls
|
||||
assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more
|
||||
|
||||
|
||||
def test_api_down_shows_banner(monkeypatch):
|
||||
def refuse(req: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused", request=req)
|
||||
|
||||
client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse))
|
||||
monkeypatch.setattr(state, "client", lambda: client)
|
||||
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
assert "API недоступен" in _html(at)
|
||||
|
||||
|
||||
def test_connect_flow_shows_code(api):
|
||||
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
|
||||
assert not at.exception
|
||||
at.button[0].click().run() # «Заполнить демо-данными»
|
||||
at.checkbox(key="cp_consent").check().run()
|
||||
assert at.text_input(key="cp_name").value == "Андрей С."
|
||||
submit = next(b for b in at.button if b.label == "Подключить и получить код")
|
||||
submit.click().run()
|
||||
assert not at.exception
|
||||
assert ("POST", "/patients/connect") in api.calls
|
||||
assert "DEMO-0001" in _html(at)
|
||||
assert at.get("image") # QR image
|
||||
|
||||
|
||||
def test_connect_requires_consent(api):
|
||||
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
|
||||
at.text_input(key="cp_name").set_value("Пётр К.").run()
|
||||
next(b for b in at.button if b.label == "Подключить и получить код").click().run()
|
||||
assert at.error and "согласие" in at.error[0].value
|
||||
assert ("POST", "/patients/connect") not in api.calls
|
||||
|
||||
|
||||
def test_app_entry_navigation_and_acute_banner(api):
|
||||
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
|
||||
assert not at.exception
|
||||
html = _html(at)
|
||||
assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page
|
||||
assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list
|
||||
assert "Прототип на синтетических данных" in html
|
||||
assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls
|
||||
|
||||
|
||||
def test_new_alert_toasts_once(api):
|
||||
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
|
||||
assert not at.toast # alerts open at session start are not "new"
|
||||
new = fixtures.all_alerts()[0].model_copy(
|
||||
update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"}
|
||||
)
|
||||
api.extra_alerts.append(new)
|
||||
at.run()
|
||||
assert not at.exception
|
||||
assert any("Сергей П." in t.value for t in at.toast)
|
||||
assert "Кризисный сигнал · Сергей П." in _html(at)
|
||||
at.run()
|
||||
assert not any("Сергей П." in t.value for t in at.toast)
|
||||
Reference in New Issue
Block a user