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,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