208 lines
6.6 KiB
Python
208 lines
6.6 KiB
Python
"""登录后聊天通知的契约:轮询、卡片、点击去向。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import pytest
|
|
from PySide6.QtWidgets import QApplication, QWidget
|
|
|
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
|
from doctor_workstation.ui import chat_notifications as chat_module
|
|
from doctor_workstation.ui.chat_notifications import (
|
|
CONSULTATION_COMPLETE,
|
|
PATIENT_LEFT_CHAT,
|
|
PATIENT_OPENED_CHAT,
|
|
ChatNotificationCenter,
|
|
parse_notification,
|
|
relative_time,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def application() -> QApplication:
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture
|
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def run_immediately(
|
|
function: Callable[..., Any],
|
|
*args: Any,
|
|
on_success: Callable[[Any], Any] | None = None,
|
|
on_error: Callable[[Exception], Any] | None = None,
|
|
on_finished: Callable[[], Any] | None = None,
|
|
**_kwargs: Any,
|
|
) -> object:
|
|
try:
|
|
result = function(*args)
|
|
except Exception as error:
|
|
if on_error:
|
|
on_error(error)
|
|
else:
|
|
if on_success:
|
|
on_success(result)
|
|
finally:
|
|
if on_finished:
|
|
on_finished()
|
|
return object()
|
|
|
|
monkeypatch.setattr(chat_module, "run_async", run_immediately)
|
|
|
|
|
|
class _NotifyRepository:
|
|
def __init__(self, *batches: list[dict[str, Any]]) -> None:
|
|
self.batches = list(batches)
|
|
self.calls = 0
|
|
|
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
|
self.calls += 1
|
|
# 服务端取一次即消费,这里同样只发一次。
|
|
return self.batches.pop(0) if self.batches else []
|
|
|
|
|
|
def _row(identifier: str, kind: str = PATIENT_OPENED_CHAT, **extra: Any) -> dict[str, Any]:
|
|
row = {
|
|
"id": identifier,
|
|
"type": kind,
|
|
"doctor_id": 7,
|
|
"patient_id": "11676",
|
|
"patient_name": "甘先生",
|
|
"created_at": 1787882294,
|
|
}
|
|
row.update(extra)
|
|
return row
|
|
|
|
|
|
def test_rows_normalize_into_admin_equivalent_cards() -> None:
|
|
opened = parse_notification(_row("a1"))
|
|
assert opened is not None
|
|
assert opened.title == "患者打开会话"
|
|
assert opened.description == "甘先生 已打开与您的会话,请及时查看"
|
|
assert opened.action_text == "去接诊台"
|
|
|
|
left = parse_notification(_row("a2", PATIENT_LEFT_CHAT))
|
|
assert left is not None
|
|
assert left.description == "甘先生 已离开问诊会话页面"
|
|
|
|
complete = parse_notification(
|
|
_row("a3", CONSULTATION_COMPLETE, doctor_name="陈医生", diagnosis_id="8169")
|
|
)
|
|
assert complete is not None
|
|
assert complete.diagnosis_id == 8169
|
|
assert complete.description == "甘先生 的面诊已由 陈医生 完成,请及时跟进"
|
|
|
|
# 缺 id、未知 type、非映射行都不该变成卡片。
|
|
assert parse_notification(_row("", PATIENT_OPENED_CHAT)) is None
|
|
assert parse_notification(_row("a4", "unknown_business")) is None
|
|
assert parse_notification("not-a-row") is None
|
|
|
|
|
|
def test_relative_time_matches_the_admin_wording() -> None:
|
|
now = 1787882294 + 0.0
|
|
assert relative_time(1787882294, now=now) == "刚刚"
|
|
assert relative_time(1787882294 - 120, now=now) == "2 分钟前"
|
|
assert relative_time(1787882294 - 7200, now=now) == "2 小时前"
|
|
assert relative_time(0, now=now) == ""
|
|
|
|
|
|
def test_center_polls_once_per_tick_and_never_repeats_a_card(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
host = QWidget()
|
|
host.resize(1280, 800)
|
|
repository = _NotifyRepository([_row("a1"), _row("a1")], [_row("a2", PATIENT_LEFT_CHAT)])
|
|
center = ChatNotificationCenter(repository, host)
|
|
|
|
center.poll()
|
|
assert [item.id for item in center.pending] == ["a1"]
|
|
center.poll()
|
|
# 同一条通知重复下发也只留一张卡片,新的排在最前面。
|
|
assert [item.id for item in center.pending] == ["a2", "a1"]
|
|
assert repository.calls == 2
|
|
assert center.isVisible() is False or len(center.pending) == 2
|
|
|
|
center.dismiss("a1")
|
|
assert [item.id for item in center.pending] == ["a2"]
|
|
center.clear()
|
|
assert center.pending == []
|
|
host.deleteLater()
|
|
|
|
|
|
def test_center_keeps_only_the_newest_five_cards(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
host = QWidget()
|
|
repository = _NotifyRepository([_row(f"n{index}") for index in range(8)])
|
|
center = ChatNotificationCenter(repository, host)
|
|
|
|
center.poll()
|
|
|
|
assert [item.id for item in center.pending] == ["n7", "n6", "n5", "n4", "n3"]
|
|
host.deleteLater()
|
|
|
|
|
|
def test_activating_a_card_emits_it_once_and_removes_it(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
host = QWidget()
|
|
repository = _NotifyRepository([_row("a1", CONSULTATION_COMPLETE, diagnosis_id=8169)])
|
|
center = ChatNotificationCenter(repository, host)
|
|
activated: list[Any] = []
|
|
center.notification_activated.connect(activated.append)
|
|
|
|
center.poll()
|
|
card = next(iter(center._cards.values()))
|
|
card.open_button.click()
|
|
|
|
assert [item.diagnosis_id for item in activated] == [8169]
|
|
assert center.pending == []
|
|
host.deleteLater()
|
|
|
|
|
|
def test_center_stays_silent_when_the_source_cannot_answer(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class _Failing:
|
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
|
raise RuntimeError("服务暂时不可用")
|
|
|
|
host = QWidget()
|
|
center = ChatNotificationCenter(_Failing(), host)
|
|
center.poll()
|
|
assert center.pending == []
|
|
|
|
# 演示仓储与不支持该接口的数据源都不应该报错。
|
|
ChatNotificationCenter(DemoDoctorRepository(), host).poll()
|
|
ChatNotificationCenter(object(), host).poll()
|
|
host.deleteLater()
|
|
|
|
|
|
class _RecordingClient:
|
|
def __init__(self, payload: Any) -> None:
|
|
self.payload = payload
|
|
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
|
self.get_calls.append((endpoint, dict(params or {})))
|
|
return self.payload
|
|
|
|
|
|
def test_remote_consumes_the_same_admin_endpoint() -> None:
|
|
client = _RecordingClient([_row("a1")])
|
|
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
|
|
|
rows = repository.list_chat_notifications()
|
|
|
|
assert client.get_calls == [("chat/notifications", {})]
|
|
assert [row["id"] for row in rows] == ["a1"]
|