This commit is contained in:
Your Name
2026-08-14 14:37:30 +08:00
parent 21790e35f4
commit 18c15d1262
117 changed files with 28157 additions and 8080 deletions
+1 -1
View File
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #0891B2;" in APPOINTMENT_DRAWER_QSS
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
+125 -7
View File
@@ -9,7 +9,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QDialog, QLabel
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -348,14 +348,14 @@ def test_appointment_multiline_cells_receive_enough_row_height(
False,
)
patient_text = page.table.item(0, 1).text()
appointment_text = page.table.item(0, 3).text()
assert patient_text.count("\n") == 2
assert appointment_text == "2026-08-11\n14:30"
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, 1).toolTip() == patient_text
assert page.table.item(0, 3).toolTip() == appointment_text
assert page.table.item(0, 4).toolTip() == appointment_text
page.close()
@@ -379,11 +379,68 @@ def test_video_qr_dialog_renders_downloaded_image_inside_app(
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,
@@ -425,3 +482,64 @@ def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
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()
+63 -13
View File
@@ -9,10 +9,12 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Signal
from PySide6.QtWidgets import (
QApplication,
QDialog,
QInputDialog,
QMessageBox,
QApplication,
QDialog,
QDialogButtonBox,
QInputDialog,
QLabel,
QMessageBox,
QToolButton,
QWidget,
)
@@ -20,9 +22,14 @@ from PySide6.QtWidgets import (
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_video_payload,
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_AppointmentLogDialog,
_DiagnosisCreateDialog,
_DiagnosisOrderDialog,
_DiagnosisOrderQrDialog,
_QrResultDialog,
_video_payload,
appointment_rows,
is_diagnosis_confirmed,
is_valid_id_card,
@@ -81,7 +88,7 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
@@ -105,8 +112,50 @@ def _row(**changes: Any) -> dict[str, Any]:
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_prescription": 0,
}
row.update(changes)
return row
row.update(changes)
return row
def test_consultation_business_dialogs_expose_shared_visual_contract(
application: QApplication,
) -> None:
record = _row(patient_name="鹿立核", patient_id=301)
dialogs = (
_DiagnosisCreateDialog(),
_DiagnosisOrderDialog(record),
_AppointmentLogDialog(record, []),
_QrResultDialog("视频二维码", record, "https://example.invalid/video.png"),
_DiagnosisOrderQrDialog(record, "ORDER-301"),
)
assert [dialog.objectName() for dialog in dialogs] == [
"DiagnosisCreateDialog",
"DiagnosisOrderDialog",
"AppointmentLogDialog",
"DiagnosisQrResultDialog",
"DiagnosisOrderQrDialog",
]
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.Save
).property("variant") == "primary"
assert dialogs[1].findChild(QDialogButtonBox).button(
QDialogButtonBox.StandardButton.Ok
).property("variant") == "primary"
assert next(
button
for button in dialogs[3].findChild(QDialogButtonBox).buttons()
if button.text() == "打开二维码"
).property("variant") == "primary"
assert dialogs[4].open_button.property("variant") == "primary"
for dialog in dialogs:
dialog.close()
application.processEvents()
def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
@@ -165,9 +214,10 @@ def test_default_query_matches_admin_today_and_page_size_contract(
assert len(calls) == 1
query = calls[0]
assert query["page_no"] == 1
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert "status" not in query
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert query["appointment_status"] == "1"
assert "status" not in query
assert query["has_appointment"] == ""
assert query["diagnosis_confirmed"] == ""
assert {
+4 -4
View File
@@ -775,7 +775,7 @@ def test_hpi_choice_chips_are_visible_after_dictionary_load(
def test_choice_chips_keep_visible_checked_style_when_readonly(
application: QApplication,
) -> None:
"""Selected chips must stay cyan even when the form is view-only locked."""
"""Selected chips keep the blue-tinted fill when the form is view-only locked."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
@@ -785,14 +785,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#cffafe"
assert enabled_color.name().lower() == "#f0f2ff"
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#cffafe"
assert disabled_color.name().lower() == "#f0f2ff"
dialog.close()
application.processEvents()
@@ -812,7 +812,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#cffafe"
assert color.name().lower() == "#f0f2ff"
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
File diff suppressed because it is too large Load Diff
+597
View File
@@ -0,0 +1,597 @@
"""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 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 == (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
)
+95 -10
View File
@@ -8,7 +8,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QInputDialog
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
@@ -21,6 +21,9 @@ from doctor_workstation.ui.pages.patients import (
PatientProgressWorkspace,
PatientsPage,
_AppointmentDialog,
_AssignDialog,
_OrderDetailDialog,
_OrderEditDialog,
_PaymentDialog,
_RefundDialog,
)
@@ -102,7 +105,7 @@ def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions(
resolved = _resolve_navigation(menu, permissions, demo_mode=False)
assert [(item.key, title) for item, title in resolved] == [
("patients", "患者工作区"),
("patients", "接诊台"),
("consultations", "问诊工作区"),
]
assert _resolve_navigation([], permissions, demo_mode=False) == []
@@ -187,7 +190,7 @@ def test_patient_refresh_generation_ignores_late_results(
application.processEvents()
assert workspace.table.rowCount() == 1
assert workspace.table.item(0, 0).text().startswith("新结果")
assert workspace.table.item(0, 1).text().startswith("新结果")
workspace.close()
@@ -371,6 +374,59 @@ def test_payment_and_refund_forms_expose_full_contract(
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,
@@ -525,12 +581,12 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
patient_menu = next(
row for row in session.menu if row.get("perms") == "firstvisit.myPatient/lists"
reception_menu = next(
row for row in session.menu if row.get("perms") == "doctor.appointment/lists"
)
patient_menu["name"] = "患者中心"
patient_menu["sort"] = 99
session.menu = [patient_menu]
reception_menu["name"] = "接诊台"
reception_menu["sort"] = 99
session.menu = [reception_menu]
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
@@ -540,8 +596,8 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
shell.show()
application.processEvents()
assert list(shell.pages) == ["patients"]
assert shell.nav_buttons["patients"].text().endswith("患者中心")
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
@@ -556,3 +612,32 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
}
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()
+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()
+33 -6
View File
@@ -221,11 +221,35 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 9
assert page.table.columnCount() == 10
assert [
page.table.horizontalHeaderItem(index).text()
for index in range(page.table.columnCount())
] == [
"ID",
"处方名称",
"处方类型",
"药材数量",
"药材组成(部分)",
"功效主治",
"公开范围",
"创建人",
"创建时间",
"操作",
]
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 6) is not None
assert page.table.cellWidget(0, 9) is not None
assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
assert page.ai_button.text() == "AI解释"
assert not page.edit_button.isHidden() and page.edit_button.isEnabled()
assert not page.delete_button.isHidden() and page.delete_button.isEnabled()
assert page.pager.page_size == 15
assert page.metric_cards["total"].value_label.text() == "1"
assert page.metric_cards["private"].value_label.text() == "1"
assert page.metric_cards["public"].value_label.text() == "0"
assert page.metric_cards["month"].value_label.text() == "1"
page.close()
application.processEvents()
@@ -278,6 +302,12 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 11
assert page.table.horizontalHeaderItem(10).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 5) is not None
assert page.table.cellWidget(0, 10) is not None
assert calls == [
{
"page_no": 1,
@@ -615,16 +645,13 @@ def test_editor_matches_admin_empty_rows_dosage_choices_and_detail_merge(
assert editor.herbs.rows == []
editor.herbs.add_row(formula_type="主方")
assert editor.payload()["herbs"] == [
{"name": "", "dosage": 0.0, "formula_type": "主方"}
]
assert editor.payload()["herbs"] == [{"name": "", "dosage": 0.0, "formula_type": "主方"}]
editor.need_decoction.setChecked(True)
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("饮片"))
assert editor.need_decoction.isChecked()
assert [
editor.dosage_amount.itemData(index)
for index in range(editor.dosage_amount.count())
editor.dosage_amount.itemData(index) for index in range(editor.dosage_amount.count())
] == [50.0, 100.0, 120.0, 150.0, 180.0, 200.0, 250.0]
editor.prescription_type.setCurrentIndex(editor.prescription_type.findData("颗粒"))
File diff suppressed because it is too large Load Diff
+299
View File
@@ -11,6 +11,7 @@ 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,
@@ -64,6 +65,30 @@ class RecordingClient:
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":
@@ -79,6 +104,78 @@ class RecordingClient:
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}
@@ -395,9 +492,211 @@ def test_canonical_prescription_permissions_match_routed_views() -> None:
"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:
+73 -18
View File
@@ -6,6 +6,7 @@ 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
@@ -35,6 +36,22 @@ def application() -> QApplication:
return QApplication.instance() or QApplication([])
def test_patients_navigation_ignores_the_legacy_server_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", "接诊台")]
@pytest.fixture
def shell_window(
application: QApplication,
@@ -45,16 +62,23 @@ def shell_window(
for key, title, glyph, permission in (
("reception", "接诊台", "", "doctor.appointment/lists"),
("appointments", "挂号列表", "", "doctor.appointment/lists"),
("prescription_library", "我的处方库", "", "tcm.prescriptionLibrary/lists"),
(
"prescription_library",
"我的处方库",
"",
"tcm.prescriptionLibrary/lists",
),
("prescriptions", "已开处方", "", "tcm.prescription/lists"),
("patients", "我的患者", "", "firstvisit.myPatient/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],
lambda _menu, _permissions, *, demo_mode: [
(item, item.title) for item in navigation
],
)
window = ShellWindow(
object(),
@@ -71,7 +95,7 @@ def shell_window(
application.processEvents()
def test_shell_matches_admin_geometry_at_both_acceptance_sizes(
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
application: QApplication,
shell_window: ShellWindow,
) -> None:
@@ -79,19 +103,24 @@ def test_shell_matches_admin_geometry_at_both_acceptance_sizes(
shell_window.resize(width, height)
application.processEvents()
assert shell_window.sidebar.width() == 208
assert shell_window.topbar.height() == 58
assert shell_window.tabs_host.height() == 42
assert shell_window.workspace.width() == width - 208
assert shell_window.stack.width() == width - 208
assert shell_window.stack.height() == height - 100
assert shell_window.sidebar.width() == 199
assert shell_window.topbar.height() == 62
assert shell_window.tabs_host.height() == 0
assert shell_window.workspace.width() == width - 26 - 199
assert shell_window.stack.width() == width - 26 - 199
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() == "#ffffff"
assert image.pixelColor(500, 10).name().lower() == "#ffffff"
assert image.pixelColor(220, 110).name().lower() == "#f5f7fb"
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:
@@ -100,6 +129,25 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
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 "服务端自动匹配" 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:
@@ -146,10 +194,15 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
assert not shell_window.close_tab("reception")
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.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()) == "reception"
assert (
shell_window.tab_bar.tabData(shell_window.tab_bar.currentIndex()) == "reception"
)
assert shell_window.stack.currentWidget() is shell_window.pages["reception"]
assert shell_window.nav_buttons["reception"].isChecked()
@@ -158,16 +211,18 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
assert shell_window.stack.currentWidget() is shell_window.pages["reception"]
def test_sidebar_collapse_preserves_active_navigation(shell_window: ShellWindow) -> None:
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() == 72
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() == 208
assert shell_window.sidebar.width() == 195
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
assert shell_window.nav_buttons["consultations"].isChecked()
+36 -2
View File
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QCoreApplication, QSettings
from PySide6.QtGui import QPageSize
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
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
@@ -17,6 +17,7 @@ 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,
@@ -47,6 +48,39 @@ def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
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",),