first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
+342
View File
@@ -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"))
+566
View File
@@ -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()
+545
View File
@@ -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()
+129
View File
@@ -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()
+55
View File
@@ -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
+186
View File
@@ -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
+695
View File
@@ -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()
+55
View File
@@ -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)
+33
View File
@@ -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()
+23
View File
@@ -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
+559
View File
@@ -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 == []
+68
View File
@@ -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
+118
View File
@@ -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
+598
View File
@@ -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 == (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
+643
View File
@@ -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()
+52
View File
@@ -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)
+478
View File
@@ -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()
+461
View File
@@ -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
+760
View File
@@ -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")
+268
View File
@@ -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 ("", "", "", "", "", "", "", "")
)
+507
View File
@@ -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()
+587
View File
@@ -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:///")