from __future__ import annotations import os 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.services import DemoDoctorRepository from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module from doctor_workstation.ui.dialogs.ai_consult import ( AiConsultDialog, can_open_ai_consult, present_ai_consult, render_chat_payload, ) from doctor_workstation.ui.pages.appointments import AppointmentsPage from doctor_workstation.ui.pages.consultations import ConsultationsPage from doctor_workstation.ui.pages.patients import PatientListWorkspace from doctor_workstation.ui.pages.reception import ReceptionPage @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_consult_module, "run_async", run_immediately) def test_ai_consult_dialog_matches_workspace_chrome( application: QApplication, immediate_async: None, ) -> None: repository = DemoDoctorRepository() dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"])) dialog.open_for( diagnosis_id=501, patient_id=301, seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"}, source_title="问诊列表", ) dialog.show() application.processEvents() labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()] assert "问诊详情" in labels assert "AI 助手" in labels assert "智能分析" in labels assert "快捷工具" in labels assert "对话建议" in labels assert dialog.tabs.tabText(0) == "问诊对话" assert dialog.send_button.objectName() == "AiConsultSend" dialog.close() def test_present_ai_consult_requires_diagnosis_id( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: opened: list[int] = [] monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id)) present_ai_consult( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), None, diagnosis_id=0, ) assert opened == [] present_ai_consult( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), None, diagnosis_id=501, seed={"patient_name": "杨永"}, ) assert opened == [501] def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None: repository = DemoDoctorRepository() allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"]) assert can_open_ai_consult(allowed) patients = PatientListWorkspace(repository, allowed) patients.show() application.processEvents() assert patients.ai_consult_button.text() == "AI 分析" assert not patients.ai_consult_button.isHidden() reception = ReceptionPage(repository, allowed) reception.show() application.processEvents() menu_titles = [action.text() for action in reception.more_button.menu().actions()] assert "AI 分析" in menu_titles assert reception.ai_consult_button.text() == "AI 分析" appointments = AppointmentsPage(repository, permissions=allowed) appointments.show() application.processEvents() assert appointments.toolbar_ai_consult_button.text() == "AI 分析" assert not appointments.toolbar_ai_consult_button.isHidden() consultations = ConsultationsPage(repository, permissions=allowed) assert consultations.table_host.action_policy.get("ai_consult") is True patients.close() reception.close() appointments.close() consultations.close() def test_ai_consult_sidebar_loads_patient_facts_and_reports( application: QApplication, immediate_async: None, ) -> None: dialog = AiConsultDialog( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), ) dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"}) dialog.show() application.processEvents() values = { widget.text() for widget in dialog.findChildren(QLabel) if widget.objectName() == "AiConsultKeyValue" } assert "22.1" in values assert any("病程" in text or "3" in text for text in values) titles = { widget.text() for widget in dialog.findChildren(QLabel) if widget.objectName() == "AiConsultRecordTitle" } assert "血糖控制评估" in titles assert "并发症风险评估" in titles bodies = [ widget.toPlainText() for widget in dialog.findChildren(QTextBrowser) if widget.objectName() == "AiConsultBubbleText" ] assert any("病情与证候分析" in text for text in bodies) assert any("###" not in text for text in bodies if "病情与证候分析" in text) dialog.close() def test_ai_consult_sidebar_survives_chat_archive_errors( application: QApplication, immediate_async: None, ) -> None: class BrokenChatRepository(DemoDoctorRepository): def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True): raise RuntimeError("archive unavailable") dialog = AiConsultDialog( BrokenChatRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), ) dialog.open_for(diagnosis_id=501, patient_id=301) dialog.show() application.processEvents() values = { widget.text() for widget in dialog.findChildren(QLabel) if widget.objectName() == "AiConsultKeyValue" } titles = { widget.text() for widget in dialog.findChildren(QLabel) if widget.objectName() == "AiConsultRecordTitle" } assert "22.1" in values assert "血糖控制评估" in titles dialog.close() def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None: browser = QTextBrowser() render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干") assert "病情摘要" in browser.toPlainText() assert "核心病机" in browser.toPlainText() assert "###" not in browser.toPlainText() assert "空腹血糖 6.8

") assert "空腹血糖" in browser.toPlainText() assert "6.8" in browser.toPlainText() render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}') assert "肝郁脾虚证" in browser.toPlainText() browser.deleteLater() def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order( application: QApplication, ) -> None: dialog = AiConsultDialog( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), ) dialog.show() dialog._stream_bubble = dialog._append_bubble("ai", "") bubble = dialog._stream_bubble generation = dialog._generation stream_generation = dialog._stream_generation dialog._stream_event( generation, stream_generation, {"event": "delta", "text": "第一段"}, ) dialog._flush_timer.stop() dialog._flush_stream_chunks() application.processEvents() assert bubble is not None and bubble.body is not None assert bubble.body.toPlainText() == "第一段" ai_bubble_count = len( [frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"] ) dialog._stream_event( generation, stream_generation, {"event": "delta", "text": "第二段"}, ) dialog._stream_event( generation, stream_generation, {"event": "done", "model_label": "千问"}, ) application.processEvents() assert bubble.body.toPlainText() == "第一段第二段" assert len( [frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"] ) == ai_bubble_count dialog.close() def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble( application: QApplication, ) -> None: dialog = AiConsultDialog( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), ) dialog.show() dialog._stream_bubble = dialog._append_bubble("ai", "") bubble = dialog._stream_bubble generation = dialog._generation stream_generation = dialog._stream_generation dialog._stream_event( generation, stream_generation, {"event": "delta", "text": "已生成"}, ) dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙")) application.processEvents() assert bubble is not None and bubble.body is not None assert "已生成" in bubble.body.toPlainText() assert "模型繁忙" in bubble.body.toPlainText() before_cancel = bubble.body.toPlainText() dialog.close() application.processEvents() dialog._stream_event( generation, stream_generation, {"event": "delta", "text": "迟到内容"}, ) application.processEvents() assert bubble.body.toPlainText() == before_cancel def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it( application: QApplication, ) -> None: dialog = AiConsultDialog( DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), ) dialog.diagnosis_id = 501 dialog.show() for index in range(28): dialog._append_bubble("ai", f"历史消息 {index}:" + "辨证内容" * 16) application.processEvents() bar = dialog.chat_scroll.verticalScrollBar() bar.setValue(bar.maximum()) application.processEvents() assert dialog._follow_chat bar.setValue(max(0, bar.maximum() // 3)) application.processEvents() reading_position = bar.value() assert not dialog._follow_chat dialog._append_bubble("ai", "新的流式内容" * 20) application.processEvents() assert bar.value() == reading_position dialog._ask("请继续分析") application.processEvents() assert dialog._follow_chat assert bar.value() == bar.maximum() dialog.close()