Files
zyt/app/tests/test_chat_notifications.py
T
2026-08-28 18:24:37 +08:00

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"]