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, QDialog from doctor_workstation.core import PermissionSet from doctor_workstation.ui.dialogs import ai_consult_picker as picker_module from doctor_workstation.ui.dialogs.ai_consult_picker import ( AiConsultTarget, AiConsultTargetDialog, select_and_present_ai_consult, ) @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(picker_module, "run_async", run_immediately) def _row(**changes: Any) -> dict[str, Any]: row = { "id": 501, "diagnosis_id": 501, "source_patient_id": 301, "patient_name": "张三", "gender": 1, "age": 52, "phone": "13800138000", "phone_masked": "138****8000", "id_card": "110101199001011234", "diagnosis_date": "2026-08-20", "diagnosis_summary": "2型糖尿病", "last_visit_at": "2026-08-19 09:30", "next_appointment_at": "2026-08-25 10:00", } row.update(changes) return row def test_target_keeps_diagnosis_and_patient_ids_distinct_and_sanitizes_seed() -> None: target = AiConsultTarget.from_row(_row()) assert target is not None assert target.diagnosis_id == 501 assert target.patient_id == 301 assert target.phone_masked == "138****8000" assert target.seed["diagnosis_id"] == 501 assert target.seed["source_patient_id"] == 301 assert "phone" not in target.seed assert "id_card" not in target.seed without_patient = AiConsultTarget.from_row( {"id": 502, "patient_name": "仅有诊单号"} ) assert without_patient is not None assert without_patient.diagnosis_id == 502 assert without_patient.patient_id == 0 def test_picker_loads_masked_rows_without_auto_selecting( application: QApplication, immediate_async: None, ) -> None: calls: list[dict[str, Any]] = [] class Repository: def list_ai_patient_options(self, **kwargs: Any) -> dict[str, Any]: calls.append(kwargs) return {"lists": [_row()], "count": 1} dialog = AiConsultTargetDialog( Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"]), initial_query=" 张三 ", ) dialog.show() application.processEvents() assert calls == [{"page_no": 1, "page_size": 20, "keyword": "张三"}] assert dialog.table.rowCount() == 1 assert dialog.table.currentRow() == -1 assert not dialog.start_button.isEnabled() assert dialog.table.item(0, 2).text() == "138****8000" assert "13800138000" not in " ".join( dialog.table.item(0, column).text() for column in range(dialog.table.columnCount()) ) dialog.table.selectRow(0) application.processEvents() assert dialog.start_button.isEnabled() dialog.accept() assert dialog.result() == QDialog.DialogCode.Accepted assert dialog.selected_target() is not None assert dialog.selected_target().diagnosis_id == 501 assert dialog.selected_target().patient_id == 301 def test_search_return_reloads_but_never_accepts_old_selection( application: QApplication, immediate_async: None, ) -> None: calls: list[str] = [] class Repository: def list_ai_patient_options(self, **kwargs: Any) -> dict[str, Any]: calls.append(str(kwargs["keyword"])) return {"lists": [_row(patient_name=str(kwargs["keyword"]) or "最近患者")], "count": 1} dialog = AiConsultTargetDialog( Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"]) ) dialog.show() application.processEvents() dialog.table.selectRow(0) dialog.search_edit.setText("李四") dialog.search_edit.returnPressed.emit() application.processEvents() assert calls == ["", "李四"] assert dialog.result() == 0 assert dialog.table.currentRow() == -1 assert not dialog.start_button.isEnabled() dialog.reject() def test_picker_ignores_late_success_and_error( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: callbacks: list[dict[str, Any]] = [] def queue_async(_function: Any, **options: Any) -> object: callbacks.append(options) return object() monkeypatch.setattr(picker_module, "run_async", queue_async) repository = SimpleNamespace(list_ai_patient_options=lambda **_kwargs: None) dialog = AiConsultTargetDialog( repository, PermissionSet(["tcm.diagnosis/aiAssistant"]) ) dialog.show() application.processEvents() assert len(callbacks) == 1 dialog.search_edit.setText("新患者") dialog.search_now() assert len(callbacks) == 2 callbacks[1]["on_success"]( {"lists": [_row(diagnosis_id=700, id=700, patient_name="新患者")], "count": 1} ) callbacks[0]["on_success"]({"lists": [_row(patient_name="旧患者")], "count": 1}) callbacks[0]["on_error"](RuntimeError("旧请求失败")) application.processEvents() assert dialog.table.rowCount() == 1 assert dialog.table.item(0, 0).text() == "新患者" assert not dialog.banner.isVisible() dialog.reject() callbacks[1]["on_error"](RuntimeError("关闭后的错误")) application.processEvents() assert not dialog.isVisible() def test_picker_error_has_retry_and_no_confirm( application: QApplication, immediate_async: None, ) -> None: class Repository: def list_ai_patient_options(self, **_kwargs: Any) -> dict[str, Any]: raise RuntimeError("服务暂不可用") dialog = AiConsultTargetDialog( Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"]) ) dialog.show() application.processEvents() assert dialog.banner.isVisible() assert "加载失败" in dialog.banner.label.text() assert dialog.empty_state.isVisible() assert not dialog.start_button.isEnabled() dialog.reject() def test_selector_orchestrator_opens_ai_only_after_accept( monkeypatch: pytest.MonkeyPatch, ) -> None: target = AiConsultTarget.from_row(_row()) assert target is not None opened: list[dict[str, Any]] = [] class AcceptedDialog: def __init__(self, *_args: Any, **_kwargs: Any) -> None: pass def exec(self) -> QDialog.DialogCode: return QDialog.DialogCode.Accepted def selected_target(self) -> AiConsultTarget: return target monkeypatch.setattr(picker_module, "AiConsultTargetDialog", AcceptedDialog) monkeypatch.setattr( picker_module, "present_ai_consult", lambda *_args, **kwargs: opened.append(kwargs), ) allowed = PermissionSet(["tcm.diagnosis/aiAssistant"]) assert select_and_present_ai_consult(object(), allowed, None, initial_query="张三") assert opened == [ { "diagnosis_id": 501, "patient_id": 301, "seed": target.seed, "source_title": "AI 助手", } ] class RejectedDialog(AcceptedDialog): def exec(self) -> QDialog.DialogCode: return QDialog.DialogCode.Rejected monkeypatch.setattr(picker_module, "AiConsultTargetDialog", RejectedDialog) assert not select_and_present_ai_consult(object(), allowed, None) assert len(opened) == 1