更新
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 == (
|
||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",),
|
||||
|
||||
Reference in New Issue
Block a user