Files
zyt/app/tests/test_prescription_ai_ui.py
T
2026-08-27 14:23:23 +08:00

524 lines
19 KiB
Python

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, QTextBrowser
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": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
"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.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
assert "###" not in dialog.answer_label.toPlainText()
assert "**" not in dialog.answer_label.toPlainText()
rendered_html = dialog.answer_label.toHtml().lower()
assert "<h3" in rendered_html
assert "font-weight:700" in rendered_html.replace(" ", "")
assert "openai" in dialog.model_label.text()
assert isinstance(dialog.answer_label, QTextBrowser)
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
assert dialog.answer_label.openLinks() is False
assert dialog.answer_label.openExternalLinks() is False
assert dialog.loading is False
assert dialog.retry_button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
return {
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
"model_key": "qwen",
}
dialog = DiagnosisAiAssistantDialog(Repository())
dialog.open_for(501, "复诊建议", task="custom")
assert "安全内容" in dialog.answer_label.toPlainText()
assert "建议复诊" in dialog.answer_label.toPlainText()
assert "<img" in dialog.answer_label.toPlainText()
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
assert (
dialog.answer_label.loadResource(
ai_module.QTextDocument.ResourceType.ImageResource,
"https://invalid.example/pixel",
)
is None
)
dialog.answer_label.selectAll()
copied = dialog.answer_label.createMimeDataFromSelection()
assert copied.hasText()
assert copied.hasHtml() is False
assert set(copied.formats()) == {"text/plain"}
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()