first commit
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
"""Contract tests for the UI-independent API client and token store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiBusinessError,
|
||||
ApiError,
|
||||
ApiProtocolError,
|
||||
ApiTimeoutError,
|
||||
AuthenticationExpiredError,
|
||||
OpenPageRequiredError,
|
||||
WorkWechatBindingRequiredError,
|
||||
)
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.token_store import TokenStore
|
||||
|
||||
|
||||
def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
|
||||
"""The site base and already-prefixed base resolve to the same API URL."""
|
||||
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"code": 1, "data": {"ok": True}})
|
||||
|
||||
with ApiClient(
|
||||
"https://example.test/root/",
|
||||
token="secret-token",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
assert client.get("/doctor.appointment/lists", {"page_no": 2}) == {"ok": True}
|
||||
|
||||
request = requests[0]
|
||||
assert str(request.url) == (
|
||||
"https://example.test/root/adminapi/doctor.appointment/lists?page_no=2"
|
||||
)
|
||||
assert request.headers["token"] == "secret-token"
|
||||
assert request.headers["version"] == "1.9.4"
|
||||
assert ApiClient.normalise_base_url("https://example.test/adminapi") == (
|
||||
"https://example.test/adminapi/"
|
||||
)
|
||||
|
||||
|
||||
def test_post_uses_json_and_never_retries_timeout() -> None:
|
||||
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
|
||||
|
||||
attempts = 0
|
||||
bodies: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
bodies.append(json.loads(request.content))
|
||||
raise httpx.ReadTimeout("slow write", request=request)
|
||||
|
||||
client = ApiClient(
|
||||
"https://example.test",
|
||||
max_retries=5,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with pytest.raises(ApiTimeoutError) as caught:
|
||||
client.post("doctor.appointment/complete", {"id": 42})
|
||||
client.close()
|
||||
|
||||
assert attempts == 1
|
||||
assert bodies == [{"id": 42}]
|
||||
assert caught.value.data["attempts"] == 1
|
||||
|
||||
|
||||
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Uploads use real multipart encoding without the JSON content type."""
|
||||
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"code": 1, "data": {"uri": "/uploads/demo.jpg"}},
|
||||
)
|
||||
|
||||
source = tmp_path / "demo.jpg"
|
||||
source.write_bytes(b"jpeg-demo-bytes")
|
||||
with (
|
||||
ApiClient(
|
||||
"https://example.test",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client,
|
||||
source.open("rb") as stream,
|
||||
):
|
||||
result = client.post_multipart(
|
||||
"upload/image",
|
||||
files={"file": (source.name, stream, "image/jpeg")},
|
||||
data={"cid": "0"},
|
||||
)
|
||||
|
||||
assert result == {"uri": "/uploads/demo.jpg"}
|
||||
request = requests[0]
|
||||
content_type = request.headers["content-type"]
|
||||
assert content_type.startswith("multipart/form-data; boundary=")
|
||||
assert "application/json" not in content_type
|
||||
assert b'name="file"; filename="demo.jpg"' in request.content
|
||||
assert b'name="cid"' in request.content
|
||||
assert b"jpeg-demo-bytes" in request.content
|
||||
|
||||
|
||||
def test_get_retries_only_timeouts_then_returns_data() -> None:
|
||||
"""A GET may recover from a bounded number of timeout failures."""
|
||||
|
||||
attempts = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts < 3:
|
||||
raise httpx.ReadTimeout("temporary", request=request)
|
||||
return httpx.Response(200, json={"code": "1", "data": ["ready"]})
|
||||
|
||||
with ApiClient(
|
||||
"https://example.test/adminapi/",
|
||||
max_retries=2,
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
assert client.get("health") == ["ready"]
|
||||
assert attempts == 3
|
||||
|
||||
|
||||
def test_get_bytes_downloads_relative_public_image_without_api_token() -> None:
|
||||
"""Generated QR images bypass the JSON envelope and never leak the API token."""
|
||||
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, headers={"content-type": "image/png"}, content=b"png-data")
|
||||
|
||||
with ApiClient(
|
||||
"https://example.test/root",
|
||||
token="private-token",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
assert client.get_bytes("/uploads/qrcode.png") == b"png-data"
|
||||
|
||||
request = requests[0]
|
||||
assert str(request.url) == "https://example.test/uploads/qrcode.png"
|
||||
assert "token" not in request.headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "exception_type"),
|
||||
[
|
||||
(0, ApiBusinessError),
|
||||
(-1, AuthenticationExpiredError),
|
||||
(10, WorkWechatBindingRequiredError),
|
||||
],
|
||||
)
|
||||
def test_envelope_error_codes_are_structured(code: int, exception_type: type[Exception]) -> None:
|
||||
"""Known control-flow codes become typed exceptions with response data."""
|
||||
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
headers={"x-request-id": "req-123"},
|
||||
json={"code": code, "msg": "action needed", "data": {"reason": "demo"}},
|
||||
)
|
||||
)
|
||||
with (
|
||||
ApiClient("https://example.test", transport=transport) as client,
|
||||
pytest.raises(exception_type) as caught,
|
||||
):
|
||||
client.get("auth.admin/mySelf")
|
||||
error = caught.value
|
||||
assert isinstance(error, ApiError)
|
||||
assert error.code == code
|
||||
assert error.data == {"reason": "demo"}
|
||||
assert error.request_id == "req-123"
|
||||
|
||||
|
||||
def test_open_page_signal_does_not_open_a_browser() -> None:
|
||||
"""Code 2 is surfaced to the UI as data, not executed by the service layer."""
|
||||
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(
|
||||
200,
|
||||
json={"code": 2, "data": {"url": "https://example.test/continue"}},
|
||||
)
|
||||
)
|
||||
with (
|
||||
ApiClient("https://example.test", transport=transport) as client,
|
||||
pytest.raises(OpenPageRequiredError) as caught,
|
||||
):
|
||||
client.get("continue")
|
||||
assert caught.value.url == "https://example.test/continue"
|
||||
|
||||
|
||||
def test_invalid_envelope_raises_protocol_error() -> None:
|
||||
"""Successful HTTP is not mistaken for API success without a valid envelope."""
|
||||
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, json={"data": "missing code"})
|
||||
)
|
||||
with (
|
||||
ApiClient("https://example.test", transport=transport) as client,
|
||||
pytest.raises(ApiProtocolError),
|
||||
):
|
||||
client.get("broken")
|
||||
|
||||
|
||||
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
|
||||
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
|
||||
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=None)
|
||||
store.save_token("token-value", account="doctor")
|
||||
password_saved = store.save_password(
|
||||
"must-not-reach-disk",
|
||||
account="doctor",
|
||||
scope="https://example.test/adminapi",
|
||||
)
|
||||
|
||||
assert store.load_token() == "token-value"
|
||||
assert store.load_account() == "doctor"
|
||||
restored = store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||
if password_saved:
|
||||
assert restored == "must-not-reach-disk"
|
||||
else:
|
||||
assert restored is None
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert payload["token"] == "token-value"
|
||||
assert payload["account"] == "doctor"
|
||||
assert "password" not in path.read_text(encoding="utf-8").lower()
|
||||
assert "must-not-reach-disk" not in path.read_text(encoding="utf-8")
|
||||
store.clear_token()
|
||||
assert store.load_token() is None
|
||||
assert store.load_account() == "doctor"
|
||||
after_logout = store.load_password(
|
||||
account="doctor",
|
||||
scope="https://example.test/adminapi",
|
||||
)
|
||||
if password_saved:
|
||||
assert after_logout == "must-not-reach-disk"
|
||||
else:
|
||||
assert after_logout is None
|
||||
store.clear_account()
|
||||
assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None
|
||||
|
||||
|
||||
class _MemoryKeyring:
|
||||
"""Minimal deterministic keyring double."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[tuple[str, str], str] = {}
|
||||
|
||||
def get_password(self, service: str, username: str) -> str | None:
|
||||
"""Return an in-memory secret."""
|
||||
|
||||
return self.values.get((service, username))
|
||||
|
||||
def set_password(self, service: str, username: str, password: str) -> None:
|
||||
"""Store an in-memory secret."""
|
||||
|
||||
self.values[(service, username)] = password
|
||||
|
||||
def delete_password(self, service: str, username: str) -> None:
|
||||
"""Delete an in-memory secret."""
|
||||
|
||||
self.values.pop((service, username), None)
|
||||
|
||||
|
||||
def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
|
||||
"""A working keyring keeps the token out of the fallback JSON file."""
|
||||
|
||||
backend = _MemoryKeyring()
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=backend)
|
||||
store.save_token("keyring-token", account="doctor")
|
||||
|
||||
assert store.uses_keyring
|
||||
assert store.load_token() == "keyring-token"
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
|
||||
|
||||
|
||||
def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None:
|
||||
backend = _MemoryKeyring()
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=backend)
|
||||
scope = "https://example.test/adminapi/"
|
||||
|
||||
assert store.save_password("secret-value", account="doctor", scope=scope)
|
||||
assert (
|
||||
store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||
== "secret-value"
|
||||
)
|
||||
assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None
|
||||
assert "secret-value" not in path.read_text(encoding="utf-8")
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {
|
||||
"account": "doctor",
|
||||
"scope": "https://example.test/adminapi",
|
||||
}
|
||||
|
||||
next_scope = "https://next.test/adminapi"
|
||||
assert store.save_password("next-secret", account="doctor", scope=next_scope)
|
||||
assert store.load_password(account="doctor", scope=scope) is None
|
||||
assert store.load_password(account="doctor", scope=next_scope) == "next-secret"
|
||||
|
||||
store.clear_password(account="doctor", scope=next_scope)
|
||||
assert store.load_password(account="doctor", scope=next_scope) is None
|
||||
|
||||
|
||||
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
|
||||
"""Automatic restore never returns a token issued for another API base."""
|
||||
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=None)
|
||||
api_scope = "https://example.test/adminapi/"
|
||||
store.save_token(
|
||||
"scoped-token",
|
||||
account="doctor",
|
||||
scope=api_scope,
|
||||
)
|
||||
|
||||
assert store.load_token(scope="https://example.test/adminapi") == "scoped-token"
|
||||
assert store.load_token(scope="https://other.test/adminapi/") is None
|
||||
assert store.load_token() == "scoped-token"
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {
|
||||
"token": "scoped-token",
|
||||
"account": "doctor",
|
||||
"scope": "https://example.test/adminapi",
|
||||
}
|
||||
|
||||
store.save_token("next-token", account="", scope=api_scope)
|
||||
assert store.load_account() is None
|
||||
assert "account" not in json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,566 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QSize, Qt
|
||||
from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _immediate_async(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
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()
|
||||
|
||||
|
||||
class _DeferredAsync:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
self.calls.append(
|
||||
{
|
||||
"function": function,
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
"on_success": on_success,
|
||||
"on_error": on_error,
|
||||
"on_finished": on_finished,
|
||||
}
|
||||
)
|
||||
return object()
|
||||
|
||||
def succeed(self, index: int = 0) -> Any:
|
||||
call = self.calls.pop(index)
|
||||
try:
|
||||
result = call["function"](*call["args"], **call["kwargs"])
|
||||
except Exception as error:
|
||||
if call["on_error"]:
|
||||
call["on_error"](error)
|
||||
raise
|
||||
else:
|
||||
if call["on_success"]:
|
||||
call["on_success"](result)
|
||||
return result
|
||||
finally:
|
||||
if call["on_finished"]:
|
||||
call["on_finished"]()
|
||||
|
||||
|
||||
class _VisualRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
today_conflict: bool = True,
|
||||
doctors: bool = True,
|
||||
rosters: bool = True,
|
||||
slot_error: bool = False,
|
||||
) -> None:
|
||||
self.today_conflict = today_conflict
|
||||
self.doctors = doctors
|
||||
self.rosters = rosters
|
||||
self.slot_error = slot_error
|
||||
self.roster_queries: list[dict[str, Any]] = []
|
||||
self.slot_queries: list[dict[str, Any]] = []
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
if not self.doctors:
|
||||
return []
|
||||
return [
|
||||
{"id": 77, "name": "陈医生", "department_name": "中医科"},
|
||||
{"id": 88, "name": "周医生", "department_name": "内科"},
|
||||
{"id": 99, "name": "林医生", "department_name": "全科"},
|
||||
]
|
||||
|
||||
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
|
||||
assert dictionary_type == "channels"
|
||||
return [
|
||||
{"id": 2, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 20},
|
||||
{"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10},
|
||||
]
|
||||
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
if kwargs.get("status") == 3:
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"appointment_date": "2026-08-01",
|
||||
"appointment_time": "10:30-11:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
if self.today_conflict:
|
||||
return {"lists": [{"status": 1}], "count": 1}
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.roster_queries.append(kwargs)
|
||||
if not self.rosters:
|
||||
return {"lists": [], "count": 0}
|
||||
today = QDate.currentDate()
|
||||
return {
|
||||
"lists": [
|
||||
{"date": today.toString("yyyy-MM-dd")},
|
||||
{"date": today.addDays(1).toString("yyyy-MM-dd")},
|
||||
{"date": today.addDays(2).toString("yyyy-MM-dd")},
|
||||
],
|
||||
"count": 3,
|
||||
}
|
||||
|
||||
def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.slot_queries.append(kwargs)
|
||||
if self.slot_error:
|
||||
raise RuntimeError("号源服务暂时不可用")
|
||||
return {
|
||||
"slots": [
|
||||
{"time": "09:30-10:00", "available": True, "quota": 2},
|
||||
{"time": "10:00-10:30", "available": False, "quota": 0},
|
||||
{"time": "14:30-15:00", "available": True, "quota": 1},
|
||||
{"time": "15:00-15:30", "available": False, "quota": 0},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _show_drawer(
|
||||
application: QApplication,
|
||||
size: tuple[int, int],
|
||||
*,
|
||||
repository: Any = None,
|
||||
autoload: bool = False,
|
||||
async_runner: Any = _immediate_async,
|
||||
) -> tuple[QWidget, AppointmentDrawer]:
|
||||
host = QWidget()
|
||||
host.resize(*size)
|
||||
host.show()
|
||||
drawer = AppointmentDrawer(
|
||||
{
|
||||
"id": 501,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 999,
|
||||
"patient_name": "林晓岚",
|
||||
"doctor_id": 77,
|
||||
},
|
||||
repository=repository,
|
||||
parent=host,
|
||||
autoload=autoload,
|
||||
async_runner=async_runner,
|
||||
)
|
||||
drawer.show()
|
||||
application.processEvents()
|
||||
application.processEvents()
|
||||
return host, drawer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size,expected_width", [((1024, 640), 614), ((1440, 900), 864)])
|
||||
def test_full_window_rtl_drawer_geometry_and_fixed_regions(
|
||||
application: QApplication,
|
||||
size: tuple[int, int],
|
||||
expected_width: int,
|
||||
) -> None:
|
||||
application_style = application.styleSheet()
|
||||
host, drawer = _show_drawer(application, size)
|
||||
|
||||
assert drawer.size() == host.size()
|
||||
assert drawer.drawer_width == expected_width
|
||||
assert drawer.panel.geometry().right() == drawer.rect().right()
|
||||
assert drawer.panel.height() == drawer.height()
|
||||
assert drawer.header.height() == 58
|
||||
assert drawer.header.geometry().top() == 0
|
||||
assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom()
|
||||
assert drawer.body_scroll.geometry().top() == drawer.header.geometry().bottom() + 1
|
||||
assert drawer.body_scroll.geometry().bottom() + 1 == drawer.footer.geometry().top()
|
||||
assert drawer.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||
assert drawer.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
footer_geometry = drawer.footer.geometry()
|
||||
scroll_bar = drawer.body_scroll.verticalScrollBar()
|
||||
scroll_bar.setValue(scroll_bar.maximum())
|
||||
application.processEvents()
|
||||
assert drawer.footer.geometry() == footer_geometry
|
||||
assert application.styleSheet() == application_style
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_field_order_density_and_conditional_channel_row(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host, drawer = _show_drawer(application, (1024, 640))
|
||||
|
||||
assert [label.text() for label in drawer.form_labels] == [
|
||||
"上次就诊:",
|
||||
"预约方式:",
|
||||
"预约类型:",
|
||||
"选择患者:",
|
||||
"渠道来源 *:",
|
||||
"自媒体补充 *:",
|
||||
"预约医生:",
|
||||
"预约时间:",
|
||||
"备注:",
|
||||
]
|
||||
assert all(label.width() == 100 for label in drawer.form_labels)
|
||||
assert drawer.channel_source.maximumWidth() == 360
|
||||
assert drawer.channel_source_detail.maximumWidth() == 360
|
||||
assert drawer.channel_source.height() == 32
|
||||
assert drawer.cancel_button.height() == 32
|
||||
assert drawer.ok_button.height() == 32
|
||||
assert drawer.ok_button.text() == "确定"
|
||||
assert drawer.remark.height() == 70
|
||||
assert drawer.channel_detail_row.isHidden()
|
||||
assert drawer.time_empty.illustration.size() == QSize(80, 60)
|
||||
assert drawer.time_empty.illustration.accessibleName() == "空状态插图"
|
||||
|
||||
for radio in (
|
||||
drawer.appointment_method_radio,
|
||||
drawer.appointment_type_radio,
|
||||
drawer.patient_radio,
|
||||
):
|
||||
option = QStyleOptionButton()
|
||||
radio.initStyleOption(option)
|
||||
indicator = radio.style().subElementRect(
|
||||
QStyle.SubElement.SE_RadioButtonIndicator, option, radio
|
||||
)
|
||||
assert indicator.size() == QSize(14, 14)
|
||||
|
||||
drawer._channel_names = {"self-4h": "自媒体4H", "online": "线上复诊"}
|
||||
drawer.channel_source.clear()
|
||||
drawer.channel_source.addItem("请选择渠道来源", "")
|
||||
drawer.channel_source.addItem("自媒体4H", "self-4h")
|
||||
drawer.channel_source.addItem("线上复诊", "online")
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("self-4h"))
|
||||
application.processEvents()
|
||||
assert not drawer.channel_detail_row.isHidden()
|
||||
assert drawer.channel_source_detail.height() == 32
|
||||
drawer.channel_source_detail.setText("视频号")
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||
assert drawer.channel_detail_row.isHidden()
|
||||
assert drawer.channel_source_detail.text() == ""
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_roster_slot_states_conflict_refresh_and_submit_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repository = _VisualRepository(today_conflict=True)
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1440, 900),
|
||||
repository=repository,
|
||||
autoload=True,
|
||||
)
|
||||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
|
||||
assert drawer.last_visit_label.text() == "2026-08-01 10:30-11:00"
|
||||
assert list(drawer.doctor_buttons) == [77, 88, 99]
|
||||
assert [button.text() for button in drawer.doctor_buttons.values()] == [
|
||||
"陈医生",
|
||||
"周医生",
|
||||
"林医生",
|
||||
]
|
||||
assert all("·" not in button.text() for button in drawer.doctor_buttons.values())
|
||||
assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values())
|
||||
assert all(button.minimumSize() == QSize(130, 40) for button in drawer.date_buttons.values())
|
||||
assert all(button.maximumSize() == QSize(130, 40) for button in drawer.date_buttons.values())
|
||||
assert drawer.date_combo.currentData() == tomorrow
|
||||
assert drawer.conflict_alert.property("kind") == "info"
|
||||
assert "可为患者预约其他日期" in drawer.conflict_alert.label.text()
|
||||
|
||||
available = drawer.slot_buttons["09:30-10:00"]
|
||||
unavailable = drawer.slot_buttons["10:00-10:30"]
|
||||
assert available.minimumWidth() >= 110
|
||||
assert available.minimumHeight() >= 70
|
||||
assert available.property("slotState") == "available"
|
||||
assert available.isEnabled()
|
||||
assert unavailable.property("slotState") == "unavailable"
|
||||
assert not unavailable.isEnabled()
|
||||
assert unavailable.status_label.text() == "已约"
|
||||
assert unavailable.status_label.isVisible()
|
||||
assert unavailable.accessibleName() == "10:00-10:30 已约"
|
||||
available.click()
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
expected_payload = {
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 501,
|
||||
"doctor_id": 77,
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"remark": "",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
}
|
||||
assert drawer.payload() == expected_payload
|
||||
assert repository.roster_queries[-1] == {
|
||||
"doctor_id": 77,
|
||||
"start_date": today,
|
||||
"end_date": QDate.currentDate().addDays(6).toString("yyyy-MM-dd"),
|
||||
"status": 1,
|
||||
"page_no": 1,
|
||||
"page_size": 100,
|
||||
}
|
||||
assert repository.slot_queries[-1] == {
|
||||
"doctor_id": 77,
|
||||
"appointment_date": tomorrow,
|
||||
"period": "all",
|
||||
}
|
||||
|
||||
query_count = len(repository.slot_queries)
|
||||
drawer.refresh_slots_button.click()
|
||||
assert len(repository.slot_queries) == query_count + 1
|
||||
assert drawer.slot_combo.currentData() == "09:30-10:00"
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.date_buttons[today].click()
|
||||
assert drawer.date_combo.currentData() == today
|
||||
assert drawer.conflict_alert.property("kind") == "warning"
|
||||
assert "不能重复预约今天" in drawer.conflict_alert.label.text()
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
generation = drawer._slot_generation
|
||||
drawer._apply_slots(
|
||||
{"slots": [{"time": "00:00-00:30", "available": True, "quota": 1}]},
|
||||
77,
|
||||
today,
|
||||
generation,
|
||||
)
|
||||
application.processEvents()
|
||||
expired = drawer.slot_buttons["00:00-00:30"]
|
||||
assert expired.property("slotState") == "unavailable"
|
||||
assert not expired.isEnabled()
|
||||
assert expired.status_label.text() == "已约"
|
||||
assert expired.status_label.isVisible()
|
||||
assert "已过期" not in expired.accessibleName()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_initial_loading_state_covers_the_drawer_body(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
deferred = _DeferredAsync()
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1024, 640),
|
||||
repository=_VisualRepository(),
|
||||
autoload=True,
|
||||
async_runner=deferred,
|
||||
)
|
||||
|
||||
assert len(deferred.calls) == 1
|
||||
assert drawer._active_loading == "initial"
|
||||
assert drawer.loading_overlay.isVisible()
|
||||
assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…"
|
||||
assert drawer.loading_overlay.geometry() == drawer.body_scroll.viewport().rect()
|
||||
assert drawer.loading_overlay.spinner._timer.isActive()
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_empty_doctor_state_is_explicit_and_blocks_submit(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1024, 640),
|
||||
repository=_VisualRepository(doctors=False),
|
||||
autoload=True,
|
||||
)
|
||||
|
||||
assert not drawer.doctor_buttons
|
||||
assert drawer.doctor_empty.text() == "暂无可预约医生"
|
||||
assert drawer.doctor_empty.isVisible()
|
||||
assert drawer.time_stack.currentWidget() is drawer.time_empty
|
||||
assert drawer.time_empty.label.text() == "暂无可预约医生"
|
||||
assert drawer.time_empty.illustration.size() == QSize(80, 60)
|
||||
assert drawer.banner.property("kind") == "warning"
|
||||
assert not drawer._initial_loaded
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_empty_roster_state_is_explicit_and_blocks_submit(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repository = _VisualRepository(rosters=False)
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1024, 640),
|
||||
repository=repository,
|
||||
autoload=True,
|
||||
)
|
||||
|
||||
assert list(drawer.doctor_buttons) == [77, 88, 99]
|
||||
assert repository.roster_queries
|
||||
assert not drawer.date_buttons
|
||||
assert drawer.time_stack.currentWidget() is drawer.time_empty
|
||||
assert drawer.time_empty.label.text() == "该医生暂无排班"
|
||||
assert drawer.banner.property("kind") == "warning"
|
||||
assert "未来 7 天暂无可预约排班" in drawer.banner.label.text()
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_refreshing_state_and_generation_ignore_stale_slot_results(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repository = _VisualRepository(today_conflict=False)
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1440, 900),
|
||||
repository=repository,
|
||||
autoload=True,
|
||||
)
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
drawer.date_buttons[tomorrow].click()
|
||||
selected = "09:30-10:00"
|
||||
drawer.slot_buttons[selected].click()
|
||||
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
deferred = _DeferredAsync()
|
||||
drawer._run_async = deferred
|
||||
drawer.refresh_slots_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert len(deferred.calls) == 1
|
||||
assert drawer._active_loading == "slots"
|
||||
assert drawer.loading_overlay.isVisible()
|
||||
assert drawer.loading_overlay.label.text() == "正在刷新可用号源…"
|
||||
assert drawer.refresh_slots_button.text() == "刷新中…"
|
||||
assert not drawer.refresh_slots_button.isEnabled()
|
||||
assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty
|
||||
assert drawer.slot_empty.label.text() == "正在刷新可用号源…"
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
doctor_id = int(drawer.doctor_combo.currentData())
|
||||
appointment_date = str(drawer.date_combo.currentData())
|
||||
stale_generation = drawer._slot_generation
|
||||
drawer._request_slots(doctor_id, appointment_date, restore_selection=selected)
|
||||
current_generation = drawer._slot_generation
|
||||
assert current_generation == stale_generation + 1
|
||||
assert len(deferred.calls) == 2
|
||||
|
||||
deferred.succeed()
|
||||
application.processEvents()
|
||||
assert drawer._slot_generation == current_generation
|
||||
assert drawer.loading_overlay.isVisible()
|
||||
assert not drawer.slot_buttons
|
||||
|
||||
deferred.succeed()
|
||||
application.processEvents()
|
||||
assert drawer.loading_overlay.isHidden()
|
||||
assert drawer.refresh_slots_button.text() == "刷新"
|
||||
assert drawer.refresh_slots_button.isEnabled()
|
||||
assert drawer.slot_combo.currentData() == selected
|
||||
assert drawer.slot_buttons[selected].isChecked()
|
||||
assert drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_slot_error_state_is_visible_and_recoverable(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1024, 640),
|
||||
repository=_VisualRepository(today_conflict=False, slot_error=True),
|
||||
autoload=True,
|
||||
)
|
||||
|
||||
assert drawer.loading_overlay.isHidden()
|
||||
assert drawer.banner.isVisible()
|
||||
assert drawer.banner.property("kind") == "danger"
|
||||
assert "号源加载失败" in drawer.banner.label.text()
|
||||
assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty
|
||||
assert drawer.slot_empty.label.text() == "号源加载失败"
|
||||
assert drawer.refresh_slots_button.text() == "刷新"
|
||||
assert drawer.refresh_slots_button.isEnabled()
|
||||
assert not drawer.slot_buttons
|
||||
assert not drawer.ok_button.isEnabled()
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_keyboard_focus_has_a_visible_state(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host, drawer = _show_drawer(
|
||||
application,
|
||||
(1440, 900),
|
||||
repository=_VisualRepository(today_conflict=False),
|
||||
autoload=True,
|
||||
)
|
||||
focus_target = list(drawer.date_buttons.values())[1]
|
||||
drawer.activateWindow()
|
||||
focus_target.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
application.processEvents()
|
||||
|
||||
assert focus_target.focusPolicy() != Qt.FocusPolicy.NoFocus
|
||||
assert focus_target.hasFocus()
|
||||
assert application.focusWidget() is focus_target
|
||||
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Parity contracts for the admin appointment list port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, PageResult
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages.appointments import (
|
||||
AppointmentsPage,
|
||||
_diagnosis_id,
|
||||
_video_patient_id,
|
||||
prescription_action_label,
|
||||
)
|
||||
from doctor_workstation.ui.shell import _match_navigation
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
def __init__(self) -> None:
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.post_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 {})))
|
||||
if endpoint == "doctor.appointment/detail":
|
||||
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试"}
|
||||
if endpoint == "dept.dept/all":
|
||||
return [{"id": 10, "name": "中医门诊", "children": []}]
|
||||
return {"lists": [], "count": 0, "extend": {"status_count": {"1": 2}}}
|
||||
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
self.post_calls.append((endpoint, dict(payload or {})))
|
||||
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode":
|
||||
return {"qrcode_url": "https://example.test/uploads/video-qr.png"}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_remote_appointment_list_and_detail_hit_admin_endpoints() -> None:
|
||||
client = RecordingClient()
|
||||
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
repo.list_appointments(
|
||||
page_no=1,
|
||||
page_size=15,
|
||||
status=1,
|
||||
start_date="2026-08-11",
|
||||
end_date="2026-08-11",
|
||||
include_status_counts=1,
|
||||
diagnosis_confirmed="1",
|
||||
assistant_dept_id=10,
|
||||
patient_name="林",
|
||||
)
|
||||
detail = repo.get_appointment_detail(101)
|
||||
departments = repo.list_departments()
|
||||
|
||||
assert client.get_calls[0][0] == "doctor.appointment/lists"
|
||||
assert client.get_calls[0][1]["include_status_counts"] == 1
|
||||
assert client.get_calls[0][1]["assistant_dept_id"] == 10
|
||||
assert "diag_scope_relax" not in client.get_calls[0][1]
|
||||
assert client.get_calls[1] == ("doctor.appointment/detail", {"id": 101})
|
||||
assert client.get_calls[2] == ("dept.dept/all", {})
|
||||
assert detail["id"] == 101
|
||||
assert departments[0]["id"] == 10
|
||||
|
||||
|
||||
def test_unlimited_date_relaxes_today_scope_for_pending_status() -> None:
|
||||
client = RecordingClient()
|
||||
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
repo.list_appointments(status=1, diag_scope_relax=1, page_no=1, page_size=15)
|
||||
params = client.get_calls[0][1]
|
||||
assert "start_date" not in params
|
||||
assert "end_date" not in params
|
||||
assert "diag_scope_relax" not in params
|
||||
|
||||
|
||||
def test_video_qr_uses_doctor_id_without_fake_diagnosis_id() -> None:
|
||||
client = RecordingClient()
|
||||
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
result = repo.generate_video_qrcode(
|
||||
diagnosis_id=501,
|
||||
doctor_id=88,
|
||||
patient_id=301,
|
||||
share_user_id=9,
|
||||
)
|
||||
|
||||
endpoint, payload = client.post_calls[-1]
|
||||
assert endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
|
||||
assert payload == {
|
||||
"diagnosis_id": 501,
|
||||
"doctor_id": 88,
|
||||
"patient_id": 301,
|
||||
"share_user_id": 9,
|
||||
"mini_program_path": "pages/login/login",
|
||||
}
|
||||
assert result["qrcode_url"].endswith("video-qr.png")
|
||||
|
||||
|
||||
def test_legacy_empty_string_prescription_response_is_scoped_to_not_found() -> None:
|
||||
class LegacyClient(RecordingClient):
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
if endpoint == "tcm.prescription/getByAppointment":
|
||||
raise ApiProtocolError("envelope must be an object", data="")
|
||||
return super().get(endpoint, params)
|
||||
|
||||
repo = RemoteDoctorRepository(LegacyClient()) # type: ignore[arg-type]
|
||||
assert repo.get_prescription_by_appointment(101) is None
|
||||
|
||||
class BrokenClient(LegacyClient):
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
raise ApiProtocolError("invalid JSON")
|
||||
|
||||
broken = RemoteDoctorRepository(BrokenClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ApiProtocolError, match="invalid JSON"):
|
||||
broken.get_prescription_by_appointment(101)
|
||||
|
||||
|
||||
def test_shell_matches_appointment_list_by_route_not_shared_permission() -> None:
|
||||
row = {
|
||||
"name": "挂号列表",
|
||||
"paths": "/appointments",
|
||||
"component": "tcm/appointment/list",
|
||||
"perms": "doctor.appointment/lists",
|
||||
}
|
||||
item = _match_navigation(row)
|
||||
assert item is not None
|
||||
assert item.key == "appointments"
|
||||
|
||||
reception = {
|
||||
"name": "接诊台",
|
||||
"paths": "/reception",
|
||||
"component": "patient/reception/index",
|
||||
"perms": "doctor.appointment/lists",
|
||||
}
|
||||
assert _match_navigation(reception).key == "reception" # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
|
||||
row = Appointment.from_dict(
|
||||
{
|
||||
"id": 101,
|
||||
"patient_id": 301,
|
||||
"diagnosis_id": 501,
|
||||
"source_patient_id": 301,
|
||||
"status": 1,
|
||||
"has_prescription": 0,
|
||||
}
|
||||
)
|
||||
assert _diagnosis_id(row) == 501
|
||||
assert _video_patient_id(row) == 301
|
||||
assert prescription_action_label(row) == "开方"
|
||||
|
||||
approved = Appointment.from_dict(
|
||||
{
|
||||
"id": 102,
|
||||
"prescription_audit_status": 1,
|
||||
"prescription_void_status": 0,
|
||||
"has_prescription": 1,
|
||||
}
|
||||
)
|
||||
assert prescription_action_label(approved) == "查看"
|
||||
|
||||
pending = Appointment.from_dict(
|
||||
{
|
||||
"id": 103,
|
||||
"prescription_audit_status": 0,
|
||||
"prescription_void_status": 0,
|
||||
"has_prescription": 1,
|
||||
}
|
||||
)
|
||||
assert prescription_action_label(pending) == "编辑处方"
|
||||
|
||||
|
||||
def test_appointment_pending_prescription_uses_full_edit_contract(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
updated: list[tuple[int, dict[str, Any]]] = []
|
||||
existing = {
|
||||
"id": 81,
|
||||
"diagnosis_id": 501,
|
||||
"audit_status": 0,
|
||||
"void_status": 0,
|
||||
"patient_name": "林晓岚",
|
||||
}
|
||||
|
||||
class Repository:
|
||||
def update_prescription(
|
||||
self, prescription: int, changes: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
updated.append((prescription, dict(changes)))
|
||||
return {"id": prescription}
|
||||
|
||||
class AcceptedEditor:
|
||||
def __init__(self, _repository: Any, source: Any, **kwargs: Any) -> None:
|
||||
assert source is existing
|
||||
assert kwargs["mode"] == "edit"
|
||||
|
||||
def exec(self) -> QDialog.DialogCode:
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {"id": 81, "clinical_diagnosis": "脾气虚"}
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(appointments_module, "PrescriptionEditorDialog", AcceptedEditor)
|
||||
monkeypatch.setattr(appointments_module, "run_async", run_immediately)
|
||||
page = AppointmentsPage(Repository(), permissions=PermissionSet(["*"]))
|
||||
monkeypatch.setattr(page, "refresh", lambda **_kwargs: None)
|
||||
|
||||
page._prescription_loaded(existing, {"id": 101}, page._prescription_generation)
|
||||
|
||||
assert updated == [(81, {"id": 81, "clinical_diagnosis": "脾气虚"})]
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointment_prescription_seed_keeps_admin_observation_fields(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
seed = page._prescription_seed(
|
||||
{"id": 101, "appointment_id": 101, "diagnosis_id": 501},
|
||||
{
|
||||
"id": 501,
|
||||
"patient_name": "林晓岚",
|
||||
"tongue": "面象哨兵",
|
||||
"tongue_image": "舌象哨兵",
|
||||
"pulse": "脉象哨兵",
|
||||
"pulse_condition": "脉象详情哨兵",
|
||||
},
|
||||
)
|
||||
assert seed["tongue"] == "面象哨兵"
|
||||
assert seed["tongue_image"] == "舌象哨兵"
|
||||
assert seed["pulse"] == "脉象哨兵"
|
||||
assert seed["pulse_condition"] == "脉象详情哨兵"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointments_page_default_query_is_today_pending(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repo = DemoDoctorRepository()
|
||||
page = AppointmentsPage(
|
||||
repo,
|
||||
permissions=PermissionSet(
|
||||
[
|
||||
"doctor.appointment/lists",
|
||||
"doctor.appointment/complete",
|
||||
"doctor.appointment/cancel",
|
||||
"doctor.appointment/prescription",
|
||||
"doctor.appointment/addDoctorNote",
|
||||
"tcm.diagnosis/edit",
|
||||
"tcm.diagnosis/kaifang",
|
||||
"tcm.diagnosis/videoQr",
|
||||
]
|
||||
),
|
||||
current_user={"id": 1001, "name": "陈医生", "role_id": 1},
|
||||
)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
filters = page._query_filters()
|
||||
assert filters["status"] == 1
|
||||
assert filters["include_status_counts"] == 1
|
||||
assert filters["start_date"] == filters["end_date"]
|
||||
assert "diag_scope_relax" not in filters
|
||||
assert page.table.rowCount() >= 1
|
||||
|
||||
|
||||
def test_demo_appointment_status_counts_respect_date_scope() -> None:
|
||||
repo = DemoDoctorRepository()
|
||||
today = repo._today.isoformat()
|
||||
result = repo.list_appointments(
|
||||
page_no=1,
|
||||
page_size=20,
|
||||
start_date=today,
|
||||
end_date=today,
|
||||
include_status_counts=1,
|
||||
)
|
||||
assert isinstance(result, PageResult)
|
||||
counts = result.extend.get("status_count", {})
|
||||
assert int(counts.get("1", counts.get(1, 0))) >= 1
|
||||
assert int(counts.get("3", counts.get(3, 0))) >= 1
|
||||
|
||||
|
||||
def test_appointment_multiline_cells_receive_enough_row_height(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["doctor.appointment/lists"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
page._loaded(
|
||||
{
|
||||
"lists": [
|
||||
{
|
||||
"id": 15534,
|
||||
"patient_name": "张玉英",
|
||||
"patient_phone": "13800138000",
|
||||
"gender": 0,
|
||||
"age": 42,
|
||||
"height": 162,
|
||||
"weight": 55,
|
||||
"doctor_name": "徐国军",
|
||||
"appointment_date": "2026-08-11",
|
||||
"appointment_time": "14:30",
|
||||
"assistant_name": "蒋露露",
|
||||
"diagnosis_confirmed": 0,
|
||||
"has_prescription": 0,
|
||||
"status": 1,
|
||||
"status_desc": "已预约",
|
||||
"remark": "—",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
},
|
||||
page._generation,
|
||||
False,
|
||||
)
|
||||
|
||||
patient_text = page.table.item(0, 2).text()
|
||||
appointment_text = page.table.item(0, 4).text()
|
||||
assert patient_text == "张玉英"
|
||||
assert appointment_text.count("\n") == 2
|
||||
assert "2026-08-11 14:30" in appointment_text
|
||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||
assert page.table.rowHeight(0) >= required
|
||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||
page.close()
|
||||
|
||||
|
||||
def test_video_qr_dialog_renders_downloaded_image_inside_app(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repo = DemoDoctorRepository()
|
||||
page = AppointmentsPage(
|
||||
repo,
|
||||
permissions=PermissionSet(["tcm.diagnosis/videoQr"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
url = "https://demo.invalid/qrcode/video/88.png"
|
||||
dialog = page._build_qr_dialog(
|
||||
{"patient_name": "鹿立核"},
|
||||
url,
|
||||
{"qrcode_url": url, "_image_bytes": repo.download_public_image(url)},
|
||||
)
|
||||
|
||||
image = dialog.findChild(QLabel, "VideoQrImage")
|
||||
assert image is not None
|
||||
assert image.pixmap() is not None and not image.pixmap().isNull()
|
||||
assert dialog.findChild(QLabel, "VideoQrImage").text() == ""
|
||||
assert dialog.objectName() == "VideoQrDialog"
|
||||
assert dialog.property("businessDialog") is True
|
||||
buttons = dialog.findChild(QDialogButtonBox)
|
||||
assert next(button for button in buttons.buttons() if button.text() == "浏览器打开").property(
|
||||
"variant"
|
||||
) == "primary"
|
||||
dialog.close()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_page_owned_appointment_dialogs_have_stable_visual_contract(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(
|
||||
["doctor.appointment/complete", "doctor.appointment/addDoctorNote"]
|
||||
),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
captured: list[QDialog] = []
|
||||
|
||||
def reject_dialog(dialog: QDialog) -> QDialog.DialogCode:
|
||||
captured.append(dialog)
|
||||
return QDialog.DialogCode.Rejected
|
||||
|
||||
monkeypatch.setattr(QDialog, "exec", reject_dialog)
|
||||
page._open_custom_date()
|
||||
page._show_detail({"id": 101, "patient_name": "鹿立核"}, page._action_generation)
|
||||
page.table.set_rows(
|
||||
[{"id": 101, "diagnosis_id": 501, "patient_id": 301, "status": 1}]
|
||||
)
|
||||
page.table.selectRow(0)
|
||||
page._complete_selected()
|
||||
|
||||
assert [dialog.objectName() for dialog in captured] == [
|
||||
"AppointmentCustomDateDialog",
|
||||
"AppointmentDetailDialog",
|
||||
"AppointmentCompleteDialog",
|
||||
]
|
||||
assert all(dialog.property("businessDialog") is True for dialog in captured)
|
||||
assert all(
|
||||
any(label.property("dialogRole") == "title" for label in dialog.findChildren(QLabel))
|
||||
for dialog in captured
|
||||
)
|
||||
custom_buttons = captured[0].findChild(QDialogButtonBox)
|
||||
complete_buttons = captured[2].findChild(QDialogButtonBox)
|
||||
assert custom_buttons.button(QDialogButtonBox.StandardButton.Ok).property(
|
||||
"variant"
|
||||
) == "primary"
|
||||
assert complete_buttons.button(QDialogButtonBox.StandardButton.Ok).property(
|
||||
"variant"
|
||||
) == "primary"
|
||||
|
||||
for dialog in captured:
|
||||
dialog.close()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
requested: list[int] = []
|
||||
opened: list[tuple[Any, Any]] = []
|
||||
|
||||
class Repository:
|
||||
def get_diagnosis_detail(self, *, diagnosis_id: int) -> dict[str, Any]:
|
||||
requested.append(diagnosis_id)
|
||||
return {"id": diagnosis_id, "patient_name": "测试患者"}
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
callbacks["on_success"](function())
|
||||
except Exception as error: # pragma: no cover - assertion output is clearer
|
||||
callbacks["on_error"](error)
|
||||
finally:
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(appointments_module, "run_async", run_immediately)
|
||||
page = AppointmentsPage(
|
||||
Repository(),
|
||||
permissions=PermissionSet(["tcm.diagnosis/kaifang"]),
|
||||
current_user={"id": 9, "role_id": 1},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
page,
|
||||
"_open_prescription_editor",
|
||||
lambda record, detail: opened.append((record, detail)),
|
||||
)
|
||||
|
||||
page._begin_case_record_load(
|
||||
{"id": 101, "appointment_id": 101, "diagnosis_id": 501, "patient_id": 501}
|
||||
)
|
||||
|
||||
assert requested == [501]
|
||||
assert opened and opened[0][1]["id"] == 501
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointment_ai_report_button_visible_with_reception_permission(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
hidden = AppointmentsPage(
|
||||
SimpleNamespace(),
|
||||
permissions=PermissionSet(["tcm.diagnosis/kaifang"]),
|
||||
)
|
||||
assert hidden.ai_button.isHidden()
|
||||
hidden.close()
|
||||
|
||||
page = AppointmentsPage(
|
||||
SimpleNamespace(),
|
||||
permissions=PermissionSet(["doctor.appointment/reception"]),
|
||||
)
|
||||
assert not page.ai_button.isHidden()
|
||||
assert not page.ai_button.isEnabled()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointments_reference_split_layout_and_video_list(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = AppointmentsPage(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["doctor.appointment/lists"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
page.resize(1460, 820)
|
||||
page._loaded(
|
||||
{
|
||||
"lists": [
|
||||
{
|
||||
"id": 101,
|
||||
"diagnosis_id": 501,
|
||||
"patient_name": "赵俊霞",
|
||||
"gender": 0,
|
||||
"age": 53,
|
||||
"assistant_name": "自媒体4",
|
||||
"appointment_date": "2026-08-13",
|
||||
"appointment_time": "09:50",
|
||||
"status": 1,
|
||||
"status_desc": "已挂号",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
},
|
||||
page._generation,
|
||||
False,
|
||||
)
|
||||
application.processEvents()
|
||||
|
||||
assert page.video_list.count() == 1
|
||||
assert "赵俊霞" in page.video_list.item(0).text()
|
||||
assert page.video_list.parentWidget().width() == 420
|
||||
assert page.table.objectName() == "AppointmentTable"
|
||||
assert page.date_buttons["today"].isChecked()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QObject, QSettings
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.login import LoginWindow
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.widgets import BusyOverlay
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _visible_windows(application: QApplication) -> list[QWidget]:
|
||||
return [
|
||||
widget
|
||||
for widget in application.topLevelWidgets()
|
||||
if widget.isWindow() and widget.isVisible()
|
||||
]
|
||||
|
||||
|
||||
def test_busy_overlay_children_are_not_windows(application: QApplication) -> None:
|
||||
host = QWidget()
|
||||
host.resize(360, 240)
|
||||
host.show()
|
||||
application.processEvents()
|
||||
before = {id(widget) for widget in _visible_windows(application)}
|
||||
|
||||
overlay = BusyOverlay(host, "正在验证账号…")
|
||||
overlay.setVisible(True)
|
||||
application.processEvents()
|
||||
|
||||
assert overlay.parentWidget() is host
|
||||
assert not overlay.isWindow()
|
||||
assert overlay.progress.objectName() == "BusyOverlayProgress"
|
||||
assert overlay.progress.parentWidget() is overlay
|
||||
assert overlay.label.parentWidget() is overlay
|
||||
assert not overlay.progress.isWindow()
|
||||
assert not overlay.label.isWindow()
|
||||
extra = [
|
||||
widget
|
||||
for widget in _visible_windows(application)
|
||||
if id(widget) not in before and widget is not host
|
||||
]
|
||||
assert extra == []
|
||||
|
||||
overlay.setVisible(False)
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_login_loading_does_not_spawn_extra_windows(
|
||||
application: QApplication, tmp_path: Any
|
||||
) -> None:
|
||||
settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat)
|
||||
window = LoginWindow(
|
||||
object(),
|
||||
config=SimpleNamespace(
|
||||
api_base_url="https://127.0.0.1:9",
|
||||
request_timeout=30,
|
||||
demo_mode=False,
|
||||
remembered_account="admin",
|
||||
),
|
||||
settings=settings,
|
||||
)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
before = {id(widget) for widget in _visible_windows(application)}
|
||||
|
||||
window._set_loading(True)
|
||||
application.processEvents()
|
||||
|
||||
extra = [widget for widget in _visible_windows(application) if id(widget) not in before]
|
||||
assert extra == []
|
||||
assert window.busy_overlay.isVisible()
|
||||
assert not window.busy_overlay.isWindow()
|
||||
assert not window.busy_overlay.progress.isWindow()
|
||||
assert window.busy_overlay.label.text() == "正在验证账号…"
|
||||
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_construction_never_shows_orphan_business_controls(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
shown: list[tuple[str, str]] = []
|
||||
|
||||
class OrphanShowRecorder(QObject):
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
if (
|
||||
event.type() == QEvent.Type.Show
|
||||
and isinstance(watched, QWidget)
|
||||
and watched.parentWidget() is None
|
||||
):
|
||||
text = getattr(watched, "text", lambda: "")()
|
||||
shown.append((type(watched).__name__, str(text)))
|
||||
return False
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
recorder = OrphanShowRecorder(application)
|
||||
application.installEventFilter(recorder)
|
||||
try:
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{
|
||||
"session": session,
|
||||
"user": session.user,
|
||||
"demo_mode": True,
|
||||
},
|
||||
permissions=session.permissions,
|
||||
)
|
||||
finally:
|
||||
application.removeEventFilter(recorder)
|
||||
|
||||
assert shown == []
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.config import AppConfig, normalize_api_base_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("https://api.example.com", "https://api.example.com/adminapi"),
|
||||
("https://api.example.com/", "https://api.example.com/adminapi"),
|
||||
("https://api.example.com/adminapi", "https://api.example.com/adminapi"),
|
||||
("http://127.0.0.1:8080/gateway", "http://127.0.0.1:8080/gateway/adminapi"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_normalize_api_base_url(raw: str, expected: str) -> None:
|
||||
assert normalize_api_base_url(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
["api.example.com", "ftp://api.example.com", "https://u:p@example.com", "https://x.test?a=1"],
|
||||
)
|
||||
def test_normalize_api_base_url_rejects_unsafe_values(raw: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
normalize_api_base_url(raw)
|
||||
|
||||
|
||||
def test_config_update_validates_video_mode() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AppConfig().with_updates(video_mode="unknown")
|
||||
|
||||
|
||||
def test_config_update_normalizes_ssl_boolean_strings() -> None:
|
||||
assert AppConfig().with_updates(verify_ssl="false").verify_ssl is False
|
||||
assert AppConfig(verify_ssl=False).with_updates(verify_ssl="true").verify_ssl is True
|
||||
|
||||
|
||||
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
log_dir = tmp_path / "logs"
|
||||
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(config_dir))
|
||||
monkeypatch.setenv("DOCTOR_LOG_DIR", str(log_dir))
|
||||
|
||||
config = AppConfig()
|
||||
|
||||
assert config.config_dir == config_dir
|
||||
assert config.log_dir == log_dir
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
"""Exact endpoint and fail-closed tests for diagnosis-detail mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
"""Minimal no-network client that preserves exact endpoint DTOs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.post_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
body = dict(params or {})
|
||||
self.get_calls.append((endpoint, body))
|
||||
if endpoint == "tcm.diagnosis/getImChatMessages":
|
||||
return {"lists": [], "patient_im_id": "patient_301"}
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
body = dict(payload or {})
|
||||
self.post_calls.append((endpoint, body))
|
||||
if endpoint == "tcm.diagnosis/createManualCallRecord":
|
||||
return {"id": 88}
|
||||
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode":
|
||||
return {"qrcode_url": "https://example.invalid/mini.png"}
|
||||
if endpoint == "tcm.diagnosis/generateOrderQrcode":
|
||||
return {"qrcode_url": "https://example.invalid/order.png"}
|
||||
if endpoint == "order.order/create":
|
||||
return {"id": 99, "order_no": "ORDER99"}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_remote_detail_actions_use_exact_confirmed_endpoints() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
today = date.today().isoformat()
|
||||
|
||||
repository.set_revisit_slot_start_offset(501, 4)
|
||||
repository.add_blood_record(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
record_date=today,
|
||||
record_time="09:30",
|
||||
fasting_blood_sugar=6.2,
|
||||
)
|
||||
repository.add_diet_record(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
record_date=today,
|
||||
breakfast_foods="燕麦",
|
||||
breakfast_images=[],
|
||||
lunch_images=[],
|
||||
dinner_images=[],
|
||||
)
|
||||
repository.add_exercise_record(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
record_date=today,
|
||||
exercise_type="步行",
|
||||
duration=30,
|
||||
intensity=2,
|
||||
images=[],
|
||||
)
|
||||
repository.add_tracking_note(501, "继续观察")
|
||||
repository.add_diagnosis_todo(501, "复测餐后血糖", int(time.time()) + 120)
|
||||
repository.cancel_diagnosis_todo(77)
|
||||
repository.list_call_records(501)
|
||||
repository.create_manual_call_record(501)
|
||||
repository.attach_local_call_recording(
|
||||
501, "https://media.example.invalid/replay.mp4", call_record_id=88
|
||||
)
|
||||
assert repository.list_im_chat_messages(501)["lists"] == []
|
||||
repository.sync_im_chat_messages(501)
|
||||
repository.list_appointment_logs(501)
|
||||
repository.generate_video_qrcode(1001, 301, 1001)
|
||||
repository.generate_diagnosis_qrcode(501, 1001, 301, 1001)
|
||||
repository.create_diagnosis_order(301, 2, 88.6, remark="检查费")
|
||||
repository.generate_order_qrcode("ORDER99")
|
||||
repository.cancel_diagnosis_appointment(101)
|
||||
|
||||
assert ("tcm.diagnosis/getCallRecords", {"diagnosis_id": 501}) in client.get_calls
|
||||
assert (
|
||||
"tcm.diagnosis/getImChatMessages",
|
||||
{"diagnosis_id": 501, "only_archived": 1},
|
||||
) in client.get_calls
|
||||
assert ("tcm.diagnosis/guahaoLogList", {"id": 501}) in client.get_calls
|
||||
assert (
|
||||
"doctor.appointment/cancel",
|
||||
{"id": 101},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.diagnosis/setRevisitSlotStartOffset",
|
||||
{"id": 501, "revisit_slot_start_offset": 4},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"order.order/create",
|
||||
{"patient_id": 301, "order_type": 2, "amount": 88.6, "remark": "检查费"},
|
||||
) in client.post_calls
|
||||
qr_payloads = [
|
||||
body
|
||||
for endpoint, body in client.post_calls
|
||||
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
|
||||
]
|
||||
assert qr_payloads[0] == {
|
||||
"doctor_id": 1001,
|
||||
"patient_id": 301,
|
||||
"share_user_id": 1001,
|
||||
"mini_program_path": "pages/login/login",
|
||||
}
|
||||
assert qr_payloads[1] == {
|
||||
"diagnosis_id": 501,
|
||||
"doctor_id": 1001,
|
||||
"patient_id": 301,
|
||||
"share_user_id": 1001,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call", "message"),
|
||||
[
|
||||
(lambda repo: repo.set_revisit_slot_start_offset(501, 21), "between 0 and 20"),
|
||||
(lambda repo: repo.add_tracking_note(501, ""), "1 to 1000"),
|
||||
(
|
||||
lambda repo: repo.add_diagnosis_todo(501, "稍后", int(time.time()) + 5),
|
||||
"30 seconds",
|
||||
),
|
||||
(lambda repo: repo.cancel_diagnosis_todo(0), "positive"),
|
||||
(lambda repo: repo.create_diagnosis_order(301, 9, 1), "between 1 and 8"),
|
||||
(lambda repo: repo.cancel_diagnosis_appointment(0), "positive"),
|
||||
],
|
||||
)
|
||||
def test_remote_detail_validation_fails_before_transport(call: Any, message: str) -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
call(repository)
|
||||
assert client.get_calls == []
|
||||
assert client.post_calls == []
|
||||
|
||||
|
||||
def test_demo_detail_mutations_round_trip(tmp_path: Path) -> None:
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
blood = repository.add_blood_record(
|
||||
diagnosis_id=501,
|
||||
patient_id=301,
|
||||
record_date="2026-08-10",
|
||||
fasting_blood_sugar=5.8,
|
||||
)
|
||||
assert blood["id"] > 0
|
||||
assert (
|
||||
repository.get_tracking_window(501, start_date="2026-08-10", end_date="2026-08-10")[
|
||||
"blood_records"
|
||||
][-1]["fasting_blood_sugar"]
|
||||
== 5.8
|
||||
)
|
||||
|
||||
todo = repository.add_diagnosis_todo(501, "今晚回访", int(time.time()) + 120)
|
||||
cancelled = repository.cancel_diagnosis_todo(todo["id"])
|
||||
assert cancelled["status_text"] == "已取消"
|
||||
|
||||
replay = tmp_path / "replay.mp4"
|
||||
replay.write_bytes(b"demo-video")
|
||||
uploaded = repository.upload_call_recording(replay, 501)
|
||||
assert uploaded["file_url"].startswith("/demo/uploads/video/")
|
||||
assert uploaded["file_url"] in repository.list_call_records(501)[0]["recording_urls_list"]
|
||||
|
||||
archive = repository.list_im_chat_messages(501, only_archived=True)
|
||||
assert archive["only_archived"] is True
|
||||
assert {row["msg_type"] for row in archive["lists"]} >= {"text", "image", "file"}
|
||||
assert repository.sync_im_chat_messages(501)["queued"] is True
|
||||
|
||||
repository.set_revisit_slot_start_offset(501, 7)
|
||||
assert repository.get_diagnosis_detail(501)["diagnosis"]["revisit_slot_start_offset"] == 7
|
||||
assert repository.cancel_diagnosis_appointment(101)["status"] == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,695 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DIAGNOSIS_INDEX_QSS,
|
||||
DiagnosisItemDelegate,
|
||||
DiagnosisTableModel,
|
||||
)
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
|
||||
|
||||
class _ListDiagnosisDialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
def open_for(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _CancellationRepository:
|
||||
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
|
||||
del appointment_id
|
||||
|
||||
|
||||
class _FullMenuRepository(_CancellationRepository):
|
||||
def generate_video_qrcode(
|
||||
self,
|
||||
doctor_id: int,
|
||||
patient_id: int,
|
||||
share_user_id: int,
|
||||
*,
|
||||
diagnosis_id: int,
|
||||
) -> dict[str, str]:
|
||||
del diagnosis_id, doctor_id, patient_id, share_user_id
|
||||
return {"qrcode_url": "https://example.invalid/video.png"}
|
||||
|
||||
def generate_diagnosis_qrcode(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
doctor_id: int,
|
||||
patient_id: int,
|
||||
share_user_id: int,
|
||||
) -> dict[str, str]:
|
||||
del diagnosis_id, doctor_id, patient_id, share_user_id
|
||||
return {"qrcode_url": "https://example.invalid/confirm.png"}
|
||||
|
||||
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||
del diagnosis_id
|
||||
return []
|
||||
|
||||
def create_diagnosis_order(
|
||||
self,
|
||||
patient_id: int,
|
||||
order_type: int,
|
||||
amount: float,
|
||||
*,
|
||||
remark: str = "",
|
||||
) -> dict[str, Any]:
|
||||
del patient_id, order_type, amount, remark
|
||||
return {"order_no": "ORDER-715"}
|
||||
|
||||
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
|
||||
del order_no
|
||||
return {"qrcode_url": "https://example.invalid/order.png"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_list_from_detail_dialog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(consultations_module, "DiagnosisDialog", _ListDiagnosisDialog)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = 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(consultations_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _row(identifier: int, **changes: Any) -> dict[str, Any]:
|
||||
row: dict[str, Any] = {
|
||||
"id": identifier,
|
||||
"diagnosis_id": identifier,
|
||||
"patient_id": identifier + 1000,
|
||||
"patient_name": f"患者{identifier}",
|
||||
"gender": 1,
|
||||
"age": 38,
|
||||
"has_appointment": 1,
|
||||
"appointment_id": identifier + 2000,
|
||||
"appointment_status": 1,
|
||||
"appointments": [
|
||||
{
|
||||
"id": identifier + 2000,
|
||||
"status": 1,
|
||||
"doctor_name": "陈医生",
|
||||
"time_text": "今天 09:00-09:30",
|
||||
}
|
||||
],
|
||||
"DiagnosisViewRecord": [{"is_confirmed": 1}],
|
||||
"assistant_id": 8,
|
||||
"assistant_name": "赵医助",
|
||||
"assign_read_at": None,
|
||||
"has_prescription": 1,
|
||||
"followup_time_text": "2026-08-17 09:00",
|
||||
"followup_doctor_name": "陈医生",
|
||||
"unserved_days": 2,
|
||||
"last_blood_record_at": "2026-08-09 20:10",
|
||||
}
|
||||
row.update(changes)
|
||||
return row
|
||||
|
||||
|
||||
def _page() -> ConsultationsPage:
|
||||
return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"]))
|
||||
|
||||
|
||||
def test_visual_hierarchy_and_filter_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
content_layout = page.page_scroll.widget().layout()
|
||||
margins = content_layout.contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (20, 18, 29, 16)
|
||||
assert content_layout.spacing() == 12
|
||||
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
|
||||
assert status_card is not None
|
||||
assert status_card.height() == 62
|
||||
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
|
||||
assert page.findChild(QFrame, "DiagnosisListCard") is not None
|
||||
assert page.filters_card.height() == 108
|
||||
assert page.keyword_edit.maximumWidth() == 380
|
||||
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
|
||||
assert page.status_buttons["1"].isChecked()
|
||||
assert not page.advanced_filters.isVisible()
|
||||
assert page.more_filter_button.text() == "更多筛选"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.DownArrow
|
||||
assert [page._date_button_labels[key] for key in page.date_buttons] == [
|
||||
"昨天挂号",
|
||||
"前天挂号",
|
||||
"当天挂号",
|
||||
"明天挂号",
|
||||
"后天挂号",
|
||||
"全部",
|
||||
]
|
||||
assert page.date_buttons[page._appointment_date].isChecked()
|
||||
assert page.pending_assign_month.width() == 128
|
||||
assert page.pending_assign_keyword.width() == 220
|
||||
assert page.pending_assign_keyword.placeholderText() == "搜身份证/诊单ID/患者号/备注…"
|
||||
assert page.pending_assign_button.parentWidget() is page.pending_assign_wrap
|
||||
assert page.pending_assign_filters.parentWidget() is page.pending_assign_wrap
|
||||
assert page.batch_assign_button.text() == "批量指派医助"
|
||||
assert "PageHeader" not in {type(widget).__name__ for widget in page.findChildren(QFrame)}
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_pending_assign_and_secondary_chip_semantics(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = _page()
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page._choose_pending_assign()
|
||||
assert page.pending_assign_button.isChecked()
|
||||
assert not page.date_buttons[page._appointment_date].isChecked()
|
||||
assert not page.pending_assign_filters.isHidden()
|
||||
page.pending_assign_keyword.setText("订单-88")
|
||||
filters = page._filters()
|
||||
assert filters == {
|
||||
"pending_assign": "1",
|
||||
"pending_assign_keyword": "订单-88",
|
||||
}
|
||||
page.pending_assign_keyword.clear()
|
||||
filters = page._filters()
|
||||
assert filters["pending_assign_order_month"] == page.pending_assign_month.text().strip()
|
||||
assert filters["pending_assign_keyword"] == ""
|
||||
page._choose_appointment_filter("0")
|
||||
assert page.appointment_filter_buttons["0"].isChecked()
|
||||
assert page._combo_value(page.has_appointment_combo) == "0"
|
||||
page._toggle_advanced_filters(True)
|
||||
assert not page.advanced_filters.isHidden()
|
||||
assert page.more_filter_button.text() == "收起"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.UpArrow
|
||||
assert page.unserved_sort_combo.isHidden()
|
||||
date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange")
|
||||
assert len(date_ranges) == 2
|
||||
assert all(field.width() == 260 for field in date_ranges)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
assert isinstance(page.table.model(), QAbstractTableModel)
|
||||
assert isinstance(page.table.model(), DiagnosisTableModel)
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 340)
|
||||
assert page.table_host.fixed.width() == 462
|
||||
assert page.table.isColumnHidden(10)
|
||||
assert page.table_host.fixed.isColumnHidden(9)
|
||||
assert not page.table_host.fixed.isColumnHidden(10)
|
||||
assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
|
||||
rows = [_row(501), _row(502, has_appointment=0, appointments=[])]
|
||||
page.table_host.set_rows(rows)
|
||||
model = page.table_host.model
|
||||
assert model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
assert page.table_host.selected_records() == [rows[0]]
|
||||
assert page.selected_label.text() == "已选 1 条"
|
||||
|
||||
requested: list[str] = []
|
||||
page.table_host.sort_unserved_requested.connect(requested.append)
|
||||
assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数"
|
||||
assert page.table_host.model._sort_direction == ""
|
||||
page.table_host.main.horizontalHeader().sectionClicked.emit(9)
|
||||
assert requested == ["desc"]
|
||||
assert page.table_host.model._sort_direction == "desc"
|
||||
|
||||
page.table_host.action_requested.disconnect(page._row_action)
|
||||
actions: list[tuple[str, Any]] = []
|
||||
page.table_host.action_requested.connect(
|
||||
lambda action, record: actions.append((action, record))
|
||||
)
|
||||
action_cell = page.table_host.fixed.indexWidget(model.index(0, 11))
|
||||
second_fixed_cell = page.table_host.fixed.indexWidget(model.index(1, 11))
|
||||
second_fixed_cell.hovered_row.emit(1)
|
||||
assert model.hover_row == 1
|
||||
second_fixed_cell.hovered_row.emit(-1)
|
||||
assert model.hover_row == -1
|
||||
direct_links = {
|
||||
button.text() for button in action_cell.findChildren(QToolButton) if button.menu() is None
|
||||
}
|
||||
assert {"查看", "诊单", "开方", "预约", "补全身份证"} <= direct_links
|
||||
more = next(button for button in action_cell.findChildren(QToolButton) if button.menu())
|
||||
menu_texts = [action.text() for action in more.menu().actions() if not action.isSeparator()]
|
||||
assert "指派" in menu_texts
|
||||
assert "取消挂号" in menu_texts
|
||||
assert {"视频二维码", "二维码", "挂号日志", "创建订单"}.isdisjoint(menu_texts)
|
||||
next(action for action in more.menu().actions() if action.text() == "指派").trigger()
|
||||
assert actions == [("assign", rows[0])]
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
page.table_host.set_rows([])
|
||||
page._loading = True
|
||||
page.resize(1024, 640)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page._loading = False
|
||||
assert page.table_host.empty_label.isVisible()
|
||||
assert page.table.horizontalHeader().isVisible()
|
||||
assert page.table_host.height() >= 39 + 60
|
||||
|
||||
page.loading_overlay.start()
|
||||
application.processEvents()
|
||||
assert page.loading_overlay.isVisible()
|
||||
assert page.loading_overlay.geometry() == page.table_host.rect()
|
||||
page.loading_overlay.stop()
|
||||
|
||||
page.pager.update_state(3, 97)
|
||||
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
|
||||
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
|
||||
assert page.pager.jumper.maximum() == 7
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("record", "stripe", "channel"),
|
||||
[
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#9a6813", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#2f6edb", "info"),
|
||||
],
|
||||
)
|
||||
def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
|
||||
application: QApplication,
|
||||
record: dict[str, Any],
|
||||
stripe: str,
|
||||
channel: str,
|
||||
) -> None:
|
||||
model = DiagnosisTableModel([record])
|
||||
model.set_hover_row(0)
|
||||
image = QImage(48, 52, QImage.Format.Format_ARGB32_Premultiplied)
|
||||
image.fill(QColor("#FFFFFF"))
|
||||
painter = QPainter(image)
|
||||
DiagnosisItemDelegate._paint_row_background(
|
||||
painter,
|
||||
QRect(0, 0, 48, 52),
|
||||
record,
|
||||
0,
|
||||
0,
|
||||
model,
|
||||
)
|
||||
painter.end()
|
||||
|
||||
assert image.pixelColor(0, 20).name() == stripe
|
||||
assert image.pixelColor(1, 20).name() == stripe
|
||||
assert image.pixelColor(2, 20).name() == stripe
|
||||
gradient = image.pixelColor(6, 20)
|
||||
assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row"
|
||||
|
||||
|
||||
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
page.resize(1024, 640)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
shadow = page.table_host.fixed_shadow
|
||||
assert shadow.isHidden()
|
||||
assert shadow.width() == 12
|
||||
assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1
|
||||
assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in (
|
||||
DIAGNOSIS_INDEX_QSS
|
||||
)
|
||||
assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in (
|
||||
DIAGNOSIS_INDEX_QSS
|
||||
)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_multi_appointment_never_exposes_ambiguous_row_cancel(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
single = _row(710)
|
||||
multiple = _row(
|
||||
711,
|
||||
appointments=[
|
||||
{"id": 2711, "status": 1, "doctor_name": "陈医生", "time_text": "09:00"},
|
||||
{
|
||||
"id": 4_294_967_302,
|
||||
"status": 4,
|
||||
"doctor_name": "李医生",
|
||||
"time_text": "10:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
page.table_host.set_rows([single, multiple])
|
||||
|
||||
def menu_texts(row: int) -> set[str]:
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(row, 11))
|
||||
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
|
||||
return {action.text() for action in more.menu().actions() if not action.isSeparator()}
|
||||
|
||||
assert "取消挂号" in menu_texts(0)
|
||||
assert "取消挂号" not in menu_texts(1)
|
||||
|
||||
appointment_cell = page.table_host.main.indexWidget(page.table_host.model.index(1, 4))
|
||||
appointment_buttons = appointment_cell.findChildren(QToolButton)
|
||||
assert [button.text() for button in appointment_buttons] == ["取消", "取消"]
|
||||
assert [button.accessibleName() for button in appointment_buttons] == [
|
||||
"取消挂号 2711",
|
||||
"取消挂号 4294967302",
|
||||
]
|
||||
page.table_host.appointment_cancel_requested.disconnect(page._cancel_appointment_item)
|
||||
requested: list[tuple[Any, int]] = []
|
||||
page.table_host.appointment_cancel_requested.connect(
|
||||
lambda record, appointment_id: requested.append((record, appointment_id))
|
||||
)
|
||||
appointment_buttons[1].click()
|
||||
assert requested == [(multiple, 4_294_967_302)]
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_full_more_menu_requires_each_real_repository_capability(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(["*"]))
|
||||
record = _row(
|
||||
715,
|
||||
appointment_doctor_id=9,
|
||||
appointments=[
|
||||
{
|
||||
"id": 2715,
|
||||
"status": 1,
|
||||
"doctor_id": 9,
|
||||
"doctor_name": "陈医生",
|
||||
"time_text": "09:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
page.table_host.set_rows([record])
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
|
||||
assert [action.text() for action in more.menu().actions() if not action.isSeparator()] == [
|
||||
"指派",
|
||||
"取消指派",
|
||||
"视频二维码",
|
||||
"二维码",
|
||||
"取消挂号",
|
||||
"挂号日志",
|
||||
"创建订单",
|
||||
"删除",
|
||||
]
|
||||
actions = more.menu().actions()
|
||||
menu_actions = [action for action in actions if not action.isSeparator()]
|
||||
assert all(not action.icon().isNull() for action in menu_actions)
|
||||
assert all(action.property("iconSource") == "qpaint" for action in menu_actions)
|
||||
assert actions[-2].isSeparator()
|
||||
assert actions[-1].text() == "删除"
|
||||
assert actions[-1].property("danger") is True
|
||||
|
||||
more.menu().ensurePolished()
|
||||
more.menu().adjustSize()
|
||||
more.menu().show()
|
||||
application.processEvents()
|
||||
danger_rect = more.menu().actionGeometry(actions[-1])
|
||||
danger_image = more.menu().grab().toImage()
|
||||
red_text_pixels = 0
|
||||
for y in range(max(0, danger_rect.top()), min(danger_image.height(), danger_rect.bottom() + 1)):
|
||||
for x in range(
|
||||
max(0, danger_rect.left() + 40),
|
||||
min(danger_image.width(), danger_rect.right() + 1),
|
||||
):
|
||||
color = danger_image.pixelColor(x, y)
|
||||
if color.red() > 190 and color.green() < 150 and color.blue() < 150:
|
||||
red_text_pixels += 1
|
||||
assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色"
|
||||
more.menu().hide()
|
||||
assert page.table_host.action_policy == {
|
||||
"view": True,
|
||||
"edit": True,
|
||||
"prescription": True,
|
||||
"appointment": True,
|
||||
"assign": True,
|
||||
"delete": True,
|
||||
"video_call": False,
|
||||
"appointment_cancel": True,
|
||||
"video_qr": True,
|
||||
"confirm_qr": True,
|
||||
"appointment_logs": True,
|
||||
"create_order": True,
|
||||
}
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_patient_workspace_cancel_endpoint_is_not_a_diagnosis_list_capability(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class PatientWorkspaceOnlyRepository:
|
||||
def cancel_patient_appointment(self, appointment_id: int) -> None:
|
||||
del appointment_id
|
||||
|
||||
page = ConsultationsPage(
|
||||
PatientWorkspaceOnlyRepository(),
|
||||
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
|
||||
)
|
||||
page.table_host.set_rows([_row(716)])
|
||||
assert not page.table_host.action_policy["appointment_cancel"]
|
||||
assert page.table_host.main.indexWidget(page.table_host.model.index(0, 4)) is None
|
||||
action_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
more_buttons = [button for button in action_cell.findChildren(QToolButton) if button.menu()]
|
||||
assert all(button.isHidden() for button in more_buttons)
|
||||
assert all(
|
||||
"取消挂号" not in {action.text() for action in button.menu().actions()}
|
||||
for button in more_buttons
|
||||
)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing_method", ["create_diagnosis_order", "generate_order_qrcode"])
|
||||
def test_order_menu_requires_the_complete_create_and_payment_qr_capability(
|
||||
application: QApplication,
|
||||
missing_method: str,
|
||||
) -> None:
|
||||
repository = _FullMenuRepository()
|
||||
setattr(repository, missing_method, None)
|
||||
page = ConsultationsPage(
|
||||
repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/order"]),
|
||||
)
|
||||
page.table_host.set_rows([_row(717, appointment_doctor_id=9)])
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
more_buttons = [button for button in cell.findChildren(QToolButton) if button.menu()]
|
||||
assert not page.table_host.action_policy["create_order"]
|
||||
assert all(
|
||||
"创建订单" not in {action.text() for action in button.menu().actions()}
|
||||
for button in more_buttons
|
||||
)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permission", "missing_text"),
|
||||
[
|
||||
("tcm.diagnosis/videoQr", "视频二维码"),
|
||||
("tcm.diagnosis/guahao", "二维码"),
|
||||
("tcm.diagnosis/guahaoLogList", "挂号日志"),
|
||||
("tcm.diagnosis/order", "创建订单"),
|
||||
],
|
||||
)
|
||||
def test_menu_capability_without_its_exact_permission_is_hidden(
|
||||
application: QApplication,
|
||||
permission: str,
|
||||
missing_text: str,
|
||||
) -> None:
|
||||
granted = {
|
||||
"tcm.diagnosis/assign",
|
||||
"tcm.diagnosis/delete",
|
||||
"tcm.diagnosis/videoQr",
|
||||
"tcm.diagnosis/guahao",
|
||||
"tcm.diagnosis/guahaoLogList",
|
||||
"tcm.diagnosis/order",
|
||||
}
|
||||
granted.remove(permission)
|
||||
page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(granted))
|
||||
page.table_host.set_rows([_row(718, appointment_doctor_id=9)])
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
|
||||
texts = {action.text() for action in more.menu().actions() if not action.isSeparator()}
|
||||
assert missing_text not in texts
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_permission_crop_removes_unavailable_row_actions(application: QApplication) -> None:
|
||||
page = ConsultationsPage(
|
||||
SimpleNamespace(),
|
||||
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||
)
|
||||
page.table_host.set_rows([_row(720)])
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
visible = {button.text() for button in cell.findChildren(QToolButton) if not button.isHidden()}
|
||||
assert visible == {"查看"}
|
||||
assert not page.table_host.action_policy["video_qr"]
|
||||
assert not page.table_host.action_policy["confirm_qr"]
|
||||
assert not page.table_host.action_policy["appointment_logs"]
|
||||
assert not page.table_host.action_policy["create_order"]
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_video_entry_is_hidden_without_the_native_repository_lifecycle(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
page.table_host.set_rows([_row(725)])
|
||||
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
|
||||
assert video_cell.findChildren(QToolButton) == []
|
||||
assert page.video_button.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_error_state_is_persistent_until_rows_replace_it(application: QApplication) -> None:
|
||||
page = _page()
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.table_host.set_rows([])
|
||||
page.table_host.show_error("列表加载失败,请重试")
|
||||
assert page.table_host.empty_label.isVisible()
|
||||
assert page.table_host.empty_label.property("stateKind") == "error"
|
||||
assert "加载失败" in page.table_host.empty_label.text()
|
||||
page.table_host.set_rows([_row(730)])
|
||||
assert not page.table_host.empty_label.isVisible()
|
||||
assert page.table_host.empty_label.property("stateKind") == "empty"
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping(
|
||||
application: QApplication,
|
||||
size: tuple[int, int],
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)]
|
||||
page.table_host.set_rows(rows)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
assert page.page_scroll.horizontalScrollBar().maximum() == 0
|
||||
assert page.page_scroll.verticalScrollBar().maximum() > 0
|
||||
assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right()
|
||||
assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_required_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_empty_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_error_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_hover_warning_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_focus_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_permissions_cropped_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_horizontal_scroll_1024x640.png": (
|
||||
1024,
|
||||
640,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_pending_assign_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_advanced_filters_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_full_menu_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root / "artifacts" / "diagnosis_visual" / "diagnosis_order_qrcode_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "diagnosis_visual"
|
||||
/ "diagnosis_double_appointment_cancel_1280x800.png": (
|
||||
1280,
|
||||
800,
|
||||
),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
assert path.is_file(), f"run scripts/render_diagnosis_visual.py to create {path.name}"
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull()
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_drawer import (
|
||||
ChatPanel,
|
||||
DailyRecordPanel,
|
||||
NotesTimeline,
|
||||
_RemoteImageButton,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes:
|
||||
image = QImage(width, height, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor(color))
|
||||
buffer = QBuffer()
|
||||
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
assert image.save(buffer, "PNG")
|
||||
return bytes(buffer.data())
|
||||
|
||||
|
||||
class _FakeReply(QObject):
|
||||
finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payload: bytes,
|
||||
error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.payload = payload
|
||||
self.network_error = error
|
||||
self.aborted = False
|
||||
|
||||
def abort(self) -> None:
|
||||
self.aborted = True
|
||||
|
||||
def error(self) -> QNetworkReply.NetworkError:
|
||||
return self.network_error
|
||||
|
||||
def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply
|
||||
return QByteArray(self.payload)
|
||||
|
||||
|
||||
class _FakeManager(QObject):
|
||||
def __init__(self, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = []
|
||||
self.requests: list[str] = []
|
||||
self.replies: list[_FakeReply] = []
|
||||
|
||||
def queue(
|
||||
self,
|
||||
payload: bytes,
|
||||
error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError,
|
||||
) -> None:
|
||||
self.responses.append((payload, error))
|
||||
|
||||
def get(self, request: object) -> _FakeReply:
|
||||
payload, error = self.responses.pop(0)
|
||||
self.requests.append(request.url().toString())
|
||||
reply = _FakeReply(payload, error, self)
|
||||
self.replies.append(reply)
|
||||
return reply
|
||||
|
||||
|
||||
class _RenderOwner(QWidget):
|
||||
def __init__(self, generation: int) -> None:
|
||||
super().__init__()
|
||||
self._image_generation = generation
|
||||
|
||||
|
||||
def _hold_remote_load(self: _RemoteImageButton, source: str) -> None:
|
||||
"""Offline transport used by panel tests; payload completion stays explicit."""
|
||||
|
||||
self._source = str(source).strip()
|
||||
self._invalidate_request()
|
||||
self.setToolTip(self._source)
|
||||
self._show_loading()
|
||||
|
||||
|
||||
def test_remote_image_request_is_thread_owned_and_rejects_stale_results(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
owner = _RenderOwner(7)
|
||||
button = _RemoteImageButton(
|
||||
"",
|
||||
render_owner=owner,
|
||||
owner_generation=7,
|
||||
maximum_size=QSize(64, 64),
|
||||
fallback_text="舌象\n查看",
|
||||
cover=True,
|
||||
object_name="DiagnosisTongueThumb",
|
||||
parent=owner,
|
||||
)
|
||||
button._manager.deleteLater()
|
||||
manager = _FakeManager(button)
|
||||
button._manager = manager
|
||||
manager.queue(_png_bytes(180, 90, "#DC2626"))
|
||||
manager.queue(_png_bytes(180, 90, "#16A34A"))
|
||||
|
||||
button.load_url("https://media.example.invalid/old.png")
|
||||
old_reply = manager.replies[-1]
|
||||
assert button.text() == "舌象\n查看"
|
||||
assert button.property("loadState") == "loading"
|
||||
button.load_url("https://media.example.invalid/current.png")
|
||||
current_reply = manager.replies[-1]
|
||||
assert old_reply.aborted is True
|
||||
|
||||
old_reply.finished.emit()
|
||||
assert button.property("loadState") == "loading"
|
||||
current_reply.finished.emit()
|
||||
assert button.property("loadState") == "ready"
|
||||
assert button._rendered_pixmap.size() == QSize(64, 64)
|
||||
assert button.text() == ""
|
||||
assert manager.parent() is button
|
||||
assert manager.thread() == button.thread() == application.thread()
|
||||
assert manager.requests == [
|
||||
"https://media.example.invalid/old.png",
|
||||
"https://media.example.invalid/current.png",
|
||||
]
|
||||
|
||||
manager.queue(_png_bytes(90, 180, "#2563EB"))
|
||||
button.load_url("https://media.example.invalid/new-owner.png")
|
||||
owner._image_generation += 1
|
||||
manager.replies[-1].finished.emit()
|
||||
assert button.property("loadState") == "loading"
|
||||
|
||||
|
||||
def test_remote_image_uses_text_only_after_request_or_decode_failure(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
owner = _RenderOwner(3)
|
||||
button = _RemoteImageButton(
|
||||
"",
|
||||
render_owner=owner,
|
||||
owner_generation=3,
|
||||
maximum_size=QSize(240, 200),
|
||||
fallback_text="查看图片",
|
||||
cover=False,
|
||||
object_name="DiagnosisChatImage",
|
||||
parent=owner,
|
||||
)
|
||||
button._manager.deleteLater()
|
||||
manager = _FakeManager(button)
|
||||
button._manager = manager
|
||||
manager.queue(b"not-an-image")
|
||||
|
||||
button.load_url("https://media.example.invalid/broken.jpg")
|
||||
assert button.text() == "查看图片"
|
||||
assert button.property("loadState") == "loading"
|
||||
manager.replies[-1].finished.emit()
|
||||
assert button.property("loadState") == "failed"
|
||||
assert button.text() == "查看图片"
|
||||
|
||||
request_count = len(manager.requests)
|
||||
button.load_url("file:///C:/private/image.png")
|
||||
assert len(manager.requests) == request_count
|
||||
assert button.property("loadState") == "failed"
|
||||
assert application.thread() == button.thread()
|
||||
|
||||
|
||||
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||
timeline = NotesTimeline(editable=True)
|
||||
opened: list[str] = []
|
||||
deleted: list[tuple[int, str, str]] = []
|
||||
timeline.open_attachment_requested.connect(opened.append)
|
||||
timeline.delete_attachment_requested.connect(
|
||||
lambda note_id, kind, path: deleted.append((note_id, kind, path))
|
||||
)
|
||||
tongue_url = "https://media.example.invalid/tongue-7001.jpg"
|
||||
timeline.set_notes(
|
||||
[
|
||||
{
|
||||
"id": 7001,
|
||||
"note_date": "2026-08-10",
|
||||
"content": "舌淡红,苔薄白。",
|
||||
"tongue_images": [tongue_url],
|
||||
"report_files": [
|
||||
{
|
||||
"name": "近期血糖趋势.pdf",
|
||||
"url": "https://media.example.invalid/report-7001.pdf",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
thumb = timeline.findChild(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||
assert thumb is not None
|
||||
assert thumb.text() == "舌象\n查看"
|
||||
assert thumb.property("loadState") == "loading"
|
||||
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
||||
assert thumb.property("loadState") == "ready"
|
||||
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
||||
thumb.click()
|
||||
assert opened == [tongue_url]
|
||||
|
||||
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
||||
assert len(remove_buttons) == 2
|
||||
tongue_remove = next(button for button in remove_buttons if "舌象" in button.toolTip())
|
||||
tongue_remove.click()
|
||||
assert deleted == [(7001, "tongue_images", tongue_url)]
|
||||
|
||||
stale_generation = thumb._generation
|
||||
timeline.set_notes(
|
||||
[
|
||||
{
|
||||
"id": 7002,
|
||||
"note_date": "2026-08-11",
|
||||
"content": "复诊舌象。",
|
||||
"tongue_images": ["https://media.example.invalid/tongue-7002.jpg"],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert not thumb._apply_payload(_png_bytes(96, 192), stale_generation)
|
||||
current = next(
|
||||
item
|
||||
for item in timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||
if item is not thumb
|
||||
)
|
||||
assert current.text() == "舌象\n查看"
|
||||
assert current.property("loadState") == "loading"
|
||||
assert not current._apply_payload(b"invalid", current._generation)
|
||||
assert current.text() == "舌象\n查看"
|
||||
timeline.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_chat_image_is_previewable_bounded_and_owner_generation_safe(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||
panel = ChatPanel()
|
||||
opened: list[str] = []
|
||||
panel.open_attachment_requested.connect(opened.append)
|
||||
first_url = "https://media.example.invalid/glucose-chart.jpg"
|
||||
panel.set_messages(
|
||||
[
|
||||
{
|
||||
"msg_id": "chat-image-1",
|
||||
"msg_type": "image",
|
||||
"image_url": first_url,
|
||||
"is_from_doctor": False,
|
||||
"time": "2026-08-10 08:31",
|
||||
}
|
||||
]
|
||||
)
|
||||
image = panel.findChild(_RemoteImageButton, "DiagnosisChatImage")
|
||||
assert image is not None
|
||||
assert image.text() == "查看图片"
|
||||
assert image.property("loadState") == "loading"
|
||||
assert image._apply_payload(_png_bytes(640, 480), image._generation)
|
||||
assert image._rendered_pixmap.size() == QSize(240, 180)
|
||||
assert image.width() <= 240 and image.height() <= 200
|
||||
image.click()
|
||||
assert opened == [first_url]
|
||||
|
||||
stale_generation = image._generation
|
||||
second_url = "https://media.example.invalid/tall-photo.jpg"
|
||||
panel.set_messages(
|
||||
[
|
||||
{
|
||||
"msg_id": "chat-image-2",
|
||||
"msg_type": "image",
|
||||
"image_url": second_url,
|
||||
"is_from_doctor": True,
|
||||
"from_staff_name": "陈医生",
|
||||
"time": "2026-08-10 08:42",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert not image._apply_payload(_png_bytes(480, 640), stale_generation)
|
||||
current = next(
|
||||
item
|
||||
for item in panel.findChildren(_RemoteImageButton, "DiagnosisChatImage")
|
||||
if item is not image
|
||||
)
|
||||
assert current._apply_payload(_png_bytes(120, 480), current._generation)
|
||||
assert current._rendered_pixmap.size() == QSize(50, 200)
|
||||
assert current.property("loadState") == "ready"
|
||||
panel.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_daily_todo_has_exact_local_toolbar_and_refresh_signal(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
panel = DailyRecordPanel()
|
||||
panel.set_editable(True)
|
||||
refreshed: list[bool] = []
|
||||
panel.refresh_requested.connect(lambda: refreshed.append(True))
|
||||
|
||||
assert panel.todo_add_button.text() == "+ 新增待办"
|
||||
assert panel.todo_add_button.parentWidget() is panel.todo_toolbar
|
||||
assert panel.todo_refresh_button.text() == "刷新"
|
||||
assert panel.todo_refresh_button.parentWidget() is panel.todo_toolbar
|
||||
assert panel.todo_toolbar.objectName() == "DiagnosisTodoToolbar"
|
||||
assert all(button.parentWidget() is panel.todo_toolbar for button in panel.todo_group.buttons())
|
||||
panel.todo_refresh_button.click()
|
||||
assert refreshed == [True]
|
||||
|
||||
panel.set_loading(True)
|
||||
assert not panel.todo_refresh_button.isEnabled()
|
||||
panel.set_loading(False)
|
||||
assert panel.todo_refresh_button.isEnabled()
|
||||
panel.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,521 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from doctor_workstation.ui import diagnosis_media
|
||||
from doctor_workstation.ui.diagnosis_drawer import RecordTable
|
||||
from doctor_workstation.ui.diagnosis_media import (
|
||||
InlineRecordingPlayer,
|
||||
RecordingPlaybackCell,
|
||||
normalize_recording_urls,
|
||||
preferred_recording_url,
|
||||
safe_http_url,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.dialogs.diagnosis import (
|
||||
DiagnosisDialog,
|
||||
OrderDetailDrawer,
|
||||
present_order_detail,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class _Repository:
|
||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
||||
return {"id": order_id}
|
||||
|
||||
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]:
|
||||
return {"diagnosis_id": diagnosis_id, "revisit_slot_start_offset": offset}
|
||||
|
||||
def upload_call_recording(
|
||||
self,
|
||||
path: str,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
call_record_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"path": path,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"call_record_id": call_record_id,
|
||||
}
|
||||
|
||||
|
||||
def _rich_order() -> dict[str, Any]:
|
||||
return {
|
||||
"id": 801,
|
||||
"order_no": "RX-20260811-0801",
|
||||
"diagnosis_id": 501,
|
||||
"prescription_id": 601,
|
||||
"amount": 428.5,
|
||||
"linked_pay_paid_total": 300,
|
||||
"refund_amount": 20,
|
||||
"agency_collect_amount": 128.5,
|
||||
"fulfillment_status": 5,
|
||||
"prescription_audit_status": 1,
|
||||
"payment_slip_audit_status": 1,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"creator_name": "赵医助",
|
||||
"creator_account": "assistant.zhao",
|
||||
"create_time": "2026-08-11 09:26",
|
||||
"recipient_name": "林晓岚",
|
||||
"recipient_phone": "18600004218",
|
||||
"shipping_province": "河南省",
|
||||
"shipping_city": "洛阳市",
|
||||
"shipping_district": "洛龙区",
|
||||
"shipping_address": "开元大道 88 号",
|
||||
"is_follow_up": 1,
|
||||
"medication_days": 14,
|
||||
"service_channel": "线上复诊",
|
||||
"service_package": ["调理服务", "复诊随访"],
|
||||
"fee_type": 3,
|
||||
"tracking_number": "SF164208110801",
|
||||
"express_company": "sf",
|
||||
"remark_assistant": "工作日下午送达",
|
||||
"prescription_audit_remark": "辨证与用量已复核",
|
||||
"payment_slip_audit_remark": "收款凭证已核验",
|
||||
"prescription": {
|
||||
"id": 601,
|
||||
"sn": "RX601",
|
||||
"patient_name": "林晓岚",
|
||||
"gender_desc": "女",
|
||||
"age": 34,
|
||||
"phone": "18600004218",
|
||||
"prescription_date": "2026-08-11",
|
||||
"doctor_name": "陈医生",
|
||||
"prescription_type": "饮片",
|
||||
"clinical_diagnosis": "气阴两虚",
|
||||
"dose_count": 14,
|
||||
"dose_unit": "剂",
|
||||
"usage_instruction": "水煎服",
|
||||
"amount": 428.5,
|
||||
"audit_status": 1,
|
||||
"dosage_amount": 180,
|
||||
"dosage_unit": "g",
|
||||
"need_decoction": 1,
|
||||
"times_per_day": 2,
|
||||
"usage_days": 14,
|
||||
"dietary_taboo": ["辛辣", "生冷"],
|
||||
"void_status": 0,
|
||||
},
|
||||
"linked_pay_orders": [
|
||||
{
|
||||
"id": 9101,
|
||||
"order_no": "PAY-9101",
|
||||
"order_type_desc": "药品费用",
|
||||
"amount": 300,
|
||||
"status_desc": "已支付",
|
||||
"creator_name": "赵医助",
|
||||
"create_time": "2026-08-11 09:32",
|
||||
}
|
||||
],
|
||||
"unlinked_pay_orders": [],
|
||||
"logistics_trace": {
|
||||
"state_text": "运输中",
|
||||
"carrier_label": "顺丰速运",
|
||||
"traces": [
|
||||
{
|
||||
"time": "2026-08-11 16:10",
|
||||
"status": "运输中",
|
||||
"context": "快件已离开洛阳集散中心",
|
||||
},
|
||||
{
|
||||
"time": "2026-08-11 13:06",
|
||||
"status": "已揽收",
|
||||
"context": "顺丰速运已收取快件",
|
||||
},
|
||||
],
|
||||
},
|
||||
"logs": [
|
||||
{
|
||||
"id": 1,
|
||||
"admin_name": "赵医助",
|
||||
"action": "ship",
|
||||
"summary": "确认发货并填写顺丰运单",
|
||||
"create_time": "2026-08-11 13:08",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_order_offset_copy_tooltip_preview_and_list_columns_are_exact(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
|
||||
label = dialog.findChild(QLabel, "DiagnosisOrderOffsetLabel")
|
||||
assert label is not None
|
||||
assert label.text() == "复诊统计起始偏移"
|
||||
assert label.toolTip() == diagnosis_module._ORDER_OFFSET_HELP
|
||||
assert dialog.order_offset_help.toolTip() == diagnosis_module._ORDER_OFFSET_HELP
|
||||
assert dialog.order_offset_save.text() == "保存"
|
||||
|
||||
dialog._editable = True
|
||||
dialog._can_offset = True
|
||||
dialog._saved_order_offset = 0
|
||||
dialog.order_offset.setValue(2)
|
||||
assert dialog.order_offset_preview.text() == "第 1 笔实单计为三诊"
|
||||
assert dialog.order_offset_save.isEnabled()
|
||||
|
||||
dialog._fill_orders(
|
||||
[
|
||||
{
|
||||
"id": 801,
|
||||
"order_no": "RX-801",
|
||||
"global_visit_seq": 4,
|
||||
"counts_for_revisit_rate": 0,
|
||||
"amount": 286,
|
||||
"fulfillment_status": 2,
|
||||
}
|
||||
]
|
||||
)
|
||||
table = dialog._table_registry["orders"][1]
|
||||
assert table.horizontalHeaderItem(0).text() == "订单编号"
|
||||
assert table.item(0, 1).text() == "4诊"
|
||||
assert table.item(0, 2).text() == "否"
|
||||
assert table.item(0, 3).text() == "¥286.00"
|
||||
assert table.item(0, 6).text() == "待发货"
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_order_detail_is_eighty_percent_readonly_drawer_with_real_sections(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host = DiagnosisDialog(_Repository(), permissions=["*"])
|
||||
host.resize(1200, 760)
|
||||
host.show()
|
||||
drawer = host._build_order_detail_dialog(_rich_order(), 801)
|
||||
drawer.show()
|
||||
application.processEvents()
|
||||
|
||||
assert isinstance(drawer, OrderDetailDrawer)
|
||||
assert drawer.size() == host.size()
|
||||
assert abs(drawer.drawer_panel.width() - round(host.width() * 0.8)) <= 1
|
||||
assert drawer.drawer_panel.property("readonly") is True
|
||||
labels = [label.text() for label in drawer.findChildren(QLabel)]
|
||||
for section in ("金额概览", "处方详情", "收款记录", "履约与收货信息", "物流轨迹", "操作日志"):
|
||||
assert section in labels
|
||||
assert "¥428.50" in labels
|
||||
assert "¥300.00" in labels
|
||||
assert "确认发货并填写顺丰运单" in labels
|
||||
assert "快件已离开洛阳集散中心" in labels
|
||||
linked = drawer.findChild(RecordTable, "DiagnosisOrderLinkedPayments")
|
||||
assert linked is not None and linked.rowCount() == 1
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_present_order_detail_shared_entry_matches_admin_drawer_sections(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host = QFrame()
|
||||
host.resize(1100, 720)
|
||||
host.show()
|
||||
application.processEvents()
|
||||
drawer = present_order_detail(
|
||||
host,
|
||||
_rich_order(),
|
||||
order_id=801,
|
||||
permissions=["tcm.prescriptionOrder/logs", "tcm.prescriptionOrder/detail"],
|
||||
exec_=False,
|
||||
)
|
||||
application.processEvents()
|
||||
assert isinstance(drawer, OrderDetailDrawer)
|
||||
labels = [label.text() for label in drawer.findChildren(QLabel)]
|
||||
for section in ("金额概览", "处方详情", "收款记录", "履约与收货信息", "物流轨迹", "操作日志"):
|
||||
assert section in labels
|
||||
assert "RX-20260811-0801" in " ".join(labels)
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_order_detail_missing_fields_use_explicit_empty_states_without_fake_zero(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host = DiagnosisDialog(_Repository(), permissions=["*"])
|
||||
host.resize(1000, 680)
|
||||
drawer = host._build_order_detail_dialog({"id": 809}, 809)
|
||||
drawer.show()
|
||||
application.processEvents()
|
||||
|
||||
labels = [label.text() for label in drawer.findChildren(QLabel)]
|
||||
amount_values = [
|
||||
label.text()
|
||||
for label in drawer.findChildren(QLabel)
|
||||
if label.property("orderAmountValue") is True
|
||||
]
|
||||
assert amount_values == ["—", "—", "—", "—", "—"]
|
||||
assert "¥0.00" not in labels
|
||||
assert "无处方数据(详情接口未返回 prescription)" in labels
|
||||
assert "详情接口未返回关联收款记录字段" in labels
|
||||
assert "详情接口未返回未关联收款记录字段" in labels
|
||||
assert "订单详情未返回快递单号,暂无物流轨迹" in labels
|
||||
assert "详情接口未返回操作日志数据" in labels
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_order_logs_remain_permission_gated(application: QApplication) -> None:
|
||||
host = DiagnosisDialog(
|
||||
_Repository(),
|
||||
permissions=["tcm.prescriptionOrder/detail"],
|
||||
)
|
||||
drawer = host._build_order_detail_dialog(_rich_order(), 801)
|
||||
denied = next(
|
||||
label for label in drawer.findChildren(QLabel) if label.property("permissionDenied") is True
|
||||
)
|
||||
assert denied.text() == "当前账号无操作日志查看权限"
|
||||
assert "确认发货并填写顺丰运单" not in [label.text() for label in drawer.findChildren(QLabel)]
|
||||
drawer.close()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_recording_preference_inline_height_alternates_and_safe_external_open(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
urls = [
|
||||
"https://bucket.cos.ap-shanghai.myqcloud.com/replay/index.m3u8",
|
||||
"https://media.example.invalid/replay.mp4",
|
||||
"https://media.example.invalid/replay.webm",
|
||||
"file:///C:/private/replay.mp4",
|
||||
"https://media.example.invalid/replay.mp4",
|
||||
]
|
||||
assert normalize_recording_urls(urls) == urls[:-1]
|
||||
assert preferred_recording_url(urls) == "https://media.example.invalid/replay.mp4"
|
||||
assert safe_http_url("file:///C:/private/replay.mp4") is None
|
||||
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
diagnosis_media.QDesktopServices,
|
||||
"openUrl",
|
||||
lambda url: opened.append(url.toString()) or True,
|
||||
)
|
||||
cell = RecordingPlaybackCell(urls, record_id=48)
|
||||
cell.show()
|
||||
application.processEvents()
|
||||
player = cell.findChild(InlineRecordingPlayer, "DiagnosisInlineRecordingPlayer")
|
||||
assert player is not None
|
||||
assert player.target == "https://media.example.invalid/replay.mp4"
|
||||
assert player.maximumHeight() == 180
|
||||
assert player.property("maximumPlaybackHeight") == 180
|
||||
assert player._source_attached is False
|
||||
assert player.player is None
|
||||
assert player.audio_output is None
|
||||
assert player.video is None
|
||||
alternate_buttons = cell.findChildren(QPushButton, "DiagnosisRecordingAlternateLink")
|
||||
assert [button.text() for button in alternate_buttons] == [
|
||||
"COS HLS 1",
|
||||
"链接 2",
|
||||
"MP4 3",
|
||||
]
|
||||
assert alternate_buttons[-1].isEnabled() is False
|
||||
alternate_buttons[0].click()
|
||||
assert opened == [urls[0]]
|
||||
|
||||
empty = RecordingPlaybackCell([], record_id=49)
|
||||
empty_state = empty.findChild(QLabel, "DiagnosisEmptyState")
|
||||
assert empty_state is not None and empty_state.text() == "暂无录制回放"
|
||||
invalid = RecordingPlaybackCell(["file:///C:/private/replay.mp4"], record_id=50)
|
||||
invalid_state = invalid.findChild(QLabel, "DiagnosisUnsupportedState")
|
||||
assert invalid_state is not None and "回放地址无效" in invalid_state.text()
|
||||
empty.close()
|
||||
invalid.close()
|
||||
cell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_recording_rows_do_not_eagerly_create_native_players(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
cells = [
|
||||
RecordingPlaybackCell(
|
||||
[f"https://media.example.invalid/replay-{index}.mp4"],
|
||||
record_id=index,
|
||||
)
|
||||
for index in range(40)
|
||||
]
|
||||
application.processEvents()
|
||||
|
||||
inline_players = [cell.inline_player for cell in cells]
|
||||
assert all(player is not None for player in inline_players)
|
||||
assert all(player.player is None for player in inline_players if player is not None)
|
||||
assert all(player.audio_output is None for player in inline_players if player is not None)
|
||||
assert all(player.video is None for player in inline_players if player is not None)
|
||||
|
||||
for cell in cells:
|
||||
cell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_video_table_embeds_player_and_preserves_row_bound_upload(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
|
||||
dialog._editable = True
|
||||
dialog._can_video_upload = True
|
||||
dialog._diagnosis_id = 501
|
||||
dialog._tab_generations["video"] = 7
|
||||
uploaded: list[int | None] = []
|
||||
dialog._upload_call_recording = lambda call_record_id=None: uploaded.append(call_record_id) # type: ignore[method-assign]
|
||||
dialog._fill_video(
|
||||
[
|
||||
{
|
||||
"id": 48,
|
||||
"recording_urls_list": [
|
||||
"https://media.example.invalid/replay.mp4",
|
||||
"https://media.example.invalid/replay-backup.m3u8",
|
||||
],
|
||||
"call_type": 2,
|
||||
"status": 2,
|
||||
"recording_status_text": "录制完成",
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"recording_urls_list": [],
|
||||
"call_type": 1,
|
||||
"status": 3,
|
||||
"recording_status_text": "暂无录制",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
preview = QFrame()
|
||||
preview_layout = QVBoxLayout(preview)
|
||||
page = dialog._tab_pages["video"]
|
||||
page.setParent(preview)
|
||||
page.show()
|
||||
preview_layout.addWidget(page)
|
||||
preview.resize(1200, 560)
|
||||
preview.show()
|
||||
application.processEvents()
|
||||
|
||||
table = dialog._table_registry["video"][1]
|
||||
playback = table.cellWidget(0, 0)
|
||||
assert isinstance(playback, RecordingPlaybackCell)
|
||||
assert playback.property("callRecordId") == 48
|
||||
player = playback.findChild(InlineRecordingPlayer)
|
||||
assert player is not None
|
||||
surface = player.findChild(QFrame, "DiagnosisInlineRecordingSurface")
|
||||
external = player.findChild(QPushButton, "DiagnosisInlineRecordingExternal")
|
||||
fallback = player.findChild(QPushButton, "DiagnosisInlineRecordingFallback")
|
||||
assert surface is not None and external is not None and fallback is not None
|
||||
|
||||
assert table.rowHeight(0) >= playback.required_table_row_height()
|
||||
assert playback.height() >= playback.minimumSizeHint().height()
|
||||
assert 158 <= player.height() <= 180
|
||||
assert surface.height() >= 122
|
||||
assert player.position.width() >= 50
|
||||
assert player.position.height() >= 12
|
||||
assert player.time_label.height() >= 15
|
||||
for control in (player.play_button, external, fallback):
|
||||
assert control.isVisibleTo(player)
|
||||
assert control.height() >= 24
|
||||
for control in (player.play_button, player.position, player.time_label, external, fallback):
|
||||
assert player.rect().contains(control.geometry())
|
||||
|
||||
# Guard the actual rendered pixels: the previous regression produced only
|
||||
# a 16 px dark strip despite the class-level maximumHeight declaration.
|
||||
playback_image = playback.grab().toImage()
|
||||
dark_rows = []
|
||||
for y in range(playback_image.height()):
|
||||
dark_pixels = sum(
|
||||
1
|
||||
for x in range(playback_image.width())
|
||||
if max(
|
||||
playback_image.pixelColor(x, y).red(),
|
||||
playback_image.pixelColor(x, y).green(),
|
||||
playback_image.pixelColor(x, y).blue(),
|
||||
)
|
||||
<= 55
|
||||
)
|
||||
if dark_pixels >= round(playback_image.width() * 0.65):
|
||||
dark_rows.append(y)
|
||||
assert dark_rows and dark_rows[-1] - dark_rows[0] + 1 >= 120
|
||||
|
||||
assert table.item(1, 0).text() == "暂无录制回放"
|
||||
assert dialog._recording_players == []
|
||||
upload_host = table.cellWidget(0, 8)
|
||||
assert upload_host is not None
|
||||
upload = upload_host.findChild(QPushButton, "DiagnosisVideoRowUpload")
|
||||
assert upload is not None
|
||||
assert upload.property("callRecordId") == 48
|
||||
assert table.item(0, 8).text() == ""
|
||||
assert abs(upload_host.rect().center().y() - upload.geometry().center().y()) <= 1
|
||||
upload.click()
|
||||
assert uploaded == [48]
|
||||
preview.close()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None:
|
||||
class _Owner:
|
||||
_tab_generations = {"video": 4}
|
||||
|
||||
owner = _Owner()
|
||||
player = InlineRecordingPlayer(
|
||||
"https://media.example.invalid/replay.mp4",
|
||||
render_owner=owner,
|
||||
owner_generation=4,
|
||||
)
|
||||
owner._tab_generations["video"] = 5
|
||||
if player.player is not None:
|
||||
assert player._attach_source() is False
|
||||
assert player.play_button.isEnabled() is False
|
||||
assert "已刷新" in player.placeholder.text()
|
||||
player.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_drawer_and_inline_player_render_non_empty_images(
|
||||
application: QApplication,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
host = DiagnosisDialog(_Repository(), permissions=["*"])
|
||||
host.resize(1100, 720)
|
||||
host.show()
|
||||
drawer = host._build_order_detail_dialog(_rich_order(), 801)
|
||||
drawer.show()
|
||||
application.processEvents()
|
||||
order_image = drawer.grab().toImage()
|
||||
order_path = tmp_path / "order.png"
|
||||
assert order_image.width() == 1100 and order_image.height() == 720
|
||||
assert order_image.save(str(order_path), "PNG")
|
||||
assert order_path.stat().st_size > 10_000
|
||||
|
||||
cell = RecordingPlaybackCell(
|
||||
[
|
||||
"https://media.example.invalid/replay.mp4",
|
||||
"https://media.example.invalid/replay.m3u8",
|
||||
],
|
||||
record_id=48,
|
||||
)
|
||||
cell.resize(520, 235)
|
||||
cell.show()
|
||||
application.processEvents()
|
||||
video_image = cell.grab().toImage()
|
||||
video_path = tmp_path / "video.png"
|
||||
assert video_image.save(str(video_path), "PNG")
|
||||
assert video_path.stat().st_size > 2_000
|
||||
|
||||
cell.close()
|
||||
drawer.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Dictionary payload contracts for diagnosis choice fields."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository, _dictionary_rows
|
||||
from doctor_workstation.ui.dialogs.diagnosis import _CHOICE_DICTIONARIES, _dictionary_choices
|
||||
|
||||
|
||||
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_dictionary_rows_unwrap_admin_type_map() -> None:
|
||||
rows = _dictionary_rows(
|
||||
{
|
||||
"sleep_condition": [
|
||||
{"name": "入睡困难", "value": "入睡困难"},
|
||||
{"name": "多梦", "value": "多梦"},
|
||||
]
|
||||
},
|
||||
"sleep_condition",
|
||||
)
|
||||
assert [row["value"] for row in rows] == ["入睡困难", "多梦"]
|
||||
|
||||
|
||||
def test_remote_get_dictionary_uses_admin_dict_shape() -> None:
|
||||
client = RecordingClient(
|
||||
{
|
||||
"appetite": [
|
||||
{"name": "口干", "value": "口干"},
|
||||
{"name": "口苦", "value": "口苦"},
|
||||
]
|
||||
}
|
||||
)
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
rows = repository.get_dictionary("appetite")
|
||||
assert client.get_calls == [("config/dict", {"type": "appetite"})]
|
||||
assert _dictionary_choices(rows, "appetite") == [("口干", "口干"), ("口苦", "口苦")]
|
||||
|
||||
|
||||
def test_demo_exposes_all_diagnosis_choice_dictionaries() -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
for dictionary_type, _multiple in _CHOICE_DICTIONARIES.values():
|
||||
rows = repository.get_dictionary(dictionary_type)
|
||||
assert rows, dictionary_type
|
||||
assert _dictionary_choices(rows, dictionary_type)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation import __main__ as entrypoint
|
||||
|
||||
|
||||
def test_smoke_entrypoint_returns_nonzero_instead_of_opening_crash_dialog(
|
||||
monkeypatch: Any,
|
||||
capsys: Any,
|
||||
) -> None:
|
||||
def fail_startup() -> int:
|
||||
raise RuntimeError("startup failed")
|
||||
|
||||
monkeypatch.setattr(entrypoint, "main", fail_startup)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
|
||||
assert entrypoint._run() == 1
|
||||
assert "RuntimeError: startup failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_normal_entrypoint_preserves_startup_exception(monkeypatch: Any) -> None:
|
||||
def fail_startup() -> int:
|
||||
raise RuntimeError("startup failed")
|
||||
|
||||
monkeypatch.setattr(entrypoint, "main", fail_startup)
|
||||
monkeypatch.delenv("DOCTOR_SMOKE_TEST", raising=False)
|
||||
monkeypatch.setattr(entrypoint.sys, "argv", ["doctor-workstation"])
|
||||
|
||||
with pytest.raises(RuntimeError, match="startup failed"):
|
||||
entrypoint._run()
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from doctor_workstation.logging_setup import SecretRedactionFilter
|
||||
|
||||
|
||||
def test_redaction_filter_hides_credentials() -> None:
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg="token: abc123 userSig='secret-value' password=hunter2",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
assert SecretRedactionFilter().filter(record)
|
||||
rendered = record.getMessage()
|
||||
assert "abc123" not in rendered
|
||||
assert "secret-value" not in rendered
|
||||
assert "hunter2" not in rendered
|
||||
assert rendered.count("<redacted>") == 3
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Behaviour tests for mutable demo data and tolerant model parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiBusinessError,
|
||||
ApiProtocolError,
|
||||
AuthenticationExpiredError,
|
||||
RepositoryNotFoundError,
|
||||
)
|
||||
from doctor_workstation.core.models import Appointment, PageResult
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.services.token_store import TokenStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository() -> DemoDoctorRepository:
|
||||
"""Return a fresh deterministic repository for each test."""
|
||||
|
||||
return DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
|
||||
|
||||
def test_demo_login_has_all_doctor_permissions(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Documented credentials produce a typed, globally authorised session."""
|
||||
|
||||
with pytest.raises(ApiBusinessError):
|
||||
repository.login("doctor", "wrong")
|
||||
|
||||
session = repository.login("doctor", "doctor123")
|
||||
assert session.authenticated
|
||||
assert session.user.name == "陈医生(演示)"
|
||||
assert session.permissions.is_superuser
|
||||
assert session.permissions.can("doctor.appointment", "complete")
|
||||
assert session.permissions.can("tcm.prescriptionLibrary", "delete")
|
||||
|
||||
|
||||
def test_complete_appointment_mutates_all_related_views(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Completing reception is observable in queue, patient and diagnosis lists."""
|
||||
|
||||
repository.complete_appointment(101)
|
||||
|
||||
completed = repository.list_appointments(status=3).items
|
||||
assert [item.id for item in completed] == [101, 104]
|
||||
patient = repository.list_patients(keyword="林晓岚").items[0]
|
||||
assert patient.appointment_status == 3
|
||||
assert patient.status_filter == "completed"
|
||||
consultation = repository.list_consultations(patient_name="林晓岚").items[0]
|
||||
assert consultation.status == 3
|
||||
assert repository.get_reception(101)["appointment"]["status"] == 3
|
||||
|
||||
|
||||
def test_add_note_persists_in_reception_detail(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Text and media added to a diagnosis remain available on later reads."""
|
||||
|
||||
before = len(repository.get_reception(101)["doctor_notes"])
|
||||
created = repository.add_doctor_note(
|
||||
501,
|
||||
"午后睡意减轻",
|
||||
tongue_images=["demo://tongue.png"],
|
||||
report_files=["demo://report.pdf"],
|
||||
)
|
||||
|
||||
after = repository.get_reception(101)["doctor_notes"]
|
||||
assert len(after) == before + 1
|
||||
assert after[-1] == created
|
||||
assert after[-1]["tongue_images"] == ["demo://tongue.png"]
|
||||
|
||||
|
||||
def test_demo_upload_material_returns_safe_uri_and_note_rejects_local_path(
|
||||
repository: DemoDoctorRepository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Demo mode exercises the same upload-before-note contract as production."""
|
||||
|
||||
source = tmp_path / "tongue.jpg"
|
||||
source.write_bytes(b"demo-image")
|
||||
uri = repository.upload_material(source, "image")
|
||||
created = repository.add_doctor_note(501, tongue_images=[uri])
|
||||
|
||||
assert uri.startswith("/demo/uploads/image/")
|
||||
assert str(tmp_path) not in uri
|
||||
assert created["tongue_images"] == [uri]
|
||||
with pytest.raises(ValueError, match="server uri/url"):
|
||||
repository.add_doctor_note(501, tongue_images=[str(source)])
|
||||
|
||||
|
||||
def test_prescription_template_crud_is_real_and_isolated(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Create, update and delete change subsequent list/detail reads."""
|
||||
|
||||
original_count = repository.list_prescription_templates().total
|
||||
created = repository.create_prescription_template(
|
||||
name="益气演示方",
|
||||
formula_type=1,
|
||||
herbs=[{"name": "黄芪", "dosage": "20g"}],
|
||||
is_public=False,
|
||||
)
|
||||
assert repository.list_prescription_templates().total == original_count + 1
|
||||
|
||||
updated = repository.update_prescription_template(
|
||||
created.id,
|
||||
{"name": "益气健脾演示方", "is_public": True},
|
||||
)
|
||||
assert updated.name == "益气健脾演示方"
|
||||
assert updated.is_public
|
||||
assert repository.get_prescription_template(created.id).name == updated.name
|
||||
|
||||
repository.delete_prescription_template(created.id)
|
||||
assert repository.list_prescription_templates().total == original_count
|
||||
with pytest.raises(RepositoryNotFoundError):
|
||||
repository.get_prescription_template(created.id)
|
||||
|
||||
|
||||
def test_demo_pagination_and_returned_copies(repository: DemoDoctorRepository) -> None:
|
||||
"""Pagination metadata is stable and callers cannot mutate repository state."""
|
||||
|
||||
page = repository.list_appointments(page_no=1, page_size=1)
|
||||
assert page.total == 5
|
||||
assert page.pages == 5
|
||||
page.items[0].patient_name = "外部改写"
|
||||
assert repository.list_appointments(page_no=1, page_size=1).items[0].patient_name != (
|
||||
"外部改写"
|
||||
)
|
||||
|
||||
|
||||
def test_demo_prescription_lookup_is_appointment_authoritative(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Demo getByAppointment never substitutes another diagnosis-level record."""
|
||||
|
||||
first = repository.get_prescription_by_appointment(101)
|
||||
second = repository.get_prescription_by_appointment(102)
|
||||
missing = repository.get_prescription_by_appointment(103)
|
||||
|
||||
assert first is not None and first.id == 802 and first.appointment_id == 101
|
||||
assert second is not None and second.id == 801 and second.appointment_id == 102
|
||||
assert missing is None
|
||||
assert first.case_record["appointment_id"] == 101
|
||||
|
||||
|
||||
def test_demo_consultation_filters_and_dictionaries_cover_exposed_ui(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Every visible consultation filter has deterministic offline semantics."""
|
||||
|
||||
for dictionary_type in (
|
||||
"diagnosis_type",
|
||||
"consultation_type",
|
||||
"syndrome_type",
|
||||
"appointment_channel_source",
|
||||
"channels",
|
||||
):
|
||||
assert repository.get_dictionary(dictionary_type)
|
||||
|
||||
assert {row.id for row in repository.list_consultations(diagnosis_confirmed="1").items} == {
|
||||
501,
|
||||
503,
|
||||
}
|
||||
assert {row.id for row in repository.list_consultations(diagnosis_type="integrated").items} == {
|
||||
502,
|
||||
504,
|
||||
}
|
||||
assert [row.id for row in repository.list_consultations(syndrome_type="phlegm_damp").items] == [
|
||||
502,
|
||||
504,
|
||||
]
|
||||
assert [
|
||||
row.id
|
||||
for row in repository.list_consultations(latest_appointment_channel_source="clinic").items
|
||||
] == [502]
|
||||
assert [row.id for row in repository.list_consultations(pending_booking="1").items] == [504]
|
||||
assert [row.id for row in repository.list_consultations(pending_assign="1").items] == [504]
|
||||
assert [row.id for row in repository.list_consultations(completed_appointment="1").items] == [
|
||||
501
|
||||
]
|
||||
sorted_rows = repository.list_consultations(sort_unserved_days="desc").items
|
||||
assert [row.unserved_days for row in sorted_rows] == [14, 8, 1, 0]
|
||||
|
||||
|
||||
def test_demo_roster_and_slots_fail_closed_to_known_doctor(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Offline booking uses the same roster/slot boundary as production."""
|
||||
|
||||
rosters = repository.list_appointment_rosters(
|
||||
doctor_id=1001,
|
||||
start_date="2026-08-10",
|
||||
end_date="2026-08-16",
|
||||
)
|
||||
slots = repository.get_available_appointment_slots(
|
||||
doctor_id=1001,
|
||||
appointment_date="2026-08-10",
|
||||
)
|
||||
|
||||
assert rosters.total == 7
|
||||
assert rosters.items[0]["date"] == "2026-08-10"
|
||||
assert any(row["time"] == "09:00" and not row["available"] for row in slots["slots"])
|
||||
assert (
|
||||
repository.get_available_appointment_slots(
|
||||
doctor_id=9999,
|
||||
appointment_date="2026-08-10",
|
||||
)["slots"]
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_demo_call_lifecycle_mutates_record(repository: DemoDoctorRepository) -> None:
|
||||
"""Start, room binding and end operations share one mutable call record."""
|
||||
|
||||
ticket = repository.get_call_ticket(301, 501)
|
||||
assert ticket.patient_user_id == "patient_301"
|
||||
started = repository.start_call(501, 301)
|
||||
assert started["status"] == "ringing"
|
||||
bound = repository.bind_call_room(501, "room-501")
|
||||
assert bound["room_id"] == "room-501"
|
||||
ended = repository.end_call(501)
|
||||
assert ended["status"] == "ended"
|
||||
assert ended["room_id"] == "room-501"
|
||||
|
||||
|
||||
def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Demo replay reads expose one finalized segment for a repeated segment ID."""
|
||||
|
||||
started = repository.start_call(501, 301)
|
||||
call_record_id = started["id"]
|
||||
repository.start_call_transcription(501, call_record_id, "session-1")
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "draft words",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "final words",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.finish_call_transcription(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
expected_segment_count=1,
|
||||
status="completed",
|
||||
)
|
||||
repository.end_call(501)
|
||||
|
||||
record = next(
|
||||
row for row in repository.list_call_records(501) if row["id"] == call_record_id
|
||||
)
|
||||
assert record["status"] == 2
|
||||
assert record["transcription_status"] == "completed"
|
||||
assert record["transcription_segment_count"] == 1
|
||||
assert record["transcript_segments"] == [
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_role": "patient",
|
||||
"speaker_user_id": "patient_301",
|
||||
"timestamp": 1200,
|
||||
"text": "final words",
|
||||
}
|
||||
]
|
||||
assert "final words" in record["transcript_text"]
|
||||
|
||||
|
||||
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
|
||||
"""List parsing handles nullable fields, aliases and non-object rows safely."""
|
||||
|
||||
page = PageResult.from_payload(
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"appointment_id": "9",
|
||||
"patient_name": "测试患者",
|
||||
"status": "waiting",
|
||||
},
|
||||
None,
|
||||
],
|
||||
"total": "12",
|
||||
"current_page": "2",
|
||||
"per_page": "5",
|
||||
"meta": {"scope": "demo"},
|
||||
},
|
||||
Appointment.from_dict,
|
||||
)
|
||||
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == 9
|
||||
assert page.items[0].status == "waiting"
|
||||
assert page.total == 12
|
||||
assert page.page_no == 2
|
||||
assert page.extend == {"scope": "demo"}
|
||||
|
||||
|
||||
class _StubApiClient:
|
||||
"""No-network API client double that records repository endpoint use."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = "https://example.test/adminapi/"
|
||||
self.token = ""
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.post_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
"""Retain the synthetic login token."""
|
||||
|
||||
self.token = token
|
||||
|
||||
def clear_token(self) -> None:
|
||||
"""Clear the synthetic login token."""
|
||||
|
||||
self.token = ""
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Return a shape appropriate for the requested read endpoint."""
|
||||
|
||||
self.get_calls.append((endpoint, dict(params or {})))
|
||||
if endpoint == "auth.admin/mySelf":
|
||||
return {
|
||||
"user": {"id": 1, "name": "远程医生", "role_ids": [1]},
|
||||
"permissions": ["doctor.appointment/lists"],
|
||||
"menu": [],
|
||||
}
|
||||
if endpoint.endswith("/detail"):
|
||||
if endpoint.startswith("tcm.prescriptionLibrary"):
|
||||
return {"id": 7, "prescription_name": "远程模板", "herbs": []}
|
||||
if endpoint.startswith("tcm.prescription"):
|
||||
return {"id": 8, "sn": "RX8", "patient_name": "远程患者"}
|
||||
if endpoint == "doctor.appointment/reception":
|
||||
return {"appointment": {"id": params["id"]}, "doctor_notes": []}
|
||||
return {"lists": [], "count": 0, "extend": {}}
|
||||
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
"""Return synthetic mutation data and record the exact JSON payload."""
|
||||
|
||||
body = dict(payload or {})
|
||||
self.post_calls.append((endpoint, body))
|
||||
if endpoint == "login/account":
|
||||
return {"token": "remote-token", "is_paw": 1}
|
||||
if endpoint == "tcm.prescriptionLibrary/add":
|
||||
return {"id": 9}
|
||||
if endpoint == "tcm.diagnosis/getCallSignature":
|
||||
return {
|
||||
"sdkAppId": 123,
|
||||
"userId": "doctor_1",
|
||||
"userSig": "short-lived",
|
||||
"patientUserId": "patient_2",
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
return {"call_record_id": 901}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_remote_repository_uses_all_confirmed_endpoints_without_network() -> None:
|
||||
"""Every required remote operation maps to its audited admin endpoint."""
|
||||
|
||||
client = _StubApiClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
session = repository.login(" doctor ", "secret")
|
||||
assert session.token == "remote-token"
|
||||
assert repository.get_current_user().name == "远程医生"
|
||||
assert [path for path, _ in client.get_calls].count("auth.admin/mySelf") == 1
|
||||
|
||||
repository.list_appointments(keyword="林", page_no=2, page_size=15)
|
||||
repository.get_reception(5)
|
||||
repository.notify_assistant(5)
|
||||
repository.add_doctor_note(6, "记录")
|
||||
repository.complete_appointment(5)
|
||||
repository.list_prescription_templates(keyword="方", formula_type="aux")
|
||||
repository.get_prescription_template(7)
|
||||
repository.create_prescription_template(
|
||||
name="新方", formula_type="main", herbs=[{"name": "茯苓", "dosage": "10g"}]
|
||||
)
|
||||
repository.update_prescription_template(7, {"name": "改方"})
|
||||
repository.delete_prescription_template(7)
|
||||
repository.list_prescriptions(keyword="RX8", status=1)
|
||||
repository.get_prescription(8)
|
||||
repository.list_patients(status="completed")
|
||||
repository.list_consultations(keyword="远程")
|
||||
ticket = repository.get_call_ticket(2, 6)
|
||||
repository.start_call(6, 2)
|
||||
repository.end_call(6)
|
||||
repository.bind_call_room(6, "room-6")
|
||||
|
||||
assert ticket.user_sig == "short-lived"
|
||||
get_endpoints = {path for path, _ in client.get_calls}
|
||||
assert {
|
||||
"doctor.appointment/lists",
|
||||
"doctor.appointment/reception",
|
||||
"tcm.prescriptionLibrary/lists",
|
||||
"tcm.prescriptionLibrary/detail",
|
||||
"tcm.prescription/lists",
|
||||
"tcm.prescription/detail",
|
||||
"firstvisit.myPatient/lists",
|
||||
"tcm.diagnosis/lists",
|
||||
} <= get_endpoints
|
||||
post_endpoints = {path for path, _ in client.post_calls}
|
||||
assert {
|
||||
"login/account",
|
||||
"doctor.appointment/notifyAssistant",
|
||||
"doctor.appointment/addDoctorNote",
|
||||
"doctor.appointment/complete",
|
||||
"tcm.prescriptionLibrary/add",
|
||||
"tcm.prescriptionLibrary/edit",
|
||||
"tcm.prescriptionLibrary/delete",
|
||||
"tcm.diagnosis/getCallSignature",
|
||||
"tcm.diagnosis/startCall",
|
||||
"tcm.diagnosis/endCall",
|
||||
"tcm.diagnosis/bindCallRoom",
|
||||
} <= post_endpoints
|
||||
template_list_call = next(
|
||||
params
|
||||
for endpoint, params in client.get_calls
|
||||
if endpoint == "tcm.prescriptionLibrary/lists"
|
||||
)
|
||||
assert template_list_call["formula_type"] == "辅方"
|
||||
prescription_list_call = next(
|
||||
params for endpoint, params in client.get_calls if endpoint == "tcm.prescription/lists"
|
||||
)
|
||||
assert prescription_list_call == {
|
||||
"sn": "RX8",
|
||||
"audit_filter": "passed",
|
||||
"page_no": 1,
|
||||
"page_size": 20,
|
||||
}
|
||||
patient_call = next(
|
||||
params for endpoint, params in client.get_calls if endpoint == "firstvisit.myPatient/lists"
|
||||
)
|
||||
assert patient_call["status_filter"] == "completed"
|
||||
|
||||
|
||||
class _FailingProfileClient(_StubApiClient):
|
||||
"""Client double whose post-login session validation always fails."""
|
||||
|
||||
def __init__(self, error: Exception) -> None:
|
||||
super().__init__()
|
||||
self.error = error
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Raise the configured error only for the authoritative profile call."""
|
||||
|
||||
if endpoint == "auth.admin/mySelf":
|
||||
self.get_calls.append((endpoint, dict(params or {})))
|
||||
raise self.error
|
||||
return super().get(endpoint, params)
|
||||
|
||||
|
||||
def test_remote_login_persists_only_after_session_validation(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A failed ``mySelf`` call rolls back memory and never saves its token."""
|
||||
|
||||
client = _FailingProfileClient(ApiProtocolError("bad profile"))
|
||||
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
||||
store.save_token("older-token", account="older", scope=client.base_url)
|
||||
saved: list[tuple[str, dict[str, Any]]] = []
|
||||
original_save = store.save_token
|
||||
|
||||
def record_save(token: str, **metadata: Any) -> None:
|
||||
saved.append((token, metadata))
|
||||
original_save(token, **metadata)
|
||||
|
||||
monkeypatch.setattr(store, "save_token", record_save)
|
||||
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ApiProtocolError, match="bad profile"):
|
||||
repository.login(
|
||||
"doctor",
|
||||
"secret",
|
||||
remember_account=True,
|
||||
)
|
||||
|
||||
assert saved == []
|
||||
assert client.token == ""
|
||||
assert store.load_token() is None
|
||||
|
||||
|
||||
def test_remote_login_applies_remember_account_to_token_store(tmp_path: Path) -> None:
|
||||
"""The checkbox choice controls account metadata while retaining the token."""
|
||||
|
||||
client = _StubApiClient()
|
||||
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
||||
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
||||
|
||||
repository.login("doctor", "secret", remember_account=True)
|
||||
assert store.load_account() == "doctor"
|
||||
assert store.load_token(scope=client.base_url) == "remote-token"
|
||||
|
||||
repository.logout()
|
||||
repository.login("doctor", "secret", remember_account=False)
|
||||
assert store.load_account() is None
|
||||
assert store.load_token(scope=client.base_url) == "remote-token"
|
||||
|
||||
|
||||
def test_expired_persisted_token_is_removed_during_restore(tmp_path: Path) -> None:
|
||||
"""An invalid startup token cannot trigger the same failed restore next run."""
|
||||
|
||||
client = _FailingProfileClient(AuthenticationExpiredError("expired", code=-1))
|
||||
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
||||
store.save_token("expired-token", account="doctor", scope=client.base_url)
|
||||
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(AuthenticationExpiredError):
|
||||
repository.restore_session()
|
||||
|
||||
assert client.token == ""
|
||||
assert store.load_token() is None
|
||||
assert store.load_account() == "doctor"
|
||||
|
||||
|
||||
def test_restore_never_sends_token_to_a_different_api_scope(tmp_path: Path) -> None:
|
||||
"""Changing the configured server invalidates automatic token reuse."""
|
||||
|
||||
client = _StubApiClient()
|
||||
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
||||
store.save_token(
|
||||
"other-server-token",
|
||||
account="doctor",
|
||||
scope="https://other.test/adminapi/",
|
||||
)
|
||||
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
||||
|
||||
assert repository.restore_session() is None
|
||||
assert client.token == ""
|
||||
assert client.get_calls == []
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(relative_path: str) -> str:
|
||||
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
||||
for name in (
|
||||
"Run_DoctorWorkstation.bat",
|
||||
"Build_DoctorWorkstation.bat",
|
||||
"一键运行_医生工作站.bat",
|
||||
"一键打包_医生工作站.bat",
|
||||
):
|
||||
assert (PROJECT_ROOT / name).is_file()
|
||||
|
||||
run_script = read("scripts/run_windows.ps1")
|
||||
package_script = read("scripts/package_windows.ps1")
|
||||
release_launcher = read("packaging/windows/start_release.bat")
|
||||
|
||||
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
||||
assert "& $Uv sync --frozen" in run_script
|
||||
assert '"UV_CACHE_DIR"' in run_script
|
||||
assert "$ProjectUvCache" in run_script
|
||||
assert "& $Uv sync --frozen --extra build" in package_script
|
||||
assert '"UV_CACHE_DIR"' in package_script
|
||||
assert "$ProjectUvCache" in package_script
|
||||
assert "& $Npm ci --prefix" in package_script
|
||||
assert "build_windows.ps1" in package_script
|
||||
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
|
||||
assert "Get-FileHash" in package_script
|
||||
assert "Start_DoctorWorkstation.bat" in package_script
|
||||
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
|
||||
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
|
||||
|
||||
|
||||
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
|
||||
debug_script = read("Debug_DoctorWorkstation.bat")
|
||||
|
||||
assert "%LOCALAPPDATA%\\ZhenYangTang\\DoctorWorkstation\\Debug" in debug_script
|
||||
assert "DOCTOR_CONFIG_DIR=%DEBUG_PROFILE%\\config" in debug_script
|
||||
assert "DOCTOR_LOG_DIR=%DEBUG_PROFILE%\\logs" in debug_script
|
||||
assert "%RANDOM%" not in debug_script
|
||||
|
||||
|
||||
def test_macos_one_click_entrypoints_and_release_pipeline() -> None:
|
||||
for name in (
|
||||
"run_macos.command",
|
||||
"package_macos.command",
|
||||
"一键运行.command",
|
||||
"一键打包.command",
|
||||
):
|
||||
assert (PROJECT_ROOT / name).is_file()
|
||||
|
||||
run_script = read("scripts/run_macos.sh")
|
||||
package_script = read("scripts/package_macos.sh")
|
||||
|
||||
assert run_script.index('/usr/bin/open "$artifact"') < run_script.index("macos_ensure_uv")
|
||||
assert "sync --locked" in run_script
|
||||
assert "sync --locked --extra build" in package_script
|
||||
assert 'ci --prefix "$project_root/video_companion"' in package_script
|
||||
assert "/usr/bin/ditto -c -k --sequesterRsrc --keepParent" in package_script
|
||||
assert "/usr/bin/shasum -a 256" in package_script
|
||||
assert "DoctorWorkstation-macOS-$release_arch-$project_version.zip" in package_script
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
MEDIA_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||
|
||||
|
||||
def read(relative_path: str) -> str:
|
||||
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_spec_explicitly_collects_qt_multimedia_and_installs_frozen_gate() -> None:
|
||||
spec = read("packaging/doctor_workstation.spec")
|
||||
|
||||
assert '"PySide6.QtMultimedia"' in spec
|
||||
assert '"PySide6.QtMultimediaWidgets"' in spec
|
||||
assert "qt_multimedia_hiddenimports" in spec
|
||||
assert "runtime_hooks=[str(MEDIA_SMOKE_HOOK)]" in spec
|
||||
assert "Qt6Multimedia*.dll/.dylib/framework" in spec
|
||||
assert "plugins/multimedia" in spec
|
||||
|
||||
|
||||
def test_media_runtime_hook_is_inert_without_gate_argument() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-S", str(MEDIA_HOOK)],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_media_runtime_hook_fails_when_frozen_components_cannot_import() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-S", str(MEDIA_HOOK), "--media-smoke-test"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 70
|
||||
assert "Qt multimedia smoke gate failed" in result.stderr
|
||||
|
||||
|
||||
def test_media_runtime_hook_constructs_player_and_video_widget_offscreen(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"QT_QPA_PLATFORM": "offscreen",
|
||||
"QT_LOGGING_RULES": "qt.multimedia.*=false",
|
||||
"XDG_CONFIG_HOME": str(tmp_path / "config"),
|
||||
"XDG_CACHE_HOME": str(tmp_path / "cache"),
|
||||
}
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(MEDIA_HOOK), "--media-smoke-test"],
|
||||
cwd=tmp_path,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "Frozen Qt multimedia smoke gate passed" in result.stdout
|
||||
|
||||
|
||||
def test_windows_build_checks_files_and_runs_media_gate_before_release_archive() -> None:
|
||||
build = read("scripts/build_windows.ps1")
|
||||
package = read("scripts/package_windows.ps1")
|
||||
|
||||
for required_name in (
|
||||
"QtMultimedia.pyd",
|
||||
"QtMultimediaWidgets.pyd",
|
||||
"Qt6Multimedia.dll",
|
||||
"Qt6MultimediaWidgets.dll",
|
||||
"ffmpegmediaplugin.dll",
|
||||
"windowsmediaplugin.dll",
|
||||
):
|
||||
assert required_name in build
|
||||
assert "Assert-FrozenMultimedia -Artifact $Artifact" in build
|
||||
assert build.index('Argument "--media-smoke-test"') < build.index('Write-Host "Build complete')
|
||||
assert "packaging\\runtime_media_smoke.py" in package
|
||||
assert package.index("& $BuildScript") < package.index("Compress-Archive")
|
||||
|
||||
|
||||
def test_macos_build_checks_modules_frameworks_plugins_and_runs_gate_before_zip() -> None:
|
||||
build = read("scripts/build_macos.sh")
|
||||
package = read("scripts/package_macos.sh")
|
||||
entry_check = read("scripts/check_macos_entrypoints.sh")
|
||||
|
||||
for contract in (
|
||||
"QtMultimedia*.so",
|
||||
"QtMultimediaWidgets*.so",
|
||||
"QtMultimedia.framework",
|
||||
"QtMultimediaWidgets.framework",
|
||||
"*Qt6Multimedia*.dylib",
|
||||
"*/plugins/multimedia",
|
||||
"*mediaplugin*.dylib",
|
||||
"--media-smoke-test",
|
||||
):
|
||||
assert contract in build
|
||||
assert build.index('"--media-smoke-test"') < build.index('echo "Build complete')
|
||||
assert package.index('build_macos.sh"') < package.index("/usr/bin/ditto -c -k")
|
||||
assert 'check_macos_entrypoints.sh"' in package
|
||||
assert 'index_mode" == "100755"' in entry_check
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Patient-level AI report contracts and reception history behaviour."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import (
|
||||
AI_MEDICAL_DISCLAIMER,
|
||||
ReceptionPage,
|
||||
_generated_patient_report,
|
||||
_patient_report_rows,
|
||||
_ReceptionAiAnalysisDialog,
|
||||
)
|
||||
|
||||
|
||||
@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: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
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(reception_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _detail(appointment_id: int, patient_id: int, diagnosis_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"appointment": {
|
||||
"id": appointment_id,
|
||||
"patient_id": patient_id,
|
||||
"patient_name": "快照患者",
|
||||
"status": 1,
|
||||
"appointment_date": date.today().isoformat(),
|
||||
},
|
||||
"patient": {"id": patient_id, "patient_name": "快照患者", "age": 48},
|
||||
"diagnosis": {
|
||||
"id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _snapshot(model: str, version: int, stamp: str) -> dict[str, Any]:
|
||||
label = "OpenAI" if model == "openai" else "千问"
|
||||
return {
|
||||
"id": version * 10 + (2 if model == "openai" else 1),
|
||||
"patient_id": 301,
|
||||
"model_key": model,
|
||||
"model_label": label,
|
||||
"model_name": "gpt-demo" if model == "openai" else "qwen-demo",
|
||||
"version": version,
|
||||
"generated_at": stamp,
|
||||
"report": {
|
||||
"diagnosis": f"{label}第 {version} 版诊断建议",
|
||||
"risk_assessment": [{"label": "随访风险", "level": "low"}],
|
||||
"treatment_advice": f"{label}第 {version} 版治疗建议",
|
||||
"disclaimer": "服务端免责声明",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_remote_patient_report_contract_sends_only_patient_and_model() -> None:
|
||||
class Client:
|
||||
token = "token"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any], **_kwargs: Any) -> Any:
|
||||
self.calls.append(("get", endpoint, dict(params)))
|
||||
return {"patient_id": params["patient_id"], "reports": []}
|
||||
|
||||
def post(self, endpoint: str, body: dict[str, Any], **_kwargs: Any) -> Any:
|
||||
self.calls.append(("post", endpoint, dict(body)))
|
||||
return {"patient_id": body["patient_id"], "reports": []}
|
||||
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.list_patient_ai_reports(301)
|
||||
repository.generate_patient_ai_report(301, model="qwen")
|
||||
|
||||
assert client.calls == [
|
||||
("get", "tcm.diagnosis/patientAiReports", {"patient_id": 301}),
|
||||
(
|
||||
"post",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
{"patient_id": 301, "model": "qwen"},
|
||||
),
|
||||
]
|
||||
assert not ({"key", "api_key", "base_url", "provider"} & client.calls[-1][2].keys())
|
||||
|
||||
|
||||
def test_demo_patient_history_has_two_versions_and_generation_appends() -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
before = repository.list_patient_ai_reports(301)
|
||||
|
||||
assert len(before["reports"]) == 4
|
||||
assert [row["version"] for row in before["reports"] if row["model_key"] == "qwen"] == [2, 1]
|
||||
generated = repository.generate_patient_ai_report(301, model="qwen")
|
||||
|
||||
assert "reports" not in generated
|
||||
assert "latest_by_model" not in generated
|
||||
assert generated["generated_report"]["version"] == 3
|
||||
assert generated["generated_report"] == generated["report"]
|
||||
assert generated["disclaimer"] == AI_MEDICAL_DISCLAIMER
|
||||
assert generated["generated_report"]["disclaimer"] == AI_MEDICAL_DISCLAIMER
|
||||
assert isinstance(generated["source_summary"], dict)
|
||||
assert generated["source_summary"] == generated["generated_report"]["source_summary"]
|
||||
assert len(repository.list_patient_ai_reports(301)["reports"]) == 5
|
||||
assert repository.list_patient_ai_reports(301)["disclaimer"] == AI_MEDICAL_DISCLAIMER
|
||||
|
||||
|
||||
def test_saved_history_is_rendered_without_automatic_generation(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(101, 301, 501)
|
||||
reports = [
|
||||
_snapshot("qwen", 2, "2026-08-13 15:42:00"),
|
||||
_snapshot("openai", 2, "2026-08-13 15:43:00"),
|
||||
_snapshot("qwen", 1, "2026-08-12 09:18:00"),
|
||||
_snapshot("openai", 1, "2026-08-12 09:19:00"),
|
||||
]
|
||||
|
||||
class Repository:
|
||||
list_calls: list[int] = []
|
||||
generate_calls: list[tuple[int, str]] = []
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
assert appointment_id == 101
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
self.list_calls.append(patient_id)
|
||||
return {"patient_id": patient_id, "reports": reports}
|
||||
|
||||
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
|
||||
self.generate_calls.append((patient_id, model))
|
||||
raise AssertionError("saved history must not auto-generate")
|
||||
|
||||
repository = Repository()
|
||||
page = ReceptionPage(
|
||||
repository,
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
|
||||
assert repository.list_calls == [301]
|
||||
assert repository.generate_calls == []
|
||||
assert page.ai_summary_label.text() == "千问第 2 版诊断建议"
|
||||
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
|
||||
assert "第 2 版" in page.ai_analysis_snapshot_meta.text()
|
||||
assert page.ai_analysis_disclaimer.text() == AI_MEDICAL_DISCLAIMER
|
||||
assert not page.ai_analysis_disclaimer.isVisibleTo(page)
|
||||
assert page.ai_analysis_history_button.objectName() == "ReceptionAiHistoryButton"
|
||||
assert page.ai_analysis_regenerate_button.objectName() == "ReceptionAiRegenerateButton"
|
||||
|
||||
dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen")
|
||||
assert dialog.history_selector.count() == 2
|
||||
assert dialog.disclaimer_label.text() == AI_MEDICAL_DISCLAIMER
|
||||
dialog.history_selector.setCurrentIndex(1)
|
||||
assert "第 1 版诊断建议" in dialog.diagnosis_label.text()
|
||||
dialog.close()
|
||||
page.close()
|
||||
|
||||
|
||||
def test_empty_database_and_manual_refresh_append_qwen_then_openai(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(102, 302, 502)
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.reports: list[dict[str, Any]] = []
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
self.calls.append(("list", patient_id))
|
||||
return {"patient_id": patient_id, "reports": list(self.reports)}
|
||||
|
||||
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
|
||||
self.calls.append(("generate", model))
|
||||
version = 1 + sum(row["model_key"] == model for row in self.reports)
|
||||
row = _snapshot(model, version, f"2026-08-14 10:0{len(self.reports)}:00")
|
||||
row["patient_id"] = patient_id
|
||||
self.reports.append(row)
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"generated_report": row,
|
||||
"report": row,
|
||||
}
|
||||
|
||||
repository = Repository()
|
||||
page = ReceptionPage(
|
||||
repository,
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
|
||||
assert repository.calls == [("list", 302), ("generate", "qwen"), ("generate", "openai")]
|
||||
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [1, 1]
|
||||
|
||||
page.ai_analysis_regenerate_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert repository.calls[-2:] == [("generate", "qwen"), ("generate", "openai")]
|
||||
assert [len(page._ai_analysis_histories[key]) for key in ("qwen", "openai")] == [2, 2]
|
||||
page.close()
|
||||
|
||||
|
||||
def test_openai_failure_keeps_new_qwen_snapshot(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(103, 303, 503)
|
||||
|
||||
class Repository:
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
return {"patient_id": patient_id, "reports": []}
|
||||
|
||||
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
|
||||
if model == "openai":
|
||||
raise RuntimeError("OpenAI 暂时不可用")
|
||||
row = _snapshot("qwen", 1, "2026-08-14 10:30:00")
|
||||
row["patient_id"] = patient_id
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"generated_report": row,
|
||||
"report": row,
|
||||
}
|
||||
|
||||
page = ReceptionPage(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
|
||||
assert page._ai_analysis_model_states["qwen"] == "success"
|
||||
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
|
||||
assert len(page._ai_analysis_histories["qwen"]) == 1
|
||||
assert page._ai_analysis_model_states["openai"] == "error"
|
||||
assert "千问新快照已保留" in page.ai_analysis_secondary_status.text()
|
||||
page.close()
|
||||
|
||||
|
||||
def test_late_patient_history_response_is_discarded_after_switch(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = _detail(104, 304, 504)
|
||||
second = _detail(105, 305, 505)
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
def queue(function: Any, *args: Any, **options: Any) -> object:
|
||||
jobs.append({"function": function, "args": args, **options})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(reception_module, "run_async", queue)
|
||||
|
||||
class Repository:
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return first if appointment_id == 104 else second
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
row = _snapshot("qwen", 1, f"2026-08-14 10:{patient_id - 300:02d}:00")
|
||||
row["patient_id"] = patient_id
|
||||
row["report"]["diagnosis"] = f"患者 {patient_id} 的报告"
|
||||
return {"patient_id": patient_id, "reports": [row]}
|
||||
|
||||
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> dict[str, Any]:
|
||||
raise AssertionError("history exists")
|
||||
|
||||
def finish(job: dict[str, Any]) -> None:
|
||||
result = job["function"](*job.get("args", ()))
|
||||
if job.get("on_success"):
|
||||
job["on_success"](result)
|
||||
if job.get("on_finished"):
|
||||
job["on_finished"]()
|
||||
|
||||
page = ReceptionPage(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(first["appointment"])
|
||||
finish(jobs[0])
|
||||
first_history_job = jobs[1]
|
||||
|
||||
page._select_record(second["appointment"])
|
||||
finish(jobs[2])
|
||||
second_history_job = jobs[3]
|
||||
finish(second_history_job)
|
||||
assert page.ai_summary_label.text() == "患者 305 的报告"
|
||||
|
||||
finish(first_history_job)
|
||||
assert page._ai_analysis_patient_id == 305
|
||||
assert page.ai_summary_label.text() == "患者 305 的报告"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
|
||||
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
|
||||
valid = {"patient_id": 301, "reports": [row]}
|
||||
|
||||
rows = _patient_report_rows(valid, expected_patient_id=301)
|
||||
assert rows is not None and len(rows) == 1
|
||||
|
||||
invalid_top_level_ids: tuple[Any, ...] = (None, 0, -1, True, "301", 302)
|
||||
for patient_id in invalid_top_level_ids:
|
||||
assert (
|
||||
_patient_report_rows(
|
||||
{"patient_id": patient_id, "reports": [row]},
|
||||
expected_patient_id=301,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
wrong_row = dict(row, patient_id=302)
|
||||
assert (
|
||||
_patient_report_rows(
|
||||
{"patient_id": 301, "reports": [wrong_row]},
|
||||
expected_patient_id=301,
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
_patient_report_rows(
|
||||
{
|
||||
"patient_id": 302,
|
||||
"data": {"patient_id": 301, "reports": [row]},
|
||||
},
|
||||
expected_patient_id=301,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_post_accepts_only_the_current_persisted_snapshot() -> None:
|
||||
valid = _snapshot("qwen", 3, "2026-08-14 11:05:00")
|
||||
accepted = _generated_patient_report(
|
||||
{"patient_id": 301, "generated_report": valid},
|
||||
expected_patient_id=301,
|
||||
expected_model="qwen",
|
||||
)
|
||||
assert accepted is not None and accepted["id"] == valid["id"]
|
||||
|
||||
invalid_payloads = (
|
||||
{"patient_id": 301, "reports": [valid], "report": valid},
|
||||
{"patient_id": 301, "generated_report": {}, "reports": [valid]},
|
||||
{"patient_id": 302, "generated_report": valid},
|
||||
{
|
||||
"patient_id": 301,
|
||||
"generated_report": dict(valid, patient_id=302),
|
||||
},
|
||||
{"patient_id": 301, "generated_report": dict(valid, id=0)},
|
||||
{"patient_id": 301, "generated_report": dict(valid, id="31")},
|
||||
{
|
||||
"patient_id": 301,
|
||||
"generated_report": dict(valid, model_key="openai"),
|
||||
},
|
||||
)
|
||||
for payload in invalid_payloads:
|
||||
assert (
|
||||
_generated_patient_report(
|
||||
payload,
|
||||
expected_patient_id=301,
|
||||
expected_model="qwen",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_stale_post_history_cannot_fake_generation_success(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(106, 306, 506)
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.generate_calls: list[str] = []
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
return {"patient_id": patient_id, "reports": []}
|
||||
|
||||
def generate_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
*,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
self.generate_calls.append(model)
|
||||
old = _snapshot("qwen", 9, "2026-08-13 08:00:00")
|
||||
old["patient_id"] = patient_id
|
||||
return {
|
||||
"patient_id": patient_id,
|
||||
"generated_report": None,
|
||||
"reports": [old],
|
||||
"report": old,
|
||||
}
|
||||
|
||||
repository = Repository()
|
||||
page = ReceptionPage(
|
||||
repository,
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
|
||||
assert repository.generate_calls == ["qwen"]
|
||||
assert not any(page._ai_analysis_histories.values())
|
||||
assert page._ai_analysis_model_states["qwen"] == "error"
|
||||
page.close()
|
||||
|
||||
|
||||
def test_patient_report_generation_requires_read_and_generate_permissions(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(107, 307, 507)
|
||||
row = _snapshot("qwen", 1, "2026-08-14 11:10:00")
|
||||
row["patient_id"] = 307
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.list_calls = 0
|
||||
self.generate_calls = 0
|
||||
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
self.list_calls += 1
|
||||
return {"patient_id": patient_id, "reports": [row]}
|
||||
|
||||
def generate_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
*,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
self.generate_calls += 1
|
||||
return {"patient_id": patient_id, "generated_report": row}
|
||||
|
||||
cases = (
|
||||
([], False, 0),
|
||||
(["tcm.diagnosis/patientAiReports"], False, 1),
|
||||
(["tcm.diagnosis/generatePatientAiReport"], False, 0),
|
||||
(["tcm.diagnosis/aiAnalysis"], False, 0),
|
||||
(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
],
|
||||
True,
|
||||
1,
|
||||
),
|
||||
)
|
||||
for permissions, expected_enabled, expected_list_calls in cases:
|
||||
repository = Repository()
|
||||
page = ReceptionPage(repository, PermissionSet(permissions))
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
assert page.ai_analysis_regenerate_button.isEnabled() is expected_enabled
|
||||
assert repository.list_calls == expected_list_calls
|
||||
assert repository.generate_calls == 0
|
||||
page.close()
|
||||
|
||||
|
||||
def test_ui_never_displays_internal_prompt_version(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
detail = _detail(108, 308, 508)
|
||||
reports = [
|
||||
_snapshot("qwen", 2, "2026-08-14 11:20:00"),
|
||||
_snapshot("qwen", 1, "2026-08-13 11:20:00"),
|
||||
]
|
||||
for row in reports:
|
||||
row["patient_id"] = 308
|
||||
row.pop("version")
|
||||
row["prompt_version"] = "patient-longitudinal-report-internal-v99"
|
||||
|
||||
class Repository:
|
||||
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||
return detail
|
||||
|
||||
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||
return {"patient_id": patient_id, "reports": reports}
|
||||
|
||||
def generate_patient_ai_report(
|
||||
self,
|
||||
patient_id: int,
|
||||
*,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
raise AssertionError("saved history must not auto-generate")
|
||||
|
||||
page = ReceptionPage(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/patientAiReports",
|
||||
"tcm.diagnosis/generatePatientAiReport",
|
||||
]
|
||||
),
|
||||
)
|
||||
page._select_record(detail["appointment"])
|
||||
application.processEvents()
|
||||
|
||||
assert page.ai_analysis_snapshot_meta.text().startswith("第 2 版")
|
||||
assert "internal-v99" not in page.ai_analysis_snapshot_meta.text()
|
||||
assert "internal-v99" not in page.ai_analysis_snapshot_meta.toolTip()
|
||||
|
||||
dialog = _ReceptionAiAnalysisDialog(page._ai_analysis_histories, preferred_model="qwen")
|
||||
assert dialog.history_selector.itemText(0).startswith("第 2 版")
|
||||
assert dialog.history_selector.itemText(1).startswith("第 1 版")
|
||||
assert "internal-v99" not in dialog.meta_label.text()
|
||||
dialog.close()
|
||||
page.close()
|
||||
|
||||
|
||||
def test_patient_ai_disclaimer_remains_the_unified_text() -> None:
|
||||
assert AI_MEDICAL_DISCLAIMER == (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
@@ -0,0 +1,643 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate
|
||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import (
|
||||
PatientListWorkspace,
|
||||
PatientOrdersWorkspace,
|
||||
PatientProgressWorkspace,
|
||||
PatientsPage,
|
||||
_AppointmentDialog,
|
||||
_AssignDialog,
|
||||
_OrderDetailDialog,
|
||||
_OrderEditDialog,
|
||||
_PaymentDialog,
|
||||
_RefundDialog,
|
||||
)
|
||||
from doctor_workstation.ui.shell import NAVIGATION, ShellWindow, _resolve_navigation
|
||||
|
||||
|
||||
@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: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
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(patients_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions() -> None:
|
||||
permissions = PermissionSet(
|
||||
[
|
||||
"firstvisit.myPatient/lists",
|
||||
"tcm.diagnosis/lists",
|
||||
"tcm.prescription/lists",
|
||||
"doctor.appointment/lists",
|
||||
]
|
||||
)
|
||||
menu = [
|
||||
{
|
||||
"name": "隐藏接诊",
|
||||
"perms": "doctor.appointment/lists",
|
||||
"sort": 999,
|
||||
"is_show": 0,
|
||||
},
|
||||
{
|
||||
"name": "诊疗中心",
|
||||
"sort": 20,
|
||||
"children": [
|
||||
{
|
||||
"name": "患者工作区",
|
||||
"component": "first_visit/my_patients/index",
|
||||
"sort": 80,
|
||||
},
|
||||
{
|
||||
"name": "问诊工作区",
|
||||
"perms": "tcm.diagnosis/lists",
|
||||
"sort": 60,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "停用处方",
|
||||
"perms": "tcm.prescription/lists",
|
||||
"sort": 30,
|
||||
"is_disable": 1,
|
||||
},
|
||||
]
|
||||
|
||||
resolved = _resolve_navigation(menu, permissions, demo_mode=False)
|
||||
|
||||
assert [(item.key, title) for item, title in resolved] == [
|
||||
("patients", "我的患者"),
|
||||
("consultations", "问诊工作区"),
|
||||
]
|
||||
assert _resolve_navigation([], permissions, demo_mode=False) == []
|
||||
assert [item.key for item, _title in _resolve_navigation([], permissions, demo_mode=True)] == [
|
||||
"appointments",
|
||||
"reception",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
]
|
||||
assert (
|
||||
_resolve_navigation(
|
||||
[{"perms": "firstvisit.myPatient/lists"}],
|
||||
PermissionSet(["firstvisit.myPatient.lists"]),
|
||||
demo_mode=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_patient_page_runs_all_three_demo_workspaces_and_progress_timer(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
page.resize(808, 560)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
|
||||
assert [page.tabs.tabText(index) for index in range(page.tabs.count())] == [
|
||||
"患者列表",
|
||||
"订单管理",
|
||||
"面诊进度",
|
||||
]
|
||||
assert page.patient_workspace.table.rowCount() > 0
|
||||
assert page.patient_workspace.scope_label.text() != ""
|
||||
|
||||
page.tabs.setCurrentIndex(1)
|
||||
application.processEvents()
|
||||
assert page.order_workspace.table.rowCount() > 0
|
||||
assert page.order_workspace.metrics["orders"].text() == "1"
|
||||
assert page.order_workspace.metrics["amount"].text() == "¥368.00"
|
||||
|
||||
page.tabs.setCurrentIndex(2)
|
||||
application.processEvents()
|
||||
assert page.progress_workspace.timer.isActive()
|
||||
assert page.progress_workspace.schedule_table.rowCount() == 7
|
||||
|
||||
page.tabs.setCurrentIndex(0)
|
||||
assert not page.progress_workspace.timer.isActive()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_patient_refresh_generation_ignores_late_results(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
callbacks: list[dict[str, Any]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
callbacks.append(options)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
|
||||
workspace.refresh()
|
||||
workspace.refresh()
|
||||
newer = {
|
||||
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
|
||||
"count": 1,
|
||||
}
|
||||
stale = {
|
||||
"lists": [{"id": 1, "diagnosis_id": 1, "patient_name": "旧结果"}],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
callbacks[1]["on_success"](newer)
|
||||
callbacks[0]["on_success"](stale)
|
||||
application.processEvents()
|
||||
|
||||
assert workspace.table.rowCount() == 1
|
||||
assert workspace.table.item(0, 1).text().startswith("新结果")
|
||||
workspace.close()
|
||||
|
||||
|
||||
def test_workspace_queries_use_frozen_page_no_contract(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
class Repository:
|
||||
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(("patients", kwargs))
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(("orders", kwargs))
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_progress(self, **kwargs: Any) -> dict[str, Any]:
|
||||
calls.append(("progress", kwargs))
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
repository = Repository()
|
||||
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
|
||||
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
|
||||
progress = PatientProgressWorkspace(repository)
|
||||
patient.refresh()
|
||||
orders.refresh()
|
||||
progress.refresh()
|
||||
|
||||
assert [name for name, _kwargs in calls] == ["patients", "orders", "progress"]
|
||||
for _name, kwargs in calls:
|
||||
assert kwargs["page_no"] == 1
|
||||
assert kwargs["page_size"] == 15
|
||||
assert "page" not in kwargs
|
||||
patient.close()
|
||||
orders.close()
|
||||
progress.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_workspace_workers_use_gui_thread_query_snapshots(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
queued: list[tuple[Any, tuple[Any, ...], dict[str, Any]]] = []
|
||||
patient_calls: list[dict[str, Any]] = []
|
||||
order_calls: list[dict[str, Any]] = []
|
||||
|
||||
def queue_async(function: Any, *args: Any, **options: Any) -> object:
|
||||
queued.append((function, args, options))
|
||||
return object()
|
||||
|
||||
class Repository:
|
||||
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
|
||||
patient_calls.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
|
||||
order_calls.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
repository = Repository()
|
||||
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
|
||||
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
|
||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||
|
||||
patient.keyword_edit.setText("captured patient")
|
||||
patient.refresh()
|
||||
patient.keyword_edit.setText("changed patient")
|
||||
function, args, _options = queued[0]
|
||||
function(*args)
|
||||
|
||||
orders.keyword_edit.setText("captured order")
|
||||
orders.refresh()
|
||||
orders.keyword_edit.setText("changed order")
|
||||
function, args, _options = queued[1]
|
||||
function(*args)
|
||||
|
||||
assert patient_calls[0]["keyword"] == "captured patient"
|
||||
assert order_calls[0]["keyword"] == "captured order"
|
||||
patient.close()
|
||||
orders.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
appointment_queries: list[dict[str, Any]] = []
|
||||
roster_queries: list[dict[str, Any]] = []
|
||||
slot_queries: list[dict[str, Any]] = []
|
||||
|
||||
class Repository:
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 77, "name": "陈医生", "department_name": "中医科"}]
|
||||
|
||||
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
|
||||
assert dictionary_type == "channels"
|
||||
return [{"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10}]
|
||||
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
appointment_queries.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]:
|
||||
roster_queries.append(kwargs)
|
||||
return {"lists": [{"date": tomorrow}], "count": 1}
|
||||
|
||||
def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]:
|
||||
slot_queries.append(kwargs)
|
||||
return {"slots": [{"time": "09:30-10:00", "available": True, "quota": 2}]}
|
||||
|
||||
row = {
|
||||
"id": 501,
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 999,
|
||||
"source_patient_id": 999,
|
||||
"patient_name": "林晓岚",
|
||||
"doctor_id": 77,
|
||||
}
|
||||
dialog = _AppointmentDialog(row, repository=Repository())
|
||||
application.processEvents()
|
||||
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
|
||||
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
|
||||
dialog.remark.setPlainText("复诊预约")
|
||||
application.processEvents()
|
||||
|
||||
payload = dialog.payload()
|
||||
assert appointment_queries[0]["patient_id"] == 501
|
||||
assert roster_queries[0]["doctor_id"] == 77
|
||||
assert slot_queries[0] == {
|
||||
"doctor_id": 77,
|
||||
"appointment_date": tomorrow,
|
||||
"period": "all",
|
||||
}
|
||||
assert dialog.ok_button.isEnabled()
|
||||
assert payload == {
|
||||
"diagnosis_id": 501,
|
||||
"patient_id": 501,
|
||||
"doctor_id": 77,
|
||||
"appointment_date": tomorrow,
|
||||
"appointment_time": "09:30-10:00",
|
||||
"period": "all",
|
||||
"appointment_type": "video",
|
||||
"remark": "复诊预约",
|
||||
"channel_source": "online",
|
||||
"channel_source_detail": "",
|
||||
}
|
||||
assert payload["patient_id"] != row["source_patient_id"]
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_payment_and_refund_forms_expose_full_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
payment = _PaymentDialog({"amount": 368, "linked_pay_paid_total": 100})
|
||||
payment.pay_create_type.setCurrentIndex(payment.pay_create_type.findData("express_cod"))
|
||||
payment.pay_amount.setValue(268)
|
||||
payment.pay_remark.setPlainText("货到代收")
|
||||
payment.completion_request.setChecked(True)
|
||||
assert payment.payload() == {
|
||||
"order_type": 3,
|
||||
"pay_amount": 268.0,
|
||||
"pay_remark": "货到代收",
|
||||
"completion_request": 1,
|
||||
"pay_create_type": "express_cod",
|
||||
}
|
||||
|
||||
refund = _RefundDialog({"amount": 368})
|
||||
refund.reason.setPlainText("患者取消")
|
||||
assert refund.payload() == {"reason": "患者取消", "refund_amount": None}
|
||||
refund.specify_amount.setChecked(True)
|
||||
refund.refund_amount.setValue(88.5)
|
||||
assert refund.payload() == {"reason": "患者取消", "refund_amount": 88.5}
|
||||
payment.close()
|
||||
refund.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_patient_business_dialogs_expose_shared_visual_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
detail = {
|
||||
"id": 81,
|
||||
"order_no": "ORDER-81",
|
||||
"patient_name": "鹿立核",
|
||||
"recipient_name": "鹿立核",
|
||||
"recipient_phone": "13800138000",
|
||||
"shipping_address": "北京市朝阳区测试路 8 号",
|
||||
"amount": 368,
|
||||
}
|
||||
dialogs = (
|
||||
_PaymentDialog(detail),
|
||||
_RefundDialog(detail),
|
||||
_AssignDialog(
|
||||
detail,
|
||||
[{"id": 7, "name": "陈医助", "department_name": "中医门诊"}],
|
||||
),
|
||||
_OrderDetailDialog(detail),
|
||||
_OrderEditDialog(detail),
|
||||
)
|
||||
|
||||
assert [dialog.objectName() for dialog in dialogs] == [
|
||||
"PatientPaymentDialog",
|
||||
"PatientRefundDialog",
|
||||
"PatientAssignDialog",
|
||||
"PatientOrderDetailDialog",
|
||||
"PatientOrderEditDialog",
|
||||
]
|
||||
assert all(dialog.property("businessDialog") is True for dialog in dialogs)
|
||||
assert all(
|
||||
any(label.property("dialogRole") == "title" for label in dialog.findChildren(QLabel))
|
||||
for dialog in dialogs
|
||||
)
|
||||
assert dialogs[0].findChild(QDialogButtonBox).button(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
).property("variant") == "primary"
|
||||
assert dialogs[1].findChild(QDialogButtonBox).button(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
).property("variant") == "danger"
|
||||
assert dialogs[2].findChild(QDialogButtonBox).button(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
).property("variant") == "primary"
|
||||
assert dialogs[4].findChild(QDialogButtonBox).button(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
).property("variant") == "primary"
|
||||
|
||||
for dialog in dialogs:
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_out_of_order_mutation_successes_each_trigger_reconciliation(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = PatientsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
callbacks: list[dict[str, Any]] = []
|
||||
reconciliations: list[str] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
callbacks.append(options)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(page, "_after_mutation", lambda: reconciliations.append("refresh"))
|
||||
page._run_action("first", lambda: None, success="first done")
|
||||
page._run_action("second", lambda: None, success="second done")
|
||||
|
||||
callbacks[1]["on_success"](None)
|
||||
callbacks[0]["on_success"](None)
|
||||
callbacks[1]["on_finished"]()
|
||||
callbacks[0]["on_finished"]()
|
||||
|
||||
assert reconciliations == ["refresh", "refresh"]
|
||||
assert page._pending_action_tokens == set()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_dialog_loads_histories_and_saves_canonical_fields(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
diagnosis_id = repository.list_patients().items[0].diagnosis_id
|
||||
before = repository.get_diagnosis_detail(diagnosis_id)
|
||||
dialog = DiagnosisDialog(repository)
|
||||
|
||||
dialog.open_for(diagnosis_id, editable=True)
|
||||
application.processEvents()
|
||||
|
||||
assert (
|
||||
dialog.edit_fields["chief_complaint"].toPlainText()
|
||||
== before["diagnosis"]["chief_complaint"]
|
||||
)
|
||||
assert dialog.appointment_table.rowCount() >= 1
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("离屏回归主诉")
|
||||
dialog._save()
|
||||
|
||||
assert (
|
||||
repository.get_diagnosis_detail(diagnosis_id)["diagnosis"]["chief_complaint"]
|
||||
== "离屏回归主诉"
|
||||
)
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_order_action_matrix_requires_exact_permissions_and_states(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
codes = {
|
||||
"tcm.prescriptionOrder/detail",
|
||||
"tcm.prescriptionOrder/edit",
|
||||
"tcm.prescriptionOrder/auditPrescription",
|
||||
"tcm.prescriptionOrder/auditPayment",
|
||||
"tcm.prescriptionOrder/ddcode",
|
||||
"tcm.prescriptionOrder/ship",
|
||||
"tcm.prescriptionOrder/addPayOrder",
|
||||
"tcm.prescriptionOrder/complete",
|
||||
"tcm.prescriptionOrder/refund",
|
||||
"tcm.prescriptionOrder/withdraw",
|
||||
"tcm.prescriptionOrder/uploadToPharmacy",
|
||||
}
|
||||
workspace = PatientOrdersWorkspace(SimpleNamespace(), PermissionSet(codes))
|
||||
pending = {
|
||||
"id": 1,
|
||||
"fulfillment_status": 1,
|
||||
"prescription_audit_status": 0,
|
||||
"payment_slip_audit_status": 0,
|
||||
"amount": 100,
|
||||
"linked_pay_paid_total": 0,
|
||||
}
|
||||
shipped = {
|
||||
**pending,
|
||||
"fulfillment_status": 5,
|
||||
"prescription_audit_status": 1,
|
||||
"payment_slip_audit_status": 1,
|
||||
"linked_pay_paid_total": 80,
|
||||
}
|
||||
|
||||
assert [key for key, _label, _danger in workspace._available_actions(pending)] == [
|
||||
"edit",
|
||||
"audit_prescription",
|
||||
"ddcode",
|
||||
"withdraw",
|
||||
]
|
||||
assert [key for key, _label, _danger in workspace._available_actions(shipped)] == [
|
||||
"revoke_pay_audit",
|
||||
"ddcode",
|
||||
"add_pay_order",
|
||||
"complete",
|
||||
"refund",
|
||||
"upload_pharmacy",
|
||||
]
|
||||
remote_locked = {**pending, "gancao_reciperl_order_no": "GC-REMOTE-1"}
|
||||
assert [key for key, _label, _danger in workspace._available_actions(remote_locked)] == [
|
||||
"audit_prescription",
|
||||
"ddcode",
|
||||
]
|
||||
alias_only = PatientOrdersWorkspace(
|
||||
SimpleNamespace(), PermissionSet(["tcm.prescriptionOrder.edit"])
|
||||
)
|
||||
assert alias_only._available_actions(pending) == []
|
||||
workspace.close()
|
||||
alias_only.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_order_audit_action_uses_repository_contract_values(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
order_id = repository.patient_orders().items[0]["id"]
|
||||
repository.revoke_patient_order_payment_audit(order_id)
|
||||
repository.revoke_patient_order_prescription_audit(order_id)
|
||||
row = repository.get_patient_order(order_id)
|
||||
monkeypatch.setattr(
|
||||
QInputDialog,
|
||||
"getItem",
|
||||
staticmethod(lambda *_args, **_kwargs: ("通过", True)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
QInputDialog,
|
||||
"getText",
|
||||
staticmethod(lambda *_args, **_kwargs: ("离屏审核", True)),
|
||||
)
|
||||
|
||||
page._handle_order_action("audit_prescription", row)
|
||||
|
||||
assert repository.get_patient_order(row["id"])["prescription_audit_status"] == 1
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_uses_demo_session_menu_and_fits_minimum_window(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
reception_menu = next(
|
||||
row for row in session.menu if row.get("perms") == "doctor.appointment/lists"
|
||||
)
|
||||
reception_menu["name"] = "接诊台"
|
||||
reception_menu["sort"] = 99
|
||||
session.menu = [reception_menu]
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{"session": session, "demo_mode": True},
|
||||
permissions=session.permissions,
|
||||
)
|
||||
shell.resize(1024, 640)
|
||||
shell.show()
|
||||
application.processEvents()
|
||||
|
||||
assert list(shell.pages) == ["reception"]
|
||||
assert shell.nav_buttons["reception"].text().endswith("接诊台")
|
||||
assert shell.minimumWidth() == 1024
|
||||
assert shell.minimumHeight() == 640
|
||||
assert shell.size().width() == 1024
|
||||
assert shell.size().height() == 640
|
||||
assert {item.key for item in NAVIGATION} == {
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
}
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_patient_list_reference_geometry_and_row_actions(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
page.resize(1460, 820)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.patient_workspace.refresh()
|
||||
application.processEvents()
|
||||
|
||||
workspace = page.patient_workspace
|
||||
assert all(
|
||||
button.minimumHeight() == 56 and button.maximumHeight() == 56
|
||||
for button in workspace.summary_buttons.values()
|
||||
)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
if workspace.table.rowCount():
|
||||
assert workspace.table.rowHeight(0) == 40
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for permission semantics reused by page and action guards."""
|
||||
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
|
||||
|
||||
def test_global_wildcard_grants_every_page_and_action() -> None:
|
||||
"""The server's global wildcard remains authoritative."""
|
||||
|
||||
permissions = PermissionSet(["*"])
|
||||
|
||||
assert permissions.is_superuser
|
||||
assert permissions.can_access_page("doctor.appointment/lists")
|
||||
assert permissions.can_perform_action("doctor.appointment/complete")
|
||||
assert permissions.can("doctor.appointment", "complete")
|
||||
|
||||
|
||||
def test_all_and_any_accept_sequences_or_positional_values() -> None:
|
||||
"""AND and OR helpers preserve the two web-client permission semantics."""
|
||||
|
||||
permissions = PermissionSet(["doctor.appointment/lists", "doctor.appointment/complete"])
|
||||
|
||||
assert permissions.all("doctor.appointment/lists", "doctor.appointment/complete")
|
||||
assert permissions.has_all(["doctor.appointment/lists", "doctor.appointment/complete"])
|
||||
assert not permissions.all("doctor.appointment/lists", "doctor.appointment/cancel")
|
||||
assert permissions.any(["doctor.appointment/cancel", "doctor.appointment/complete"])
|
||||
assert not permissions.has_any([])
|
||||
assert permissions.has_all([])
|
||||
|
||||
|
||||
def test_page_action_categories_and_resource_wildcards() -> None:
|
||||
"""Explicit page/action categories share checks without overloading UI code."""
|
||||
|
||||
permissions = PermissionSet(
|
||||
pages=["patients/view"],
|
||||
actions=["tcm.prescriptionLibrary/*"],
|
||||
)
|
||||
|
||||
assert permissions.can_access_page("patients/view")
|
||||
assert permissions.can("tcm.prescriptionLibrary", "add")
|
||||
assert permissions.can_perform_action("tcm.prescriptionLibrary/delete")
|
||||
assert not permissions.can_perform_action("tcm.prescription/delete")
|
||||
assert "patients/view" in permissions
|
||||
|
||||
|
||||
def test_permission_set_normalises_and_deduplicates_values() -> None:
|
||||
"""Whitespace and duplicates do not create surprising guard results."""
|
||||
|
||||
permissions = PermissionSet([" alpha/read ", "alpha/read", ""])
|
||||
|
||||
assert list(permissions) == ["alpha/read"]
|
||||
assert len(permissions) == 1
|
||||
assert bool(permissions)
|
||||
@@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.errors import ApiTimeoutError
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import (
|
||||
DIAGNOSIS_AI_KIND,
|
||||
DiagnosisAiAssistantDialog,
|
||||
PrescriptionAiReportDialog,
|
||||
can_open_ai_explain,
|
||||
can_open_diagnosis_ai_report,
|
||||
can_use_diagnosis_ai_assistant,
|
||||
diagnosis_ai_task,
|
||||
preferred_ai_model,
|
||||
structured_report_to_text,
|
||||
structured_text_to_report,
|
||||
)
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
|
||||
|
||||
@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: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
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(ai_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def test_structured_report_round_trips_eight_chinese_sections() -> None:
|
||||
parsed = structured_text_to_report(
|
||||
structured_report_to_text(
|
||||
{
|
||||
"summary": "肝郁脾虚",
|
||||
"possible_symptoms": ["胁胀", "纳差"],
|
||||
"main_indications": "疏肝健脾",
|
||||
"efficacy": ["疏肝"],
|
||||
"suitable_people": ["情志不畅者"],
|
||||
"compatibility_analysis": "柴胡配白芍",
|
||||
"cautions": ["需辨证"],
|
||||
"disclaimer": "仅供审方",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert parsed["ok"] is True
|
||||
assert parsed["report"]["summary"] == "肝郁脾虚"
|
||||
assert parsed["report"]["possible_symptoms"] == ["胁胀", "纳差"]
|
||||
assert "缺少章节标题" in structured_text_to_report("核心判断\n有内容").get("error", "")
|
||||
|
||||
|
||||
def test_ai_explain_permission_matches_admin_or_guard() -> None:
|
||||
assert can_open_ai_explain(PermissionSet(["wcf.prescription/read"]))
|
||||
assert can_open_ai_explain(PermissionSet(["tcm.prescriptionLibrary/aiReports"]))
|
||||
assert not can_open_ai_explain(PermissionSet(["wcf.prescription/edit"]))
|
||||
|
||||
|
||||
def test_ai_entry_content_selects_the_initial_server_report() -> None:
|
||||
assert preferred_ai_model(entry="prescription") == "qwen"
|
||||
assert preferred_ai_model("请给出中药用药调整建议", entry="reception_assistant") == "qwen"
|
||||
assert preferred_ai_model("下一步并发症筛查", entry="reception_assistant") == "openai"
|
||||
assert diagnosis_ai_task("下一步检查建议") == "exam_review"
|
||||
assert diagnosis_ai_task("用药调整建议") == "medication_review"
|
||||
assert diagnosis_ai_task("并发症筛查") == "complication_risk"
|
||||
assert diagnosis_ai_task("请核对最新版指南") == "guideline_review"
|
||||
assert diagnosis_ai_task("概括当前病情") == "summary"
|
||||
assert diagnosis_ai_task("评估当前用药风险") == "medication_review"
|
||||
assert diagnosis_ai_task("患者教育要点") == "custom"
|
||||
|
||||
|
||||
def test_library_hides_ai_explain_without_permission(application: QApplication) -> None:
|
||||
page = PrescriptionLibraryPage(
|
||||
SimpleNamespace(),
|
||||
PermissionSet(["wcf.prescription/edit"]),
|
||||
SimpleNamespace(id=1),
|
||||
)
|
||||
assert page.ai_button.isHidden()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dialog_renders_saved_structured_report(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.prescriptionLibrary/aiReports",
|
||||
"tcm.prescriptionLibrary/generateAiReports",
|
||||
"tcm.prescriptionLibrary/editAiReport",
|
||||
"wcf.prescription/read",
|
||||
]
|
||||
),
|
||||
)
|
||||
dialog.open_for(
|
||||
{
|
||||
"id": 701,
|
||||
"prescription_name": "疏肝健脾基础方",
|
||||
"formula_type": "主方",
|
||||
"herbs": [{"name": "柴胡", "dosage": "10g"}],
|
||||
}
|
||||
)
|
||||
|
||||
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
|
||||
assert dialog.windowTitle() == "AI 处方解释"
|
||||
assert dialog.subtitle_label.text() == "疏肝健脾基础方"
|
||||
assert "柴胡 10g" in dialog.snapshot_body.text()
|
||||
assert any("核心判断" in text for text in labels)
|
||||
assert any("疏肝健脾" in text for text in labels)
|
||||
assert dialog.can_refresh is True
|
||||
assert not dialog.generate_button.isHidden()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dialog_generate_creates_missing_model_reports(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
repository,
|
||||
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
|
||||
)
|
||||
dialog.open_for(
|
||||
{
|
||||
"id": 702,
|
||||
"prescription_name": "安神助眠加减方",
|
||||
"formula_type": "辅方",
|
||||
"herbs": [{"name": "酸枣仁", "dosage": "20g"}],
|
||||
}
|
||||
)
|
||||
assert dialog._state("qwen").data is None
|
||||
dialog._generate()
|
||||
assert dialog._state("qwen").data is not None
|
||||
assert dialog._state("openai").data is not None
|
||||
assert "安神助眠" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dialog_edit_saves_structured_json_payload(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class Repository:
|
||||
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"prescription_id": template_id,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"reports": [
|
||||
{
|
||||
"report_id": 11,
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-13 10:00:00",
|
||||
"report": {
|
||||
"summary": "原判断",
|
||||
"possible_symptoms": ["乏力"],
|
||||
"main_indications": "健脾",
|
||||
"efficacy": ["益气"],
|
||||
"suitable_people": ["脾虚者"],
|
||||
"compatibility_analysis": "黄芪为君",
|
||||
"cautions": ["需辨证"],
|
||||
"disclaimer": "仅供审方",
|
||||
},
|
||||
"content": "",
|
||||
"is_stale": False,
|
||||
"is_edited": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def edit_prescription_template_ai_report(
|
||||
self, template_id: int, *, report_id: int, content: str
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
{"template_id": template_id, "report_id": report_id, "content": content}
|
||||
)
|
||||
return {
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
"report": {
|
||||
"report_id": report_id,
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-13 10:00:00",
|
||||
"report": {
|
||||
"summary": "修订判断",
|
||||
"possible_symptoms": ["乏力"],
|
||||
"main_indications": "健脾",
|
||||
"efficacy": ["益气"],
|
||||
"suitable_people": ["脾虚者"],
|
||||
"compatibility_analysis": "黄芪为君",
|
||||
"cautions": ["需辨证"],
|
||||
"disclaimer": "仅供审方",
|
||||
},
|
||||
"is_edited": True,
|
||||
},
|
||||
}
|
||||
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
Repository(),
|
||||
PermissionSet(["tcm.prescriptionLibrary/editAiReport"]),
|
||||
)
|
||||
dialog.open_for({"id": 88, "prescription_name": "测试方", "formula_type": "主方", "herbs": []})
|
||||
dialog._begin_edit()
|
||||
assert dialog._state("qwen").editing is True
|
||||
dialog._state("qwen").draft = structured_report_to_text(
|
||||
{
|
||||
"summary": "修订判断",
|
||||
"possible_symptoms": ["乏力"],
|
||||
"main_indications": "健脾",
|
||||
"efficacy": ["益气"],
|
||||
"suitable_people": ["脾虚者"],
|
||||
"compatibility_analysis": "黄芪为君",
|
||||
"cautions": ["需辨证"],
|
||||
"disclaimer": "仅供审方",
|
||||
}
|
||||
)
|
||||
dialog._save_edit()
|
||||
assert calls[0]["template_id"] == 88
|
||||
assert calls[0]["report_id"] == 11
|
||||
assert '"summary": "修订判断"' in calls[0]["content"]
|
||||
assert dialog._state("qwen").editing is False
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_ai_permission_matches_reception_or_guard() -> None:
|
||||
assert can_open_diagnosis_ai_report(PermissionSet(["doctor.appointment/reception"]))
|
||||
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/aiReports"]))
|
||||
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/readonlyDetail"]))
|
||||
assert not can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/edit"]))
|
||||
assert can_use_diagnosis_ai_assistant(PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
||||
assert not can_use_diagnosis_ai_assistant(PermissionSet(["doctor.appointment/reception"]))
|
||||
|
||||
|
||||
def test_diagnosis_dialog_renders_saved_case_report(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(
|
||||
[
|
||||
"tcm.diagnosis/aiReports",
|
||||
"tcm.diagnosis/generateAiReports",
|
||||
"tcm.diagnosis/editAiReport",
|
||||
]
|
||||
),
|
||||
kind=DIAGNOSIS_AI_KIND,
|
||||
)
|
||||
dialog.open_for(
|
||||
{
|
||||
"id": 501,
|
||||
"diagnosis_id": 501,
|
||||
"patient_name": "林晓岚",
|
||||
"consultation_type": "复诊",
|
||||
"clinical_diagnosis": "肝郁脾虚证",
|
||||
"tongue": "舌淡红,苔薄白",
|
||||
"pulse": "弦细",
|
||||
}
|
||||
)
|
||||
|
||||
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
|
||||
assert dialog.windowTitle() == "AI 报告"
|
||||
assert dialog.subtitle_label.text() == "林晓岚"
|
||||
assert dialog.snapshot_caption.text() == "完整病历"
|
||||
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
||||
assert any("核心判断" in text for text in labels)
|
||||
assert dialog.can_refresh is True
|
||||
assert not dialog.generate_button.isHidden()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dialog_preferred_model_and_capabilities_respect_local_permission(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
assert diagnosis_id == 501
|
||||
return {
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"reports": [
|
||||
{"report_id": 1, "model_key": "qwen", "content": "千问报告"},
|
||||
{"report_id": 2, "model_key": "openai", "content": "OpenAI 报告"},
|
||||
],
|
||||
}
|
||||
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
Repository(),
|
||||
PermissionSet(["tcm.diagnosis/aiReports"]),
|
||||
kind=DIAGNOSIS_AI_KIND,
|
||||
)
|
||||
dialog.open_for(
|
||||
{"id": 501, "patient_name": "林晓岚"},
|
||||
preferred_model="openai",
|
||||
)
|
||||
|
||||
assert dialog.active_profile == "openai"
|
||||
assert dialog.tabs.currentIndex() == 1
|
||||
assert dialog.can_refresh is False
|
||||
assert dialog.can_edit is False
|
||||
assert dialog.generate_button.isHidden()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dialog_shows_friendly_timeout_and_reenables_loading_state(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
raise ApiTimeoutError(f"diagnosis {diagnosis_id} timed out")
|
||||
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
Repository(),
|
||||
PermissionSet(["tcm.diagnosis/aiReports"]),
|
||||
kind=DIAGNOSIS_AI_KIND,
|
||||
)
|
||||
dialog.open_for({"id": 501, "patient_name": "超时患者"})
|
||||
|
||||
assert dialog.load_loading is False
|
||||
assert dialog.load_error == "连接服务器超时,请检查网络后重试。"
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class Repository:
|
||||
def analyze_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str,
|
||||
) -> dict[str, Any]:
|
||||
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
||||
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
||||
|
||||
assert calls == [
|
||||
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
||||
]
|
||||
assert dialog.answer_label.text() == "建议复核肾功能与眼底。"
|
||||
assert "openai" in dialog.model_label.text()
|
||||
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
|
||||
assert dialog.loading is False
|
||||
assert dialog.retry_button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def analyze_diagnosis_ai(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
*,
|
||||
task: str,
|
||||
) -> dict[str, Any]:
|
||||
raise ApiTimeoutError(
|
||||
f"diagnosis {diagnosis_id} {task} {prompt} timed out"
|
||||
)
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "下一步检查建议", task="exam_review")
|
||||
|
||||
assert dialog.status_banner.label.text() == "连接服务器超时,请检查网络后重试。"
|
||||
assert dialog.loading is False
|
||||
assert dialog.retry_button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_exposes_loading_state(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pending: dict[str, Any] = {}
|
||||
|
||||
def hold_request(function: Any, **callbacks: Any) -> object:
|
||||
pending.update({"function": function, **callbacks})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(ai_module, "run_async", hold_request)
|
||||
dialog = DiagnosisAiAssistantDialog(SimpleNamespace())
|
||||
dialog.open_for(501, "下一步检查建议", task="exam_review")
|
||||
|
||||
assert dialog.loading is True
|
||||
assert not dialog.retry_button.isEnabled()
|
||||
assert "正在" in dialog.status_banner.label.text()
|
||||
pending["on_success"]({"answer": "检查建议", "model_key": "openai"})
|
||||
assert dialog.loading is False
|
||||
assert dialog.retry_button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_dialog_generate_creates_missing_model_reports(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
dialog = PrescriptionAiReportDialog(
|
||||
repository,
|
||||
PermissionSet(["*", "tcm.diagnosis/editAiReport"]),
|
||||
kind=DIAGNOSIS_AI_KIND,
|
||||
)
|
||||
dialog.open_for(
|
||||
{
|
||||
"id": 502,
|
||||
"patient_name": "赵明远",
|
||||
"clinical_diagnosis": "痰湿中阻证",
|
||||
}
|
||||
)
|
||||
assert dialog._state("qwen").data is None
|
||||
dialog._generate()
|
||||
assert dialog._state("qwen").data is not None
|
||||
assert dialog._state("openai").data is not None
|
||||
assert "赵明远" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QTabWidget, QWidget
|
||||
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _repository() -> SimpleNamespace:
|
||||
return SimpleNamespace(list_medicines=lambda **_kwargs: {"lists": [], "count": 0})
|
||||
|
||||
|
||||
def _seed() -> dict[str, object]:
|
||||
return {
|
||||
"id": 502,
|
||||
"diagnosis_id": 301,
|
||||
"appointment_id": 401,
|
||||
"patient_name": "林晓岚",
|
||||
"phone": "13800138000",
|
||||
"gender": 0,
|
||||
"age": 34,
|
||||
"visit_no": "1K00000401",
|
||||
"tongue": "面色少华",
|
||||
"tongue_image": "舌淡红、苔薄白",
|
||||
"pulse": "脉细",
|
||||
"pulse_condition": "沉取无力",
|
||||
"clinical_diagnosis": "气阴两虚",
|
||||
"doctor_name": "陈医生",
|
||||
"herbs": [
|
||||
{"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
|
||||
{"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"},
|
||||
{"medicine_id": 13, "name": "白术", "dosage": 10, "formula_type": "主方"},
|
||||
{"medicine_id": 14, "name": "茯苓", "dosage": 12, "formula_type": "主方"},
|
||||
{"medicine_id": 15, "name": "酸枣仁", "dosage": 9, "formula_type": "辅方"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _show(editor: PrescriptionEditorDialog, application: QApplication) -> None:
|
||||
editor.show()
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_editor_is_a_fixed_header_body_footer_right_drawer(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(1440, 900)
|
||||
host.show()
|
||||
editor = PrescriptionEditorDialog(
|
||||
_repository(),
|
||||
_seed(),
|
||||
current_user=SimpleNamespace(id=7, name="陈医生"),
|
||||
parent=host,
|
||||
)
|
||||
_show(editor, application)
|
||||
|
||||
origin = host.mapToGlobal(QPoint(0, 0))
|
||||
assert editor.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||
assert editor.width() == 860
|
||||
assert editor.height() == host.height()
|
||||
assert editor.x() + editor.width() == origin.x() + host.width()
|
||||
assert editor.y() == origin.y()
|
||||
assert editor.save_button.text() == "确定"
|
||||
assert editor.windowTitle() == "新增处方"
|
||||
assert not editor.findChildren(QTabWidget)
|
||||
assert editor.body_scroll.verticalScrollBar().maximum() > 0
|
||||
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||
|
||||
section_tops = [section.geometry().top() for section in editor.section_widgets]
|
||||
assert section_tops == sorted(section_tops)
|
||||
assert [section.parentWidget() for section in editor.section_widgets] == [
|
||||
editor.body_content
|
||||
] * 5
|
||||
|
||||
header_y = editor.header.mapToGlobal(QPoint(0, 0)).y()
|
||||
footer_y = editor.footer.mapToGlobal(QPoint(0, 0)).y()
|
||||
editor.body_scroll.verticalScrollBar().setValue(
|
||||
editor.body_scroll.verticalScrollBar().maximum()
|
||||
)
|
||||
application.processEvents()
|
||||
assert editor.header.mapToGlobal(QPoint(0, 0)).y() == header_y
|
||||
assert editor.footer.mapToGlobal(QPoint(0, 0)).y() == footer_y
|
||||
|
||||
editor.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_drawer_uses_full_width_on_a_narrow_host(application: QApplication) -> None:
|
||||
host = QWidget()
|
||||
host.resize(760, 720)
|
||||
host.show()
|
||||
editor = PrescriptionEditorDialog(_repository(), _seed(), parent=host)
|
||||
_show(editor, application)
|
||||
|
||||
origin = host.mapToGlobal(QPoint(0, 0))
|
||||
assert editor.width() == host.width()
|
||||
assert editor.x() == origin.x()
|
||||
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||
assert editor.herbs.table_mode
|
||||
assert editor.add_main_button.text() == "添加主方药材"
|
||||
|
||||
editor.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_decoction_usage_fields_pack_without_a_hidden_column_gap(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(1440, 900)
|
||||
host.show()
|
||||
source = _seed()
|
||||
source["prescription_type"] = "饮片"
|
||||
editor = PrescriptionEditorDialog(_repository(), source, parent=host)
|
||||
_show(editor, application)
|
||||
|
||||
assert editor._main_decoction_field.isVisible()
|
||||
assert editor._main_bags_field.isVisible()
|
||||
assert editor._main_bag_field.isHidden()
|
||||
assert editor._main_dosage_field.y() == editor._main_decoction_field.y()
|
||||
gap = editor._main_decoction_field.x() - (
|
||||
editor._main_dosage_field.x() + editor._main_dosage_field.width()
|
||||
)
|
||||
assert 0 <= gap <= 24
|
||||
assert abs(editor._main_dosage_field.width() - editor._main_decoction_field.width()) < 40
|
||||
assert editor.cancel_button.x() < editor.save_button.x()
|
||||
|
||||
editor.close()
|
||||
host.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_duplicate_warning_and_admin_dto_survive_the_visual_restructure(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
source = _seed()
|
||||
source["herbs"] = [
|
||||
{"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
|
||||
{"medicine_id": 11, "name": " 黄芪 ", "dosage": 9, "formula_type": "辅方"},
|
||||
]
|
||||
editor = PrescriptionEditorDialog(
|
||||
_repository(),
|
||||
source,
|
||||
mode="edit",
|
||||
current_user=SimpleNamespace(id=7, name="陈医生"),
|
||||
)
|
||||
editor.signature._has_strokes = True
|
||||
payload = editor.payload()
|
||||
|
||||
assert payload["id"] == 502
|
||||
assert payload["diagnosis_id"] == 301
|
||||
assert payload["herbs"][0]["formula_type"] == "主方"
|
||||
assert payload["herbs"][1]["formula_type"] == "辅方"
|
||||
assert all(bool(row.property("duplicate")) for row in editor.herbs.rows)
|
||||
assert "存在重复药名" in editor.herb_summary.text()
|
||||
|
||||
editor.accept()
|
||||
assert editor.result() == QDialog.DialogCode.Accepted
|
||||
|
||||
editor.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_locked_template_rows_keep_the_existing_mutation_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
source = _seed()
|
||||
source["herbs"] = [
|
||||
{
|
||||
"medicine_id": 11,
|
||||
"name": "黄芪",
|
||||
"dosage": 15,
|
||||
"formula_type": "主方",
|
||||
"locked": True,
|
||||
}
|
||||
]
|
||||
editor = PrescriptionEditorDialog(_repository(), source)
|
||||
|
||||
assert editor.herbs.locked
|
||||
assert not editor.add_main_button.isEnabled()
|
||||
assert not editor.add_aux_button.isEnabled()
|
||||
assert editor.import_library_button.isEnabled()
|
||||
assert editor.paste_button.isEnabled()
|
||||
assert not editor.herbs.rows[0].medicine.isEnabled()
|
||||
assert not editor.herbs.rows[0].dosage.isEnabled()
|
||||
assert editor.herbs.rows[0].remove_button.isHidden()
|
||||
assert editor.payload()["herbs"][0]["locked"] is True
|
||||
|
||||
editor.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,461 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import widgets
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.dialogs import prescription as dialog_module
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
|
||||
from doctor_workstation.ui.dialogs.prescription import (
|
||||
PrescriptionEditorDialog,
|
||||
PrescriptionOrderDialog,
|
||||
PrescriptionTemplateDialog,
|
||||
)
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescription_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _immediate_async(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = 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()
|
||||
|
||||
|
||||
class _DiagnosisRepository:
|
||||
def __init__(self) -> None:
|
||||
self.order_queries: list[dict[str, Any]] = []
|
||||
self.phone_checks: list[dict[str, Any]] = []
|
||||
self.id_card_checks: list[dict[str, Any]] = []
|
||||
self.updates: list[tuple[int, dict[str, Any]]] = []
|
||||
|
||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||
del readonly
|
||||
return {
|
||||
"diagnosis": {
|
||||
"id": diagnosis_id,
|
||||
"patient_id": 321,
|
||||
"patient_name": "林晓岚",
|
||||
"phone": "13812345678",
|
||||
"id_card": "510107199001011234",
|
||||
"gender": 0,
|
||||
"age": 36,
|
||||
"height": 162.5,
|
||||
"weight": 52.0,
|
||||
"fasting_blood_sugar": "6.2",
|
||||
"diagnosis_type": "first_visit",
|
||||
"local_hospital_name": "杭州市第一人民医院",
|
||||
"chief_complaint": "乏力",
|
||||
"symptoms": "口干",
|
||||
"appetite": ["一般", "少食"],
|
||||
"clinical_diagnosis": "气阴两虚",
|
||||
"can_edit_patient_basic": True,
|
||||
},
|
||||
"patient": {"id": 321},
|
||||
"appointment": {},
|
||||
}
|
||||
|
||||
def appointment_history(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def assign_history(self, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def list_prescription_orders(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.order_queries.append(kwargs)
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": 8,
|
||||
"order_no": "ORDER-8",
|
||||
"prescription_id": 5,
|
||||
"patient_name": "林晓岚",
|
||||
"amount": 128,
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self.phone_checks.append(payload)
|
||||
return {"exists": False}
|
||||
|
||||
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
self.id_card_checks.append(payload)
|
||||
return {"duplicate": False}
|
||||
|
||||
def update_diagnosis(
|
||||
self, diagnosis: int, changes: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
self.updates.append((diagnosis, dict(changes or {})))
|
||||
return {"id": diagnosis, **dict(changes or {})}
|
||||
|
||||
|
||||
def test_canonical_permission_helper_rejects_dot_aliases_and_accepts_wildcards() -> None:
|
||||
assert not widgets.has_permission(
|
||||
PermissionSet(["cf.prescription.edit"]), "cf.prescription/edit"
|
||||
)
|
||||
assert widgets.has_permission(PermissionSet(["cf.prescription/*"]), "cf.prescription/edit")
|
||||
assert widgets.has_permission(PermissionSet(["*"]), "cf.prescription/edit")
|
||||
assert not widgets.has_permission({}, "cf.prescription/edit")
|
||||
|
||||
|
||||
def test_diagnosis_prescription_edit_uses_admin_edit_permission(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
repository = SimpleNamespace(
|
||||
create_prescription=lambda **_kwargs: {},
|
||||
update_prescription=lambda **_kwargs: {},
|
||||
)
|
||||
create_only = DiagnosisDialog(
|
||||
repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/chufang"]),
|
||||
)
|
||||
assert create_only._can_prescribe
|
||||
assert not create_only._can_edit_prescription
|
||||
create_only.close()
|
||||
|
||||
edit_only = DiagnosisDialog(
|
||||
repository,
|
||||
permissions=PermissionSet(["cf.prescription/edit"]),
|
||||
)
|
||||
assert not edit_only._can_prescribe
|
||||
assert edit_only._can_edit_prescription
|
||||
edit_only.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_masks_sensitive_fields_and_loads_exact_context_orders(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
repository = _DiagnosisRepository()
|
||||
dialog = DiagnosisDialog(
|
||||
repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/patientOrders"]),
|
||||
)
|
||||
|
||||
dialog.open_for(77, editable=False)
|
||||
|
||||
assert dialog.summary_fields["phone"].text() == "138****5678"
|
||||
assert dialog.summary_fields["id_card"].text() == "510107********1234"
|
||||
assert dialog.edit_fields["phone"].toPlainText() == "138****5678"
|
||||
assert dialog.edit_fields["id_card"].toPlainText() == "510107********1234"
|
||||
assert dialog.edit_fields["phone"].isReadOnly()
|
||||
assert dialog.edit_fields["id_card"].isReadOnly()
|
||||
assert dialog.orders_table.rowCount() == 1
|
||||
assert repository.order_queries == [
|
||||
{
|
||||
"page_no": 1,
|
||||
"page_size": 10,
|
||||
"context_diagnosis_id": 77,
|
||||
"patient_id": 321,
|
||||
"scene": "diagnosis_edit",
|
||||
}
|
||||
]
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_edit_checks_unique_identity_and_saves_expanded_dto(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||
repository = _DiagnosisRepository()
|
||||
dialog = DiagnosisDialog(
|
||||
repository,
|
||||
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
|
||||
)
|
||||
dialog.open_for(77, editable=True)
|
||||
dialog.edit_fields["symptoms"].setPlainText("口干、多饮")
|
||||
dialog.edit_fields["appetite"].setPlainText("一般、少食")
|
||||
|
||||
dialog._save()
|
||||
|
||||
assert repository.phone_checks == [{"phone": "13812345678", "id": 77}]
|
||||
assert repository.id_card_checks == [{"id_card": "510107199001011234", "id": 77}]
|
||||
diagnosis_id, changes = repository.updates[-1]
|
||||
assert diagnosis_id == 77
|
||||
assert changes["phone"] == "13812345678"
|
||||
assert changes["id_card"] == "510107199001011234"
|
||||
assert changes["symptoms"] == "口干、多饮"
|
||||
assert changes["appetite"] == ["一般", "少食"]
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_duplicate_herbs_match_admin_warning_for_issued_prescriptions(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
herbs = [
|
||||
{"medicine_id": 11, "name": "黄芪", "dosage": 10},
|
||||
{"medicine_id": 11, "name": "黄芪", "dosage": 15},
|
||||
]
|
||||
repository = SimpleNamespace()
|
||||
template = PrescriptionTemplateDialog(
|
||||
repository,
|
||||
{"id": 1, "prescription_name": "重复方", "herbs": herbs},
|
||||
mode="edit",
|
||||
)
|
||||
template.accept()
|
||||
assert template.result() == QDialog.DialogCode.Rejected
|
||||
assert "药材不可重复:黄芪" in template.validation.label.text()
|
||||
|
||||
editor = PrescriptionEditorDialog(
|
||||
repository,
|
||||
mode="add",
|
||||
current_user=SimpleNamespace(id=9, name="周医生"),
|
||||
)
|
||||
editor.patient_name.setText("林晓岚")
|
||||
editor.clinical_diagnosis.setPlainText("气虚")
|
||||
editor.signature._has_strokes = True
|
||||
editor.herbs.set_rows(herbs, locked=False)
|
||||
assert "存在重复药名" in editor.herb_summary.text()
|
||||
editor.accept()
|
||||
assert editor.result() == QDialog.DialogCode.Accepted
|
||||
template.close()
|
||||
editor.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
callbacks: list[dict[str, Any]] = []
|
||||
|
||||
def queue_async(_function: Any, **options: Any) -> object:
|
||||
callbacks.append(options)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(dialog_module.QTimer, "singleShot", staticmethod(lambda *_args: None))
|
||||
dialog = PrescriptionOrderDialog(
|
||||
SimpleNamespace(list_paid_prescription_orders=lambda diagnosis_id: {}),
|
||||
{"id": 12, "diagnosis_id": 6, "patient_name": "林晓岚"},
|
||||
)
|
||||
|
||||
dialog._load_paid_orders()
|
||||
assert not dialog.save_button.isEnabled()
|
||||
dialog.diagnosis_id.setValue(7)
|
||||
assert len(callbacks) == 2
|
||||
|
||||
callbacks[0]["on_success"](
|
||||
{"lists": [{"id": 66, "order_no": "OLD"}], "deposit_min_amount": 100}
|
||||
)
|
||||
assert dialog.paid_orders.count() == 0
|
||||
assert not dialog.save_button.isEnabled()
|
||||
|
||||
callbacks[1]["on_success"]({"lists": [{"id": 77, "order_no": "NEW"}], "deposit_min_amount": 50})
|
||||
assert dialog.paid_orders.item(0).data(Qt.ItemDataRole.UserRole) == 77
|
||||
assert dialog.save_button.isEnabled()
|
||||
assert dialog._paid_orders_diagnosis_id == 7
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
|
||||
callback["on_success"](result)
|
||||
if callback.get("on_finished"):
|
||||
callback["on_finished"]()
|
||||
|
||||
|
||||
def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
library_callbacks: list[tuple[Any, dict[str, Any]]] = []
|
||||
library_calls: list[dict[str, Any]] = []
|
||||
|
||||
def queue_library(function: Any, **options: Any) -> object:
|
||||
library_callbacks.append((function, options))
|
||||
return object()
|
||||
|
||||
class LibraryRepository:
|
||||
def list_prescription_templates(self, **kwargs: Any) -> dict[str, Any]:
|
||||
library_calls.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
monkeypatch.setattr(library_module, "run_async", queue_library)
|
||||
library = PrescriptionLibraryPage(
|
||||
LibraryRepository(), PermissionSet(["wcf.prescription/*"]), SimpleNamespace(id=1)
|
||||
)
|
||||
library.refresh()
|
||||
library.name_filter.setText("新条件")
|
||||
library.refresh()
|
||||
assert len(library_callbacks) == 1
|
||||
first_function, first_options = library_callbacks[0]
|
||||
first_result = first_function()
|
||||
_finish_queued(first_options, first_result)
|
||||
assert len(library_callbacks) == 2
|
||||
second_function, second_options = library_callbacks[1]
|
||||
second_result = second_function()
|
||||
_finish_queued(second_options, second_result)
|
||||
assert [call["prescription_name"] for call in library_calls] == ["", "新条件"]
|
||||
|
||||
issued_callbacks: list[tuple[Any, dict[str, Any]]] = []
|
||||
issued_calls: list[dict[str, Any]] = []
|
||||
|
||||
def queue_issued(function: Any, **options: Any) -> object:
|
||||
issued_callbacks.append((function, options))
|
||||
return object()
|
||||
|
||||
class IssuedRepository:
|
||||
def list_prescriptions(self, **kwargs: Any) -> dict[str, Any]:
|
||||
issued_calls.append(kwargs)
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
monkeypatch.setattr(prescription_module, "run_async", queue_issued)
|
||||
issued = PrescriptionsPage(
|
||||
IssuedRepository(), PermissionSet(["cf.prescription/*"]), SimpleNamespace(id=1)
|
||||
)
|
||||
issued.refresh()
|
||||
issued.patient_filter.setText("新患者")
|
||||
issued.refresh()
|
||||
assert len(issued_callbacks) == 1
|
||||
first_function, first_options = issued_callbacks[0]
|
||||
first_result = first_function()
|
||||
_finish_queued(first_options, first_result)
|
||||
assert len(issued_callbacks) == 2
|
||||
second_function, second_options = issued_callbacks[1]
|
||||
second_result = second_function()
|
||||
_finish_queued(second_options, second_result)
|
||||
assert [call["patient_name"] for call in issued_calls] == ["", "新患者"]
|
||||
library.close()
|
||||
issued.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_detail_requires_permission_and_ignores_stale_target(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository_calls: list[int] = []
|
||||
|
||||
class Repository:
|
||||
def get_diagnosis_detail(
|
||||
self, diagnosis_id: int, *, readonly: bool = False
|
||||
) -> dict[str, Any]:
|
||||
repository_calls.append(diagnosis_id)
|
||||
return {"id": diagnosis_id, "readonly": readonly}
|
||||
|
||||
denied = PrescriptionsPage(Repository(), PermissionSet([]), SimpleNamespace(id=1))
|
||||
denied._open_diagnosis(1)
|
||||
assert repository_calls == []
|
||||
|
||||
callbacks: list[tuple[Any, dict[str, Any]]] = []
|
||||
|
||||
def queue_async(function: Any, **options: Any) -> object:
|
||||
callbacks.append((function, options))
|
||||
return object()
|
||||
|
||||
shown: list[int] = []
|
||||
|
||||
class FakeDiagnosisDetailDialog:
|
||||
def __init__(self, detail: dict[str, Any], _parent: Any, **_kwargs: Any) -> None:
|
||||
shown.append(detail["id"])
|
||||
|
||||
def exec(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(prescription_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(prescription_module, "DiagnosisDetailDialog", FakeDiagnosisDetailDialog)
|
||||
allowed = PrescriptionsPage(
|
||||
Repository(),
|
||||
PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||
SimpleNamespace(id=1),
|
||||
)
|
||||
allowed._open_diagnosis(11)
|
||||
allowed._open_diagnosis(12)
|
||||
assert len(callbacks) == 2
|
||||
old_function, old_options = callbacks[0]
|
||||
old_options["on_success"](old_function())
|
||||
assert shown == []
|
||||
new_function, new_options = callbacks[1]
|
||||
new_options["on_success"](new_function())
|
||||
assert shown == [12]
|
||||
assert repository_calls == [11, 12]
|
||||
denied.close()
|
||||
allowed.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_prescription_view_fetches_full_detail_and_ignores_stale_row(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
requested: list[int] = []
|
||||
callbacks: list[tuple[Any, dict[str, Any]]] = []
|
||||
shown: list[int] = []
|
||||
|
||||
class Repository:
|
||||
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
|
||||
requested.append(prescription_id)
|
||||
return {
|
||||
"id": prescription_id,
|
||||
"patient_name": f"患者{prescription_id}",
|
||||
"doctor_signature": "data:image/png;base64,detail",
|
||||
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
def queue_async(function: Any, **options: Any) -> object:
|
||||
callbacks.append((function, options))
|
||||
return object()
|
||||
|
||||
class FakePrescriptionDetailDialog:
|
||||
def __init__(self, detail: dict[str, Any], **_kwargs: Any) -> None:
|
||||
assert detail["doctor_signature"].endswith("detail")
|
||||
assert detail["herbs"][0]["name"] == "黄芪"
|
||||
shown.append(detail["id"])
|
||||
|
||||
def exec(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", queue_async)
|
||||
monkeypatch.setattr(dialog_module, "PrescriptionDetailDialog", FakePrescriptionDetailDialog)
|
||||
dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["*"]))
|
||||
dialog._view_prescription({"id": 11, "herbs": []})
|
||||
dialog._view_prescription({"id": 12, "herbs": []})
|
||||
|
||||
old_function, old_options = callbacks[0]
|
||||
old_options["on_success"](old_function())
|
||||
assert shown == []
|
||||
new_function, new_options = callbacks[1]
|
||||
new_options["on_success"](new_function())
|
||||
assert requested == [11, 12]
|
||||
assert shown == [12]
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
||||
"""No-network contracts for the audited five doctor workspaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import (
|
||||
DIAGNOSIS_AI_PERMISSIONS,
|
||||
PRESCRIPTION_LIBRARY_PERMISSIONS,
|
||||
PRESCRIPTION_PERMISSIONS,
|
||||
RemoteDoctorRepository,
|
||||
)
|
||||
|
||||
|
||||
class RecordingClient:
|
||||
"""Small API client double retaining exact endpoint and DTO calls."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = "https://example.test/adminapi/"
|
||||
self.token = "token"
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.post_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def set_token(self, token: str) -> None:
|
||||
"""Set the current synthetic token."""
|
||||
|
||||
self.token = token
|
||||
|
||||
def clear_token(self) -> None:
|
||||
"""Clear the current synthetic token."""
|
||||
|
||||
self.token = ""
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Record a GET and return a shape appropriate for its contract."""
|
||||
|
||||
self.get_calls.append((endpoint, dict(params or {})))
|
||||
if endpoint == "auth.admin/mySelf":
|
||||
return {
|
||||
"user": {"id": 1, "name": "Doctor", "role_ids": [1]},
|
||||
"permissions": ["tcm.diagnosis/lists"],
|
||||
"menu": [
|
||||
{
|
||||
"name": "问诊列表",
|
||||
"component": "tcm/diagnosis/index",
|
||||
"future": {"badge": 3},
|
||||
"children": [{"name": "只读", "perms": "tcm.diagnosis/readonlyDetail"}],
|
||||
"unsafe": lambda: None,
|
||||
7: "non-string key",
|
||||
}
|
||||
],
|
||||
}
|
||||
if endpoint in {
|
||||
"tcm.diagnosis/detail",
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
"firstvisit.myPatient/orderDetail",
|
||||
"tcm.prescriptionOrder/detail",
|
||||
}:
|
||||
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试患者"}
|
||||
if endpoint == "tcm.prescription/getByAppointment":
|
||||
return {}
|
||||
if endpoint == "tcm.prescriptionLibrary/aiReports":
|
||||
return {
|
||||
"prescription_id": int((params or {}).get("id", 0)),
|
||||
"prescription_name": "疏肝健脾基础方",
|
||||
"formula_type": "主方",
|
||||
"reports": [],
|
||||
"missing_model_keys": ["qwen", "openai"],
|
||||
"can_view": True,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiReports":
|
||||
return {
|
||||
"diagnosis_id": int((params or {}).get("id", 0)),
|
||||
"patient_name": "林晓岚",
|
||||
"case_summary": "临床诊断:肝郁脾虚证",
|
||||
"reports": [],
|
||||
"missing_model_keys": ["qwen", "openai"],
|
||||
"can_view": True,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"capabilities": {"can_view": True, "can_refresh": True, "can_edit": True},
|
||||
}
|
||||
if endpoint == "doctor.appointment/availableSlots":
|
||||
return {"slots": [{"time": "09:00", "available": True}]}
|
||||
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
|
||||
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
|
||||
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
|
||||
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
"""Record a POST and return a stable synthetic mutation result."""
|
||||
|
||||
body = dict(payload or {})
|
||||
self.post_calls.append((endpoint, body))
|
||||
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
|
||||
return {"id": 88}
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
return {"call_record_id": 901}
|
||||
if endpoint == "tcm.prescriptionLibrary/generateAiReports":
|
||||
return {
|
||||
"prescription_id": int(body.get("id", 0)),
|
||||
"reports": [],
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"status": "success",
|
||||
}
|
||||
if endpoint == "tcm.prescriptionLibrary/editAiReport":
|
||||
return {
|
||||
"prescription_id": int(body.get("id", 0)),
|
||||
"report": {
|
||||
"report_id": int(body.get("report_id", 0)),
|
||||
"model_key": "qwen",
|
||||
"content": body.get("content", ""),
|
||||
},
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/generateAiReports":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"reports": [],
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"status": "success",
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/editAiReport":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"report": {
|
||||
"report_id": int(body.get("report_id", 0)),
|
||||
"model_key": "qwen",
|
||||
"content": body.get("content", ""),
|
||||
},
|
||||
"can_edit": True,
|
||||
"can_refresh": True,
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiAssistant":
|
||||
return {
|
||||
"diagnosis_id": int(body.get("id", 0)),
|
||||
"answer": "服务端分析结果",
|
||||
"model_key": "qwen",
|
||||
"task": body.get("task"),
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/aiAnalysis":
|
||||
model = str(body.get("model") or "")
|
||||
if model == "openai":
|
||||
return {
|
||||
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
|
||||
"risk_assessment": [
|
||||
{"label": "用药安全风险", "level": "medium"},
|
||||
{"label": "肾功能风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
|
||||
"model_key": "openai",
|
||||
"model_label": "OpenAI",
|
||||
"model_name": "gpt-5.2",
|
||||
"generated_at": "2026-08-14 10:31:00",
|
||||
}
|
||||
return {
|
||||
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
|
||||
"risk_assessment": [
|
||||
{"label": "高血糖风险", "level": "high"},
|
||||
{"label": "心血管风险", "level": "medium"},
|
||||
],
|
||||
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-14 10:30:00",
|
||||
}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def test_page_result_preserves_outer_and_nested_extend() -> None:
|
||||
"""Server scope metadata must survive nested data pagination envelopes."""
|
||||
|
||||
page = PageResult.from_payload(
|
||||
{
|
||||
"extend": {"scope": {"label": "doctor"}},
|
||||
"data": {
|
||||
"lists": [{"id": 1}],
|
||||
"count": 4,
|
||||
"extend": {"schedule_mode": "roster"},
|
||||
},
|
||||
},
|
||||
Appointment.from_dict,
|
||||
)
|
||||
|
||||
assert page.total == 4
|
||||
assert page.extend == {
|
||||
"scope": {"label": "doctor"},
|
||||
"schedule_mode": "roster",
|
||||
}
|
||||
|
||||
|
||||
def test_consultation_keeps_diagnosis_and_appointment_status_separate() -> None:
|
||||
"""Video eligibility fields cannot be overwritten by diagnosis enablement."""
|
||||
|
||||
row = Consultation.from_dict(
|
||||
{
|
||||
"id": 3,
|
||||
"status": 1,
|
||||
"status_desc": "启用",
|
||||
"has_appointment": 1,
|
||||
"appointment_status": 4,
|
||||
"appointment_status_text": "已过号",
|
||||
"appointments": [{"id": 8, "status": 4}],
|
||||
"DiagnosisViewRecord": [{"is_confirmed": 1}],
|
||||
}
|
||||
)
|
||||
|
||||
assert row.status == 1
|
||||
assert row.appointment_status == 4
|
||||
assert row.has_appointment
|
||||
assert row.confirmed
|
||||
assert row.appointments == [{"id": 8, "status": 4}]
|
||||
|
||||
|
||||
def test_prescription_round_trips_appointment_and_case_record() -> None:
|
||||
"""Appointment authority and immutable case snapshot survive model DTOs."""
|
||||
|
||||
prescription = Prescription.from_dict(
|
||||
{
|
||||
"id": 8,
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"case_record": {
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert prescription.appointment_id == 17
|
||||
assert prescription.case_record["clinical_diagnosis"] == "气阴两虚证"
|
||||
payload = prescription.to_api_dict()
|
||||
assert payload["appointment_id"] == 17
|
||||
assert payload["case_record"] == {
|
||||
"diagnosis_id": 5,
|
||||
"appointment_id": 17,
|
||||
"clinical_diagnosis": "气阴两虚证",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_reception_is_forcibly_scoped_to_today() -> None:
|
||||
"""Status 1/4 queue reads always carry the audited same-day date range."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.list_appointments(status=1, keyword="林", page_no=2, page_size=15)
|
||||
|
||||
endpoint, params = client.get_calls[-1]
|
||||
assert endpoint == "doctor.appointment/lists"
|
||||
assert params == {
|
||||
"status": 1,
|
||||
"patient_name": "林",
|
||||
"start_date": date.today().isoformat(),
|
||||
"end_date": date.today().isoformat(),
|
||||
"page_no": 2,
|
||||
"page_size": 15,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.create_prescription(
|
||||
patient_name="测试患者",
|
||||
diagnosis_id=5,
|
||||
herbs=[{"medicine_id": 1, "name": "黄芪", "dosage": 10}],
|
||||
)
|
||||
repository.patch_prescription_patient(8, patient_name="修正患者", phone="13800000000", gender=2)
|
||||
repository.audit_prescription(8, action="reject", remark="剂量不符")
|
||||
repository.create_prescription_order(
|
||||
prescription_id=8,
|
||||
diagnosis_id=5,
|
||||
recipient_name="修正患者",
|
||||
recipient_phone="13800000000",
|
||||
)
|
||||
repository.list_paid_prescription_orders(5, prescription_order_id=3)
|
||||
repository.list_medicines(name="黄芪")
|
||||
repository.patient_orders(page_no=1, page_size=15, fulfillment_status=2)
|
||||
repository.patient_progress(page_no=1, page_size=15, status=1)
|
||||
repository.patient_detail(5)
|
||||
repository.appointment_history(5)
|
||||
repository.assign_history(5)
|
||||
repository.assign_patient(5, 20, is_inherit=1)
|
||||
repository.fill_patient_id_card(5, "410000199001010000")
|
||||
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
|
||||
repository.cancel_patient_appointment(7)
|
||||
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
|
||||
repository.list_appointment_rosters(
|
||||
doctor_id=1,
|
||||
start_date="2026-08-10",
|
||||
end_date="2026-08-16",
|
||||
)
|
||||
slots = repository.get_available_appointment_slots(
|
||||
doctor_id=1,
|
||||
appointment_date="2026-08-10",
|
||||
)
|
||||
|
||||
assert (
|
||||
"tcm.prescription/patchPatient",
|
||||
{
|
||||
"id": 8,
|
||||
"patient_name": "修正患者",
|
||||
"phone": "13800000000",
|
||||
"gender": 2,
|
||||
},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.prescription/audit",
|
||||
{
|
||||
"id": 8,
|
||||
"action": "reject",
|
||||
"remark": "剂量不符",
|
||||
},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"firstvisit.myPatient/assign",
|
||||
{
|
||||
"id": 5,
|
||||
"assistant_id": 20,
|
||||
"is_inherit": 1,
|
||||
},
|
||||
) in client.post_calls
|
||||
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
|
||||
assert slots == {"slots": [{"time": "09:00", "available": True}]}
|
||||
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
|
||||
assert {
|
||||
"tcm.prescriptionOrder/paidPayOrders",
|
||||
"doctor.medicine/lists",
|
||||
"firstvisit.myPatient/orders",
|
||||
"firstvisit.myPatient/progress",
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
"doctor.appointment/lists",
|
||||
"tcm.diagnosis/assignLogList",
|
||||
"doctor.roster/lists",
|
||||
"doctor.appointment/availableSlots",
|
||||
} <= get_endpoints
|
||||
|
||||
|
||||
def test_remote_transcription_endpoints_use_exact_normalized_dtos() -> None:
|
||||
"""Realtime transcript persistence stays within the three audited POST DTOs."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.start_call_transcription(501, 901, " session-1 ", language=" zh-CN ")
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": "1200",
|
||||
"text": " patient words ",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.finish_call_transcription(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
expected_segment_count=1,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"language": "zh-CN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/upsertCallTranscriptSegments",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"segments": [
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/finishCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"expected_segment_count": 1,
|
||||
"status": "completed",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
assert repository.start_call(501, 301) == {"call_record_id": 901}
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCall",
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
|
||||
)
|
||||
def test_remote_start_call_rejects_missing_or_invalid_record_id(response: Any) -> None:
|
||||
class StartCallClient(RecordingClient):
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
self.post_calls.append((endpoint, dict(payload or {})))
|
||||
return response
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
repository = RemoteDoctorRepository(StartCallClient()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ApiProtocolError, match="call_record"):
|
||||
repository.start_call(501, 301)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_reference",
|
||||
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
|
||||
)
|
||||
def test_remote_note_rejects_local_material_references(unsafe_reference: str) -> None:
|
||||
"""No drive, UNC or file URI can reach addDoctorNote JSON."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match="server uri/url"):
|
||||
repository.add_doctor_note(5, tongue_images=[unsafe_reference])
|
||||
assert not any(
|
||||
endpoint == "doctor.appointment/addDoctorNote" for endpoint, _payload in client.post_calls
|
||||
)
|
||||
|
||||
|
||||
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
|
||||
"""Future menu fields pass through safely without evaluating arbitrary values."""
|
||||
|
||||
repository = RemoteDoctorRepository(RecordingClient()) # type: ignore[arg-type]
|
||||
|
||||
menu = repository.get_session().menu
|
||||
|
||||
assert menu[0]["component"] == "tcm/diagnosis/index"
|
||||
assert menu[0]["future"] == {"badge": 3}
|
||||
assert menu[0]["children"][0]["perms"] == "tcm.diagnosis/readonlyDetail"
|
||||
assert "unsafe" not in menu[0]
|
||||
assert 7 not in menu[0]
|
||||
|
||||
|
||||
def test_canonical_prescription_permissions_match_routed_views() -> None:
|
||||
"""Service exports one canonical spelling for each routed action."""
|
||||
|
||||
assert PRESCRIPTION_LIBRARY_PERMISSIONS == {
|
||||
"create": "wcf.prescription/add",
|
||||
"read": "wcf.prescription/read",
|
||||
"update": "wcf.prescription/edit",
|
||||
"delete": "wcf.prescription/delete",
|
||||
"ai_reports": "tcm.prescriptionLibrary/aiReports",
|
||||
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
|
||||
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
|
||||
}
|
||||
assert PRESCRIPTION_PERMISSIONS["delete"] == "cf.prescription/del"
|
||||
assert PRESCRIPTION_PERMISSIONS["patch_patient"] == "tcm.prescription/patchPatient"
|
||||
assert DIAGNOSIS_AI_PERMISSIONS == {
|
||||
"ai_reports": "tcm.diagnosis/aiReports",
|
||||
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
|
||||
"edit_ai_report": "tcm.diagnosis/editAiReport",
|
||||
"analysis": "tcm.diagnosis/aiAnalysis",
|
||||
"assistant": "tcm.diagnosis/aiAssistant",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_prescription_library_ai_report_endpoints() -> None:
|
||||
"""AI interpretation uses the same adminapi contract as the Vue library page."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
listed = repository.list_prescription_template_ai_reports(701)
|
||||
generated = repository.generate_prescription_template_ai_reports(701)
|
||||
edited = repository.edit_prescription_template_ai_report(
|
||||
701, report_id=9, content='{"summary":"演示"}'
|
||||
)
|
||||
|
||||
assert listed["prescription_id"] == 701
|
||||
assert generated["status"] == "success"
|
||||
assert edited["report"]["report_id"] == 9
|
||||
assert client.get_calls[-1] == (
|
||||
"tcm.prescriptionLibrary/aiReports",
|
||||
{"id": 701},
|
||||
)
|
||||
assert (
|
||||
"tcm.prescriptionLibrary/generateAiReports",
|
||||
{"id": 701},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.prescriptionLibrary/editAiReport",
|
||||
{"id": 701, "report_id": 9, "content": '{"summary":"演示"}'},
|
||||
) in client.post_calls
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_report_endpoints() -> None:
|
||||
"""Patient-profile AI reports use the diagnosis adminapi contract."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
listed = repository.list_diagnosis_ai_reports(501)
|
||||
generated = repository.generate_diagnosis_ai_reports(501)
|
||||
edited = repository.edit_diagnosis_ai_report(
|
||||
501, report_id=9, content='{"summary":"演示"}'
|
||||
)
|
||||
|
||||
assert listed["diagnosis_id"] == 501
|
||||
assert generated["status"] == "success"
|
||||
assert edited["report"]["report_id"] == 9
|
||||
assert client.get_calls[-1] == (
|
||||
"tcm.diagnosis/aiReports",
|
||||
{"id": 501},
|
||||
)
|
||||
assert (
|
||||
"tcm.diagnosis/generateAiReports",
|
||||
{"id": 501},
|
||||
) in client.post_calls
|
||||
assert (
|
||||
"tcm.diagnosis/editAiReport",
|
||||
{"id": 501, "report_id": 9, "content": '{"summary":"演示"}'},
|
||||
) in client.post_calls
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
|
||||
class TimeoutRecordingClient(RecordingClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.timeouts.append(timeout)
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
client = TimeoutRecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
|
||||
result = repository.analyze_diagnosis_ai(
|
||||
501,
|
||||
"请给出用药调整建议",
|
||||
task="medication_review",
|
||||
)
|
||||
|
||||
assert result["answer"] == "服务端分析结果"
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/aiAssistant",
|
||||
{"id": 501, "prompt": "请给出用药调整建议", "task": "medication_review"},
|
||||
)
|
||||
]
|
||||
body = client.post_calls[0][1]
|
||||
assert not ({"key", "api_key", "base_url", "provider", "model"} & body.keys())
|
||||
assert client.timeouts == [105.0]
|
||||
|
||||
|
||||
def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None:
|
||||
"""The legacy default is qwen, followed by an explicit OpenAI request."""
|
||||
|
||||
class TimeoutRecordingClient(RecordingClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.timeouts: list[float | None] = []
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
self.timeouts.append(timeout)
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
client = TimeoutRecordingClient()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
|
||||
qwen_result = repository.get_diagnosis_ai_analysis(501)
|
||||
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
|
||||
|
||||
assert client.post_calls == [
|
||||
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "qwen"}),
|
||||
("tcm.diagnosis/aiAnalysis", {"id": 501, "model": "openai"}),
|
||||
]
|
||||
assert client.timeouts == [105.0, 105.0]
|
||||
assert qwen_result == {
|
||||
"diagnosis_advice": "2 型糖尿病,血糖控制不佳",
|
||||
"risk_assessment": [
|
||||
{"label": "高血糖风险", "level": "high"},
|
||||
{"label": "心血管风险", "level": "medium"},
|
||||
],
|
||||
"treatment_advice": "复核用药依从性并安排糖化血红蛋白检查。",
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-14 10:30:00",
|
||||
}
|
||||
assert openai_result == {
|
||||
"diagnosis_advice": "2 型糖尿病,需结合客观检查复核",
|
||||
"risk_assessment": [
|
||||
{"label": "用药安全风险", "level": "medium"},
|
||||
{"label": "肾功能风险", "level": "low"},
|
||||
],
|
||||
"treatment_advice": "复核近期检查趋势并评估联合用药安全性。",
|
||||
"model_key": "openai",
|
||||
"model_label": "OpenAI",
|
||||
"model_name": "gpt-5.2",
|
||||
"generated_at": "2026-08-14 10:31:00",
|
||||
}
|
||||
|
||||
calls_before_validation = list(client.post_calls)
|
||||
timeouts_before_validation = list(client.timeouts)
|
||||
with pytest.raises(ValueError, match="qwen or openai"):
|
||||
repository.get_diagnosis_ai_analysis(501, model="invalid") # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
repository.get_diagnosis_ai_analysis(0)
|
||||
assert client.post_calls == calls_before_validation
|
||||
assert client.timeouts == timeouts_before_validation
|
||||
|
||||
|
||||
def test_demo_diagnosis_ai_analysis_matches_structured_contract() -> None:
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 14))
|
||||
|
||||
qwen_result = repository.get_diagnosis_ai_analysis(501)
|
||||
openai_result = repository.get_diagnosis_ai_analysis(501, model="openai")
|
||||
|
||||
expected_keys = {
|
||||
"diagnosis_advice",
|
||||
"risk_assessment",
|
||||
"treatment_advice",
|
||||
"model_key",
|
||||
"model_label",
|
||||
"model_name",
|
||||
"generated_at",
|
||||
}
|
||||
for result in (qwen_result, openai_result):
|
||||
assert set(result) == expected_keys
|
||||
assert "肝郁脾虚证" in result["diagnosis_advice"]
|
||||
assert result["treatment_advice"]
|
||||
assert result["risk_assessment"]
|
||||
assert all(
|
||||
set(item) == {"label", "level"}
|
||||
and item["level"] in {"high", "medium", "low"}
|
||||
for item in result["risk_assessment"]
|
||||
)
|
||||
assert (qwen_result["model_key"], qwen_result["model_label"]) == ("qwen", "千问")
|
||||
assert (openai_result["model_key"], openai_result["model_label"]) == (
|
||||
"openai",
|
||||
"OpenAI",
|
||||
)
|
||||
assert qwen_result["model_name"] != openai_result["model_name"]
|
||||
assert qwen_result["diagnosis_advice"] != openai_result["diagnosis_advice"]
|
||||
assert qwen_result["treatment_advice"] != openai_result["treatment_advice"]
|
||||
|
||||
|
||||
def test_demo_mutates_prescriptions_orders_and_patient_workspaces() -> None:
|
||||
"""Offline mode supports the full workflow rather than static placeholders."""
|
||||
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
created = repository.create_prescription(
|
||||
diagnosis_id=501,
|
||||
patient_name="林晓岚",
|
||||
phone="13800131203",
|
||||
gender=2,
|
||||
herbs=[{"medicine_id": 17, "name": "黄芪", "dosage": 20}],
|
||||
doctor_name="陈医生(演示)",
|
||||
doctor_signature="data:image/png;base64,demo",
|
||||
)
|
||||
updated = repository.update_prescription(created.id, {"clinical_diagnosis": "气虚证"})
|
||||
repository.patch_prescription_patient(
|
||||
created.id, patient_name="林晓岚(修正)", phone="13800131203", gender=2
|
||||
)
|
||||
audit = repository.audit_prescription(created.id, action="approve")
|
||||
order = repository.create_prescription_order(
|
||||
prescription_id=created.id,
|
||||
diagnosis_id=501,
|
||||
recipient_name="林晓岚(修正)",
|
||||
recipient_phone="13800131203",
|
||||
amount=268,
|
||||
)
|
||||
|
||||
assert updated.clinical_diagnosis == "气虚证"
|
||||
assert audit["audit_status"] == 1
|
||||
assert repository.get_prescription(created.id).has_prescription_order
|
||||
assert repository.get_prescription_order(order["id"])["prescription_id"] == created.id
|
||||
assert repository.patient_orders().extend["summary"]["order_count"] == 2
|
||||
|
||||
assigned = repository.assign_patient(501, 2002)
|
||||
repository.fill_patient_id_card(501, "410000199001010000")
|
||||
appointment = repository.book_patient_appointment(
|
||||
diagnosis_id=501,
|
||||
appointment_date="2026-08-11",
|
||||
appointment_time="15:00-15:30",
|
||||
)
|
||||
repository.update_diagnosis(501, {"chief_complaint": "乏力"})
|
||||
|
||||
assert assigned["assistant_name"] == "许医助"
|
||||
assert repository.assign_history(501).total == 2
|
||||
assert repository.patient_detail(501)["diagnosis"]["chief_complaint"] == "乏力"
|
||||
assert repository.appointment_history(501).total == 2
|
||||
repository.cancel_patient_appointment(appointment["id"])
|
||||
assert repository.appointment_history(501).items[-1].status == 2
|
||||
assert repository.list_medicines(name="黄芪").items[0]["name"] == "黄芪"
|
||||
|
||||
|
||||
def test_reject_actions_require_a_remark() -> None:
|
||||
"""Audit rejection mirrors the admin dialog's mandatory reason boundary."""
|
||||
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 10))
|
||||
|
||||
with pytest.raises(ValueError, match="remark"):
|
||||
repository.audit_prescription(802, action="reject")
|
||||
with pytest.raises(ValueError, match="remark"):
|
||||
repository.audit_patient_order_payment(901, "reject")
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
|
||||
|
||||
class _ShellPageDouble(QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
_repository: Any,
|
||||
*,
|
||||
permissions: Any,
|
||||
current_user: Any,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.permissions = permissions
|
||||
self.current_user = current_user
|
||||
self.refresh_count = 0
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refresh_count += 1
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_patients_navigation_keeps_the_product_menu_title() -> None:
|
||||
resolved = shell_module._resolve_navigation(
|
||||
[
|
||||
{
|
||||
"name": "我的患者",
|
||||
"component": "first_visit/my_patients",
|
||||
"perms": "firstvisit.myPatient/lists",
|
||||
}
|
||||
],
|
||||
{"firstvisit.myPatient/lists"},
|
||||
demo_mode=False,
|
||||
)
|
||||
|
||||
assert [(item.key, title) for item, title in resolved] == [("patients", "我的患者")]
|
||||
|
||||
|
||||
def test_appointments_navigation_is_named_reception_and_always_first() -> None:
|
||||
resolved = shell_module._resolve_navigation(
|
||||
[
|
||||
{
|
||||
"name": "我的患者",
|
||||
"component": "first_visit/my_patients",
|
||||
"perms": "firstvisit.myPatient/lists",
|
||||
"sort": 99,
|
||||
},
|
||||
{
|
||||
"name": "挂号列表",
|
||||
"component": "tcm/appointment/list",
|
||||
"perms": "doctor.appointment/lists",
|
||||
"sort": 1,
|
||||
},
|
||||
],
|
||||
{"doctor.appointment/lists", "firstvisit.myPatient/lists"},
|
||||
demo_mode=False,
|
||||
)
|
||||
|
||||
assert [(item.key, title) for item, title in resolved] == [
|
||||
("appointments", "问诊列表"),
|
||||
("patients", "我的患者"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shell_window(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> ShellWindow:
|
||||
navigation = [
|
||||
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
|
||||
for key, title, glyph, permission in (
|
||||
("appointments", "问诊列表", "号", "doctor.appointment/lists"),
|
||||
("reception", "接诊台", "◎", "doctor.appointment/lists"),
|
||||
(
|
||||
"prescription_library",
|
||||
"我的处方库",
|
||||
"方",
|
||||
"tcm.prescriptionLibrary/lists",
|
||||
),
|
||||
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
|
||||
("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
|
||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"_resolve_navigation",
|
||||
lambda _menu, _permissions, *, demo_mode: [
|
||||
(item, item.title) for item in navigation
|
||||
],
|
||||
)
|
||||
window = ShellWindow(
|
||||
object(),
|
||||
{
|
||||
"user": {"name": "陈医生", "department_name": "中医门诊", "role_ids": [1]},
|
||||
"demo_mode": True,
|
||||
},
|
||||
permissions={item.permissions[0] for item in navigation},
|
||||
)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
yield window
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
for width, height in ((1024, 640), (1440, 900)):
|
||||
shell_window.resize(width, height)
|
||||
application.processEvents()
|
||||
|
||||
assert shell_window.sidebar.width() == 179
|
||||
assert shell_window.topbar.height() == 62
|
||||
assert shell_window.tabs_host.height() == 0
|
||||
assert shell_window.workspace.width() == width - 26 - 179
|
||||
assert shell_window.stack.width() == width - 26 - 179
|
||||
assert shell_window.stack.height() == height - 26 - 62
|
||||
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
|
||||
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
|
||||
|
||||
image = shell_window.grab().toImage()
|
||||
assert image.pixelColor(20, 300).name().lower() in {
|
||||
"#f2f5fd",
|
||||
"#f3f6fd",
|
||||
"#f2f6fe",
|
||||
"#f3f6fe",
|
||||
}
|
||||
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
|
||||
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
|
||||
|
||||
|
||||
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
|
||||
for page in shell_window.pages.values():
|
||||
assert page.parentWidget() is shell_window.stack
|
||||
assert not page.isWindow()
|
||||
|
||||
|
||||
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||
assert (
|
||||
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
|
||||
)
|
||||
assert shell_window.assistant_card.isVisible()
|
||||
assert shell_window.assistant_button.text() == "开始对话"
|
||||
assert "GPT-4o 医疗版" in shell_window.model_label.text()
|
||||
assert shell_window.minimize_button.text() == ""
|
||||
assert shell_window.close_button.text() == ""
|
||||
|
||||
shell_window.set_connection_state(False)
|
||||
assert "离线" in shell_window.assistant_status.text()
|
||||
shell_window.set_connection_state(True)
|
||||
assert "在线" in shell_window.assistant_status.text()
|
||||
|
||||
|
||||
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
changed: list[str] = []
|
||||
shell_window.page_changed.connect(changed.append)
|
||||
|
||||
for key in (
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
):
|
||||
assert shell_window.navigate(key)
|
||||
assert shell_window.stack.currentWidget() is shell_window.pages[key]
|
||||
assert shell_window.nav_buttons[key].isChecked()
|
||||
assert shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == key
|
||||
|
||||
assert shell_window.visited_tab_keys() == (
|
||||
"appointments",
|
||||
"reception",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
)
|
||||
assert changed[-6:] == [
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
]
|
||||
|
||||
|
||||
def test_non_fixed_tabs_close_and_active_close_renavigates(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
for key in ("patients", "consultations"):
|
||||
assert shell_window.navigate(key)
|
||||
|
||||
assert not shell_window.close_tab("appointments")
|
||||
assert shell_window.close_tab("patients")
|
||||
assert "patients" not in shell_window.visited_tab_keys()
|
||||
assert (
|
||||
shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex())
|
||||
== "consultations"
|
||||
)
|
||||
|
||||
assert shell_window.close_current_tab()
|
||||
assert (
|
||||
shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "appointments"
|
||||
)
|
||||
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
||||
assert shell_window.nav_buttons["appointments"].isChecked()
|
||||
|
||||
shell_window.close_all_tabs()
|
||||
assert shell_window.visited_tab_keys() == ("appointments",)
|
||||
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
||||
|
||||
|
||||
def test_sidebar_collapse_preserves_active_navigation(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("consultations")
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 68
|
||||
assert shell_window.nav_buttons["consultations"].text() == ""
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 195
|
||||
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
|
||||
|
||||
def test_shell_directional_controls_have_no_unicode_arrow_text(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.fold_button.text() == ""
|
||||
assert shell_window.refresh_button.text() == ""
|
||||
assert shell_window.fullscreen_button.text() == ""
|
||||
assert shell_window.tabs_menu_button.text() == ""
|
||||
assert all(
|
||||
arrow not in button.text()
|
||||
for button in shell_window.findChildren(QWidget)
|
||||
if hasattr(button, "text") and callable(button.text)
|
||||
for arrow in ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾")
|
||||
)
|
||||
@@ -0,0 +1,507 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QSettings
|
||||
from PySide6.QtGui import QPageSize, QRawFont
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
|
||||
|
||||
from doctor_workstation import app as app_module
|
||||
from doctor_workstation.app import ApplicationController
|
||||
from doctor_workstation.config import AppConfig
|
||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import login as login_module
|
||||
from doctor_workstation.ui import widgets as widget_module
|
||||
from doctor_workstation.ui.login import LoginWindow
|
||||
from doctor_workstation.ui.pages.consultations import _video_payload
|
||||
from doctor_workstation.ui.shell import NAVIGATION
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
from doctor_workstation.ui.widgets import (
|
||||
friendly_error,
|
||||
gender_text,
|
||||
invoke,
|
||||
set_authentication_expired_handler,
|
||||
)
|
||||
|
||||
|
||||
def test_consultation_video_payload_keeps_appointment_and_diagnosis_ids_distinct() -> None:
|
||||
record = SimpleNamespace(
|
||||
id=501,
|
||||
appointment_id=101,
|
||||
patient_id=301,
|
||||
patient_name="林晓岚",
|
||||
)
|
||||
|
||||
payload = _video_payload(record)
|
||||
|
||||
assert payload["appointment_id"] == 101
|
||||
assert payload["diagnosis_id"] == 501
|
||||
assert payload["patient_id"] == 301
|
||||
|
||||
|
||||
def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
|
||||
assert gender_text(1) == "男"
|
||||
assert gender_text("2") == "女"
|
||||
assert gender_text(0) == "未知"
|
||||
assert gender_text("未知标签") == "未知标签"
|
||||
|
||||
|
||||
def test_theme_resolves_real_chinese_glyphs() -> None:
|
||||
"""Guard packaged/offscreen builds against rendering every CJK glyph as tofu."""
|
||||
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
raw_font = QRawFont.fromFont(application.font())
|
||||
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
|
||||
|
||||
assert glyphs
|
||||
assert all(glyph > 0 for glyph in glyphs)
|
||||
assert len(set(glyphs)) > 1
|
||||
|
||||
|
||||
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
dialog = QDialog()
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel,
|
||||
parent=dialog,
|
||||
)
|
||||
QVBoxLayout(dialog).addWidget(buttons)
|
||||
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
assert dialog.property("businessDialog") is True
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Save).property("variant") == "primary"
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Cancel).property("variant") == "secondary"
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_navigation_requires_each_pages_actual_list_capability() -> None:
|
||||
assert {item.key: item.permissions for item in NAVIGATION} == {
|
||||
"reception": ("doctor.appointment/lists",),
|
||||
"appointments": ("doctor.appointment/lists",),
|
||||
"prescription_library": ("tcm.prescriptionLibrary/lists",),
|
||||
"prescriptions": ("tcm.prescription/lists",),
|
||||
"patients": ("firstvisit.myPatient/lists",),
|
||||
"consultations": ("tcm.diagnosis/lists",),
|
||||
}
|
||||
|
||||
|
||||
def test_video_release_does_not_remove_a_newer_call() -> None:
|
||||
older = object()
|
||||
newer = object()
|
||||
controller = SimpleNamespace(video_calls={"501": newer})
|
||||
|
||||
ApplicationController._release_video_call(controller, "501", older)
|
||||
assert controller.video_calls == {"501": newer}
|
||||
|
||||
ApplicationController._release_video_call(controller, "501", newer)
|
||||
assert controller.video_calls == {}
|
||||
|
||||
|
||||
def test_invoke_leaves_prescription_filters_for_repository_mapping() -> None:
|
||||
"""The UI adapter only normalises pagination, not API-specific DTO fields."""
|
||||
|
||||
class Repository:
|
||||
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
|
||||
return filters
|
||||
|
||||
assert invoke(
|
||||
Repository(),
|
||||
"prescriptions",
|
||||
keyword="CF-2026-8",
|
||||
status=2,
|
||||
page=3,
|
||||
page_size=15,
|
||||
) == {
|
||||
"keyword": "CF-2026-8",
|
||||
"status": 2,
|
||||
"page_no": 3,
|
||||
"page_size": 15,
|
||||
}
|
||||
|
||||
|
||||
def test_async_error_dispatch_consumes_active_session_expiry_globally() -> None:
|
||||
"""A consumed authentication expiry does not also reach a stale page."""
|
||||
|
||||
global_errors: list[Exception] = []
|
||||
local_errors: list[Exception] = []
|
||||
error = AuthenticationExpiredError("expired", code=-1)
|
||||
try:
|
||||
set_authentication_expired_handler(lambda caught: global_errors.append(caught) is None)
|
||||
widget_module._dispatch_async_error(error, local_errors.append)
|
||||
finally:
|
||||
set_authentication_expired_handler(None)
|
||||
|
||||
assert global_errors == [error]
|
||||
assert local_errors == []
|
||||
|
||||
|
||||
def test_controller_session_expiry_returns_to_login_once() -> None:
|
||||
"""The composition root owns the single transition out of an active shell."""
|
||||
|
||||
messages: list[str] = []
|
||||
controller = SimpleNamespace(
|
||||
shell_window=object(),
|
||||
current_repository=object(),
|
||||
_authentication_expiry_in_progress=False,
|
||||
_logout=lambda *, message="": messages.append(message),
|
||||
)
|
||||
error = AuthenticationExpiredError("expired", code=-1)
|
||||
|
||||
assert ApplicationController._on_authentication_expired(controller, error)
|
||||
assert ApplicationController._on_authentication_expired(controller, error)
|
||||
assert messages == ["登录状态已失效,请重新登录。"]
|
||||
|
||||
|
||||
def test_persisted_session_restore_blocks_manual_submit_before_worker_start(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
"""Login controls are locked before the asynchronous restore is dispatched."""
|
||||
|
||||
pending_states: list[bool] = []
|
||||
worker_calls: list[tuple[Any, dict[str, Any]]] = []
|
||||
worker = object()
|
||||
repository = SimpleNamespace(restore_session=lambda: None)
|
||||
login_window = SimpleNamespace(set_session_restore_pending=pending_states.append)
|
||||
controller = SimpleNamespace(
|
||||
remote_repository=repository,
|
||||
_shutting_down=False,
|
||||
config=SimpleNamespace(demo_mode=False),
|
||||
current_repository=None,
|
||||
_restore_generation=0,
|
||||
_restore_in_progress=False,
|
||||
_restore_worker=None,
|
||||
login_window=login_window,
|
||||
_on_restore_success=lambda *args: None,
|
||||
_on_restore_error=lambda *args: None,
|
||||
_on_restore_finished=lambda *args: None,
|
||||
)
|
||||
|
||||
def fake_run_async(function: Any, **callbacks: Any) -> object:
|
||||
assert pending_states == [True]
|
||||
worker_calls.append((function, callbacks))
|
||||
return worker
|
||||
|
||||
monkeypatch.setattr(app_module, "run_async", fake_run_async)
|
||||
ApplicationController._begin_session_restore(controller)
|
||||
|
||||
assert controller._restore_in_progress
|
||||
assert controller._restore_worker is worker
|
||||
assert worker_calls[0][0] == repository.restore_session
|
||||
|
||||
|
||||
def test_login_restore_pending_uses_existing_submit_guard() -> None:
|
||||
"""A manual submit is a no-op for the whole persisted-token validation window."""
|
||||
|
||||
class Banner:
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
class LoginDouble:
|
||||
def __init__(self) -> None:
|
||||
self._loading = False
|
||||
self.error_banner = Banner()
|
||||
self.loading_options: dict[str, Any] = {}
|
||||
|
||||
def _set_loading(self, loading: bool, **options: Any) -> None:
|
||||
self._loading = loading
|
||||
self.loading_options = options
|
||||
|
||||
login = LoginDouble()
|
||||
LoginWindow.set_session_restore_pending(login, True) # type: ignore[arg-type]
|
||||
LoginWindow.submit(login) # type: ignore[arg-type]
|
||||
|
||||
assert login._loading
|
||||
assert login.loading_options["button_text"] == "正在恢复登录…"
|
||||
|
||||
|
||||
def test_real_demo_login_reaches_success_without_widget_adapter(
|
||||
monkeypatch: Any,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
"""Exercise the actual login widgets and demo repository as one contract."""
|
||||
|
||||
application = QApplication.instance() or QApplication([])
|
||||
repository = DemoDoctorRepository()
|
||||
settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="https://127.0.0.1:9",
|
||||
request_timeout=30,
|
||||
demo_mode=True,
|
||||
remembered_account="",
|
||||
)
|
||||
payloads: list[dict[str, Any]] = []
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
callbacks["on_success"](function())
|
||||
except Exception as error: # pragma: no cover - assertion output is more useful
|
||||
callbacks["on_error"](error)
|
||||
finally:
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(login_module, "run_async", run_immediately)
|
||||
window = LoginWindow(
|
||||
object(),
|
||||
config=config,
|
||||
demo_repository=repository,
|
||||
settings=settings,
|
||||
)
|
||||
window.login_succeeded.connect(payloads.append)
|
||||
|
||||
window.submit()
|
||||
|
||||
assert len(payloads) == 1
|
||||
assert payloads[0]["repository"] is repository
|
||||
assert payloads[0]["demo_mode"] is True
|
||||
assert window.busy_overlay.label.text() == "正在验证账号…"
|
||||
assert not window._loading
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings_path = tmp_path / "remember-password.ini"
|
||||
secrets: dict[tuple[str, str], str] = {}
|
||||
|
||||
def load_password(*, account: str, scope: str) -> str | None:
|
||||
return secrets.get((account, scope.rstrip("/")))
|
||||
|
||||
def save_password(password: str, *, account: str, scope: str) -> bool:
|
||||
secrets[(account, scope.rstrip("/"))] = password
|
||||
return True
|
||||
|
||||
def clear_password(*, account: str, scope: str) -> None:
|
||||
secrets.pop((account, scope.rstrip("/")), None)
|
||||
|
||||
credentials = SimpleNamespace(
|
||||
load_password=load_password,
|
||||
save_password=save_password,
|
||||
clear_password=clear_password,
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="https://example.test/adminapi",
|
||||
request_timeout=30,
|
||||
verify_ssl=True,
|
||||
demo_mode=False,
|
||||
remembered_account="",
|
||||
)
|
||||
first = LoginWindow(
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
first._on_login_success({}, "admin", True, "secret-value")
|
||||
first.close()
|
||||
application.processEvents()
|
||||
|
||||
restored = LoginWindow(
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
assert restored.account_edit.text() == "admin"
|
||||
assert restored.password_edit.text() == "secret-value"
|
||||
assert restored.remember_check.text() == "记住密码"
|
||||
assert restored.remember_check.isChecked()
|
||||
|
||||
restored._on_login_success({}, "admin", False, "secret-value")
|
||||
restored.close()
|
||||
application.processEvents()
|
||||
|
||||
forgotten = LoginWindow(
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
assert forgotten.password_edit.text() == ""
|
||||
assert not forgotten.remember_check.isChecked()
|
||||
forgotten.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "server-panel.ini"), QSettings.Format.IniFormat)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="",
|
||||
request_timeout=30,
|
||||
demo_mode=False,
|
||||
remembered_account="",
|
||||
)
|
||||
window = LoginWindow(object(), config=config, settings=settings)
|
||||
window.resize(860, 590)
|
||||
window.show()
|
||||
window.server_toggle.setChecked(True)
|
||||
window._toggle_server_panel(True)
|
||||
application.processEvents()
|
||||
|
||||
assert window.server_panel.isVisible()
|
||||
assert window.server_panel.height() >= window.server_panel.minimumSizeHint().height()
|
||||
assert window.server_url_label.geometry().bottom() < window.server_url_edit.geometry().top()
|
||||
assert window.server_url_edit.geometry().bottom() < window.timeout_label.geometry().top()
|
||||
assert window.timeout_spin.geometry().right() < window.save_server_button.geometry().left()
|
||||
assert (
|
||||
window.timeout_label.geometry().bottom() < window.allow_self_signed_check.geometry().top()
|
||||
)
|
||||
assert window.allow_self_signed_check.geometry().bottom() < window.server_hint.geometry().top()
|
||||
|
||||
window.allow_self_signed_check.setChecked(True)
|
||||
application.processEvents()
|
||||
assert window.ssl_warning.isVisible()
|
||||
assert window.allow_self_signed_check.geometry().bottom() < window.ssl_warning.geometry().top()
|
||||
assert window.ssl_warning.geometry().bottom() < window.server_hint.geometry().top()
|
||||
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "self-signed.ini"), QSettings.Format.IniFormat)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="https://internal.example.test",
|
||||
request_timeout=30,
|
||||
verify_ssl=True,
|
||||
demo_mode=False,
|
||||
remembered_account="",
|
||||
)
|
||||
window = LoginWindow(object(), config=config, settings=settings)
|
||||
emitted: list[dict[str, Any]] = []
|
||||
window.server_settings_changed.connect(emitted.append)
|
||||
window.allow_self_signed_check.setChecked(True)
|
||||
|
||||
window._save_server_settings()
|
||||
|
||||
assert emitted[-1]["verify_ssl"] is False
|
||||
assert settings.value("server/verify_ssl", type=bool) is False
|
||||
assert "证书校验已关闭" in window.error_banner.label.text()
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_login_applies_self_signed_setting_before_authentication(
|
||||
monkeypatch: Any,
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "self-signed-login.ini"), QSettings.Format.IniFormat)
|
||||
config = AppConfig(
|
||||
api_base_url="https://internal.example.test/adminapi",
|
||||
demo_mode=False,
|
||||
verify_ssl=True,
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
class Repository:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def login(self, **_payload: Any) -> object:
|
||||
calls.append(self.name)
|
||||
return object()
|
||||
|
||||
def get_current_user(self) -> object:
|
||||
return object()
|
||||
|
||||
old_repository = Repository("old")
|
||||
rebuilt_repository = Repository("rebuilt-without-verification")
|
||||
window = LoginWindow(old_repository, config=config, settings=settings)
|
||||
|
||||
def rebuild_on_config_change(updated: AppConfig) -> None:
|
||||
assert updated.verify_ssl is False
|
||||
window.repository = rebuilt_repository
|
||||
window.active_repository = rebuilt_repository
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
callbacks["on_success"](function())
|
||||
except Exception as error: # pragma: no cover - assertion output is more useful
|
||||
callbacks["on_error"](error)
|
||||
finally:
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(login_module, "run_async", run_immediately)
|
||||
window.config_changed.connect(rebuild_on_config_change)
|
||||
window.account_edit.setText("admin")
|
||||
window.password_edit.setText("secret")
|
||||
window.allow_self_signed_check.setChecked(True)
|
||||
|
||||
window.submit()
|
||||
|
||||
assert calls == ["rebuilt-without-verification"]
|
||||
assert settings.value("server/verify_ssl", type=bool) is False
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_certificate_error_explains_self_signed_server_setting() -> None:
|
||||
message = friendly_error(
|
||||
RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")
|
||||
)
|
||||
assert "信任自签名证书" in message
|
||||
assert "服务器设置" in message
|
||||
|
||||
|
||||
def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
app_module._install_chinese_translations(application)
|
||||
|
||||
question = QMessageBox()
|
||||
question.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
|
||||
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
|
||||
assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消"
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Save
|
||||
| QDialogButtonBox.StandardButton.Close
|
||||
)
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定"
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭"
|
||||
assert QCoreApplication.translate("QPageSize", "A4") == "A4"
|
||||
assert QPageSize(QPageSize.PageSizeId.A4).isValid()
|
||||
|
||||
|
||||
def test_friendly_error_hides_english_technical_messages() -> None:
|
||||
message = friendly_error(TypeError("invoke() takes 2 positional arguments but 3 were given"))
|
||||
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
||||
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
|
||||
"服务器返回的数据格式不正确,请联系管理员检查接口。"
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="https://internal.example.test",
|
||||
request_timeout=30,
|
||||
verify_ssl=True,
|
||||
demo_mode=False,
|
||||
remembered_account="",
|
||||
)
|
||||
window = LoginWindow(object(), config=config, settings=settings)
|
||||
|
||||
window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate"))
|
||||
|
||||
assert window.server_toggle.isChecked()
|
||||
assert not window.server_panel.isHidden()
|
||||
assert "信任自签名证书" in window.error_banner.label.text()
|
||||
window.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,587 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
if str(SOURCE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from doctor_workstation.video.launcher import ( # noqa: E402
|
||||
BackendMode,
|
||||
VideoCallLauncher,
|
||||
VideoCallRequest,
|
||||
VideoTicketError,
|
||||
normalize_backend_ticket,
|
||||
)
|
||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # noqa: E402
|
||||
from doctor_workstation.video.security import ( # noqa: E402
|
||||
TrustedDocumentError,
|
||||
TrustedDocumentPolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
||||
request = normalize_backend_ticket(
|
||||
{
|
||||
"sdkAppId": "1400123456",
|
||||
"userId": " doctor_42 ",
|
||||
"userSig": "short-lived-ticket",
|
||||
"patientUserId": " patient_8 ",
|
||||
"diagnosisId": 123,
|
||||
"patientId": 8,
|
||||
}
|
||||
)
|
||||
|
||||
assert request == VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
patient_id=8,
|
||||
)
|
||||
assert request.to_web_config() == {
|
||||
"SDKAppID": 1400123456,
|
||||
"userID": "doctor_42",
|
||||
"userSig": "short-lived-ticket",
|
||||
"targetUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
}
|
||||
|
||||
|
||||
def test_accepts_uppercase_aliases_and_nested_backend_envelope() -> None:
|
||||
request = VideoCallRequest.from_backend_ticket(
|
||||
{
|
||||
"data": {
|
||||
"SDKAppID": 1400123456,
|
||||
"userID": "doctor_42",
|
||||
"userSig": "ticket-value",
|
||||
"targetUserId": "patient_8",
|
||||
}
|
||||
},
|
||||
diagnosis_id="diagnosis-123",
|
||||
patient_id=8,
|
||||
backend_mode="embedded",
|
||||
)
|
||||
|
||||
assert request.diagnosis_id == "diagnosis-123"
|
||||
assert request.backend_mode is BackendMode.EMBEDDED
|
||||
|
||||
|
||||
def test_accepts_repository_call_ticket_object_without_importing_core_models() -> None:
|
||||
ticket = SimpleNamespace(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="ticket-value",
|
||||
patient_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
raw={"sdkAppId": 1400123456},
|
||||
)
|
||||
|
||||
request = normalize_backend_ticket(ticket, patient_id=8)
|
||||
|
||||
assert request.patient_id == 8
|
||||
assert request.to_web_config()["targetUserId"] == "patient_8"
|
||||
|
||||
|
||||
def test_secret_is_excluded_from_repr_and_safe_log_context() -> None:
|
||||
request = normalize_backend_ticket(
|
||||
{
|
||||
"sdkAppId": 1400123456,
|
||||
"userId": "doctor_42",
|
||||
"userSig": "never-write-this-value",
|
||||
"patientUserId": "patient_8",
|
||||
},
|
||||
diagnosis_id=123,
|
||||
)
|
||||
|
||||
assert "never-write-this-value" not in repr(request)
|
||||
assert "never-write-this-value" not in str(request.safe_log_context())
|
||||
assert "user_sig" not in request.safe_log_context()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forbidden_key", ["SDKSecretKey", "sdk_secret_key", "secretKey"])
|
||||
def test_rejects_server_side_secret_material(forbidden_key: str) -> None:
|
||||
with pytest.raises(VideoTicketError, match="forbidden server-side secret"):
|
||||
normalize_backend_ticket(
|
||||
{
|
||||
"sdkAppId": 1400123456,
|
||||
"userId": "doctor_42",
|
||||
"userSig": "ticket-value",
|
||||
"patientUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
forbidden_key: "must-never-reach-a-client",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("sdkAppId", 0),
|
||||
("userId", ""),
|
||||
("userSig", ""),
|
||||
("patientUserId", " "),
|
||||
("diagnosisId", None),
|
||||
],
|
||||
)
|
||||
def test_rejects_incomplete_or_invalid_ticket(field: str, value: object) -> None:
|
||||
ticket: dict[str, object] = {
|
||||
"sdkAppId": 1400123456,
|
||||
"userId": "doctor_42",
|
||||
"userSig": "ticket-value",
|
||||
"patientUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
}
|
||||
ticket[field] = value
|
||||
|
||||
with pytest.raises(VideoTicketError):
|
||||
normalize_backend_ticket(ticket)
|
||||
|
||||
|
||||
def test_rejects_conflicting_aliases_and_modes() -> None:
|
||||
with pytest.raises(VideoTicketError, match="conflicting SDKAppID aliases"):
|
||||
normalize_backend_ticket(
|
||||
{
|
||||
"SDKAppID": 1400123456,
|
||||
"sdkAppId": 1400654321,
|
||||
"userID": "doctor_42",
|
||||
"userSig": "ticket-value",
|
||||
"targetUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(VideoTicketError, match="backend mode"):
|
||||
BackendMode.parse("native")
|
||||
|
||||
|
||||
def test_launcher_rejects_browser_before_importing_window_or_writing_repository() -> None:
|
||||
class Repository:
|
||||
def start_call(self, **payload: object) -> None:
|
||||
raise AssertionError(f"unexpected repository write: {payload}")
|
||||
|
||||
launcher = VideoCallLauncher(repository=Repository(), backend_mode="browser")
|
||||
with pytest.raises(VideoTicketError, match="one-time handoff"):
|
||||
launcher.prepare(
|
||||
{
|
||||
"sdkAppId": 1400123456,
|
||||
"userId": "doctor_42",
|
||||
"userSig": "ticket-value",
|
||||
"patientUserId": "patient_8",
|
||||
"diagnosisId": 123,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
start_entered = threading.Event()
|
||||
release_start = threading.Event()
|
||||
|
||||
class Repository:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, int]:
|
||||
start_entered.set()
|
||||
assert release_start.wait(2)
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"call_record_id": 900}
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
|
||||
events.append(("bind", diagnosis_id, room_id))
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
events.append(("end", diagnosis_id))
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
patient_id=8,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
started_at = time.monotonic()
|
||||
start_future = lifecycle.start()
|
||||
bind_future = lifecycle.bind_room("456789")
|
||||
duplicate_bind = lifecycle.bind_room("456789")
|
||||
changed_bind = lifecycle.bind_room("another-room")
|
||||
end_future = lifecycle.end("test")
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert start_entered.wait(1)
|
||||
assert elapsed < 0.2
|
||||
assert lifecycle.worker_is_daemon is True
|
||||
assert lifecycle.wait(0.01) is False
|
||||
assert duplicate_bind is bind_future
|
||||
assert changed_bind.result(timeout=0) is False
|
||||
|
||||
release_start.set()
|
||||
assert start_future.result(timeout=2) is True
|
||||
assert bind_future.result(timeout=2) is True
|
||||
assert end_future.result(timeout=2) is True
|
||||
assert lifecycle.wait(1) is True
|
||||
|
||||
assert events == [
|
||||
("start", 123, 8, 2),
|
||||
("bind", 123, "456789"),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_transcription_lifecycle_uses_start_record_id_and_remains_fifo() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, object]:
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"data": {"callRecordId": 901}}
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"transcription-start",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
language,
|
||||
)
|
||||
)
|
||||
|
||||
def upsert_call_transcript_segments(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
segments: list[dict[str, object]],
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"segment",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
segments,
|
||||
)
|
||||
)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"finish",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
expected_segment_count,
|
||||
status,
|
||||
)
|
||||
)
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
events.append(("end", diagnosis_id))
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
patient_id=8,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
start = lifecycle.start()
|
||||
transcription_start = lifecycle.start_transcription("session-1")
|
||||
segment = lifecycle.save_transcript_segment(
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_8",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
)
|
||||
duplicate = lifecycle.save_transcript_segment(
|
||||
{"segment_id": "seg-1", "text": "must not produce another write"}
|
||||
)
|
||||
finish = lifecycle.finish_transcription(status="completed")
|
||||
end = lifecycle.end("test")
|
||||
|
||||
assert duplicate is segment
|
||||
assert start.result(timeout=2) is True
|
||||
assert transcription_start.result(timeout=2) is True
|
||||
assert segment.result(timeout=2) is True
|
||||
assert finish.result(timeout=2) is True
|
||||
assert end.result(timeout=2) is True
|
||||
assert lifecycle.wait(1) is True
|
||||
assert lifecycle.call_record_id == 901
|
||||
assert events == [
|
||||
("start", 123, 8, 2),
|
||||
("transcription-start", 123, 901, "session-1", "zh-CN"),
|
||||
(
|
||||
"segment",
|
||||
123,
|
||||
901,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"transcription_session_id": "session-1",
|
||||
"speaker_user_id": "patient_8",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
],
|
||||
),
|
||||
("finish", 123, 901, "session-1", 1, "completed"),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_end_auto_finishes_active_transcription_as_partial() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, int]:
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"call_record_id": 902}
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"transcription-start",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
language,
|
||||
)
|
||||
)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"finish",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
expected_segment_count,
|
||||
status,
|
||||
)
|
||||
)
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
events.append(("end", diagnosis_id))
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
patient_id=8,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
lifecycle.start()
|
||||
lifecycle.start_transcription("session-auto-partial")
|
||||
ended = lifecycle.end("window-closed")
|
||||
|
||||
assert ended.result(timeout=2) is True
|
||||
assert lifecycle.wait(1) is True
|
||||
assert events == [
|
||||
("start", 123, 8, 2),
|
||||
("transcription-start", 123, 902, "session-auto-partial", "zh-CN"),
|
||||
("finish", 123, 902, "session-auto-partial", 0, "partial"),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_start_prevents_bind_and_end_writes() -> None:
|
||||
events: list[str] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
|
||||
del diagnosis_id, call_type
|
||||
events.append("start")
|
||||
raise RuntimeError("backend unavailable")
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
|
||||
del diagnosis_id, room_id
|
||||
events.append("bind")
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
del diagnosis_id
|
||||
events.append("end")
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
start_future = lifecycle.start()
|
||||
bind_future = lifecycle.bind_room("456789")
|
||||
end_future = lifecycle.end("test")
|
||||
|
||||
with pytest.raises(RuntimeError, match="backend unavailable"):
|
||||
start_future.result(timeout=1)
|
||||
assert bind_future.result(timeout=1) is False
|
||||
assert end_future.result(timeout=1) is False
|
||||
assert lifecycle.wait(1) is True
|
||||
assert events == ["start"]
|
||||
|
||||
|
||||
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, int]:
|
||||
events.append(("start", diagnosis_id, call_type))
|
||||
return {"call_record_id": 903}
|
||||
|
||||
def upload_material_bytes(
|
||||
self,
|
||||
content: bytes,
|
||||
filename: str,
|
||||
material_type: str,
|
||||
cid: int = 0,
|
||||
) -> str:
|
||||
events.append(("upload", content, filename, material_type, cid))
|
||||
return "/uploads/image/callshot-123.jpg"
|
||||
|
||||
def add_doctor_note(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
content: str,
|
||||
tongue_images: list[str],
|
||||
) -> None:
|
||||
events.append(("note", diagnosis_id, content, tongue_images))
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> None:
|
||||
events.append(("end", diagnosis_id))
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
lifecycle.start()
|
||||
screenshot = lifecycle.save_screenshot(b"jpeg-frame", "callshot-123.jpg")
|
||||
lifecycle.end("test")
|
||||
|
||||
assert screenshot.result(timeout=2) == "/uploads/image/callshot-123.jpg"
|
||||
assert lifecycle.wait(1) is True
|
||||
assert events == [
|
||||
("start", 123, 2),
|
||||
("upload", b"jpeg-frame", "callshot-123.jpg", "image", 0),
|
||||
("note", 123, "", ["/uploads/image/callshot-123.jpg"]),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_start_rejects_missing_record_id_without_using_ticket_fallback() -> None:
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, object]:
|
||||
del diagnosis_id, call_type
|
||||
return {}
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
call_record_id=77,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
with pytest.raises(ValueError, match="startCall response did not include"):
|
||||
lifecycle.start().result(timeout=2)
|
||||
|
||||
assert lifecycle.started is False
|
||||
|
||||
|
||||
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
|
||||
policy = TrustedDocumentPolicy.from_url(
|
||||
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
|
||||
is_local=False,
|
||||
)
|
||||
|
||||
assert policy.allows_main_document(
|
||||
"https://rtc.example.com:443/doctor-call/index.html?tenant=a#ready"
|
||||
)
|
||||
assert not policy.allows_main_document(
|
||||
"https://rtc.example.com/doctor-call/index.html?tenant=b"
|
||||
)
|
||||
assert not policy.allows_main_document("https://rtc.example.com/other/index.html?tenant=a")
|
||||
assert policy.allows_origin("https://rtc.example.com")
|
||||
assert not policy.allows_origin("https://sub.rtc.example.com")
|
||||
assert not policy.allows_origin("http://rtc.example.com")
|
||||
|
||||
with pytest.raises(TrustedDocumentError, match="HTTPS"):
|
||||
TrustedDocumentPolicy.from_url("http://rtc.example.com/doctor-call", is_local=False)
|
||||
|
||||
|
||||
def test_local_document_policy_rejects_sibling_files(tmp_path: Path) -> None:
|
||||
index = tmp_path / "dist" / "index.html"
|
||||
index.parent.mkdir()
|
||||
index.touch()
|
||||
sibling = index.with_name("other.html")
|
||||
sibling.touch()
|
||||
policy = TrustedDocumentPolicy.from_url(index.as_uri(), is_local=True)
|
||||
|
||||
assert policy.allows_main_document(index.as_uri())
|
||||
assert not policy.allows_main_document(sibling.as_uri())
|
||||
assert policy.allows_origin("file:///")
|
||||
Reference in New Issue
Block a user