"""登录后聊天通知的契约:轮询、卡片、点击去向。""" 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"]