261 lines
7.9 KiB
Python
261 lines
7.9 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, 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
|
|
|