Files
zyt/app/tests/test_ai_consult_picker_ui.py
T
2026-08-22 08:51:35 +08:00

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