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 "