更新
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -7,13 +8,22 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTextBrowser,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
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,
|
||||
build_patient_ai_context,
|
||||
can_open_ai_consult,
|
||||
present_ai_consult,
|
||||
render_chat_payload,
|
||||
@@ -27,6 +37,34 @@ from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
|
||||
LONG_CLINICAL_REPLY = """
|
||||
基于本诊单资料,患者为56岁男性,空腹血糖偏高(6.4 mmol/L),既往有脂肪肝并主诉性功能下降。
|
||||
|
||||
### 1. 可能证候分析
|
||||
- **脾肾两虚,兼夹痰湿**
|
||||
- **支持点:** 脾虚失健运,痰湿内阻,空腹血糖受损。
|
||||
- **肾气不足:** 年过五旬且性功能下降,需结合四诊进一步辨别。
|
||||
- **肝肾阴虚,虚火内扰(需鉴别)**
|
||||
- **支持点:** 若伴口干、潮热、舌红少苔,则需要纳入鉴别。
|
||||
|
||||
### 2. 关键矛盾与不足
|
||||
- **缺乏四诊合参:** 尚无舌象与脉象资料。
|
||||
- **症状细节模糊:** 性功能下降的具体表现与病程仍需确认。
|
||||
- **代谢指标单一:** 缺少糖化血红蛋白与餐后血糖。
|
||||
|
||||
### 3. 建议下一步
|
||||
1. **补充四诊:** 采集舌象、脉象及症状细节。
|
||||
2. **完善检查:** 复查糖化血红蛋白、餐后血糖、肝功能和血脂。
|
||||
3. **评估代谢风险:** 综合评估胰岛素抵抗、脂肪肝及心血管风险。
|
||||
4. **制定随访:** 根据检查结果制定治疗与随访计划。
|
||||
|
||||
### 4. 风险提示
|
||||
- **代谢综合征风险:** 血糖偏高合并脂肪肝,需要关注代谢风险。
|
||||
- **排查心血管因素:** 性功能异常可能与血管因素有关。
|
||||
|
||||
**重要提示:** 以上分析仅作临床辅助,不能替代执业医师的面诊、确诊或处方。
|
||||
""".strip()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
@@ -85,6 +123,333 @@ def test_ai_consult_dialog_matches_workspace_chrome(
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
||||
def test_input_enter_submits_once_without_closing_dialog(
|
||||
application: QApplication,
|
||||
key: Qt.Key,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
submitted: list[str] = []
|
||||
rejected: list[bool] = []
|
||||
dialog._ask = lambda text: submitted.append(text) # type: ignore[method-assign]
|
||||
dialog.rejected.connect(lambda: rejected.append(True))
|
||||
dialog.show()
|
||||
dialog.input.setText("总结当前病情")
|
||||
dialog.input.setFocus()
|
||||
application.processEvents()
|
||||
|
||||
QTest.keyClick(dialog.input, key)
|
||||
application.processEvents()
|
||||
|
||||
assert submitted == ["总结当前病情"]
|
||||
assert rejected == []
|
||||
assert dialog.isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "expected"),
|
||||
[
|
||||
("开个处方", True),
|
||||
("请给当前患者开一张处方", True),
|
||||
("新建处方", True),
|
||||
("分析当前处方", False),
|
||||
("怎么开方更合理", False),
|
||||
("是否需要开方", False),
|
||||
("给出用药建议", False),
|
||||
],
|
||||
)
|
||||
def test_prescription_action_intent_is_explicit_and_conservative(
|
||||
prompt: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
assert ai_consult_module._is_open_prescription_intent(prompt) is expected
|
||||
|
||||
|
||||
def test_prescription_action_without_permission_does_not_call_ai_or_repository(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def create_prescription(self, _payload: Any) -> None:
|
||||
raise AssertionError("must not create without permission")
|
||||
|
||||
dialog = AiConsultDialog(
|
||||
Repository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
|
||||
dialog._ask("开个处方")
|
||||
application.processEvents()
|
||||
|
||||
bodies = [
|
||||
browser.toPlainText()
|
||||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||
]
|
||||
assert any("没有开方权限" in body for body in bodies)
|
||||
assert dialog._stream_worker is None
|
||||
assert not dialog._asking
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created: list[dict[str, Any]] = []
|
||||
editor_seeds: list[dict[str, Any]] = []
|
||||
current_user = {"id": 12, "name": "甄医生"}
|
||||
|
||||
class Repository:
|
||||
def get_diagnosis_detail(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
assert diagnosis_id == 501
|
||||
assert readonly
|
||||
return {
|
||||
"diagnosis": {
|
||||
"id": 501,
|
||||
"patient_id": 301,
|
||||
"patient_name": "尹山",
|
||||
"gender": 1,
|
||||
"age": 56,
|
||||
"phone": "13800000000",
|
||||
"clinical_diagnosis": "脾肾两虚证",
|
||||
"case_record": {"chief_complaint": "性功能下降"},
|
||||
},
|
||||
"patient": {"id": 301, "name": "尹山"},
|
||||
"appointment": {"id": 801, "doctor_name": "甄医生"},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_ai_prescription(diagnosis_id: int) -> dict[str, Any]:
|
||||
assert diagnosis_id == 501
|
||||
return {
|
||||
"diagnosis_id": 501,
|
||||
"task": "prescription_generate",
|
||||
"context_scope": "patient_longitudinal",
|
||||
"prescription_draft": {
|
||||
"clinical_diagnosis": "脾肾两虚证",
|
||||
"herbs": [
|
||||
{"name": "茯苓", "dosage": 10, "formula_type": "主方"}
|
||||
],
|
||||
"dose_count": 7,
|
||||
"usage_days": 7,
|
||||
},
|
||||
}
|
||||
|
||||
def create_prescription(self, prescription: dict[str, Any]) -> dict[str, Any]:
|
||||
created.append(prescription)
|
||||
return {"id": 9001, **prescription}
|
||||
|
||||
def list_prescriptions_by_diagnosis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [{"id": 9001, "diagnosis_id": diagnosis_id, "sn": "RX-9001"}]
|
||||
|
||||
class FakeSignal:
|
||||
def connect(self, _slot: Any) -> None:
|
||||
return None
|
||||
|
||||
class FakeEditor:
|
||||
def __init__(
|
||||
self,
|
||||
_repository: Any,
|
||||
seed: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
editor_seeds.append(seed)
|
||||
assert kwargs["mode"] == "add"
|
||||
assert kwargs["current_user"] == current_user
|
||||
self.diagnosis_requested = FakeSignal()
|
||||
|
||||
def exec(self) -> Any:
|
||||
return ai_consult_module.QDialog.DialogCode.Accepted
|
||||
|
||||
@staticmethod
|
||||
def payload() -> dict[str, Any]:
|
||||
return {
|
||||
"diagnosis_id": 999,
|
||||
"appointment_id": 998,
|
||||
"case_record": {"forged": True},
|
||||
"doctor_name": "甄医生",
|
||||
"herbs": [{"name": "茯苓", "dosage": 10, "unit": "g"}],
|
||||
}
|
||||
|
||||
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
prescription_module,
|
||||
"PrescriptionEditorDialog",
|
||||
FakeEditor,
|
||||
)
|
||||
host = QWidget()
|
||||
host.current_user = current_user # type: ignore[attr-defined]
|
||||
dialog = AiConsultDialog(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
||||
),
|
||||
parent=host,
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
dialog.patient_id = 301
|
||||
dialog.show()
|
||||
|
||||
dialog._ask("开个处方")
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
assert len(editor_seeds) == 1
|
||||
assert editor_seeds[0]["diagnosis_id"] == 501
|
||||
assert editor_seeds[0]["appointment_id"] == 801
|
||||
assert editor_seeds[0]["herbs"][0]["name"] == "茯苓"
|
||||
assert created and created[0]["diagnosis_id"] == 501
|
||||
assert created[0]["appointment_id"] == 801
|
||||
assert created[0]["case_record"] == {"chief_complaint": "性功能下降"}
|
||||
assert created[0]["patient_id"] == 301
|
||||
assert created[0]["phone"] == "13800000000"
|
||||
assert dialog._stream_worker is None
|
||||
assert not dialog._asking
|
||||
bodies = [
|
||||
browser.toPlainText()
|
||||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||
]
|
||||
assert any("处方已开具并提交审核" in body for body in bodies)
|
||||
dialog.close()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_prescription_action_rejects_mismatched_patient_detail(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
opened = False
|
||||
created = False
|
||||
|
||||
class Repository:
|
||||
@staticmethod
|
||||
def get_diagnosis_detail(
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
assert readonly
|
||||
return {
|
||||
"diagnosis": {
|
||||
"id": diagnosis_id,
|
||||
"patient_id": 999,
|
||||
"patient_name": "其他患者",
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_prescription(prescription: Any) -> None:
|
||||
nonlocal created
|
||||
del prescription
|
||||
created = True
|
||||
|
||||
@staticmethod
|
||||
def generate_ai_prescription(diagnosis_id: int) -> dict[str, Any]:
|
||||
raise AssertionError(f"mismatched diagnosis {diagnosis_id} must fail before AI")
|
||||
|
||||
dialog = AiConsultDialog(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
||||
),
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
dialog.patient_id = 301
|
||||
|
||||
def capture_open(*_args: Any) -> None:
|
||||
nonlocal opened
|
||||
opened = True
|
||||
|
||||
dialog._open_prescription_editor_from_chat = capture_open # type: ignore[method-assign]
|
||||
dialog._ask("开个处方")
|
||||
for _ in range(2):
|
||||
application.processEvents()
|
||||
|
||||
assert not opened
|
||||
assert not created
|
||||
bodies = [
|
||||
browser.toPlainText()
|
||||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||
]
|
||||
assert any("患者与当前会话不一致" in body for body in bodies)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_cancelling_prescription_editor_never_creates_prescription(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created: list[Any] = []
|
||||
|
||||
class Repository:
|
||||
@staticmethod
|
||||
def create_prescription(prescription: Any) -> None:
|
||||
created.append(prescription)
|
||||
|
||||
class FakeEditor:
|
||||
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def exec() -> Any:
|
||||
return ai_consult_module.QDialog.DialogCode.Rejected
|
||||
|
||||
@staticmethod
|
||||
def payload() -> dict[str, Any]:
|
||||
raise AssertionError("cancelled editor must not read payload")
|
||||
|
||||
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
prescription_module,
|
||||
"PrescriptionEditorDialog",
|
||||
FakeEditor,
|
||||
)
|
||||
dialog = AiConsultDialog(
|
||||
Repository(),
|
||||
PermissionSet(
|
||||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/kaifang"]
|
||||
),
|
||||
)
|
||||
dialog.diagnosis_id = 501
|
||||
dialog.patient_id = 301
|
||||
status = dialog._append_bubble("ai", "正在打开处方编辑器。", time_text="系统")
|
||||
detail = {
|
||||
"diagnosis": {
|
||||
"id": 501,
|
||||
"patient_id": 301,
|
||||
"patient_name": "尹山",
|
||||
"case_record": {},
|
||||
},
|
||||
"appointment": {"id": 801},
|
||||
}
|
||||
|
||||
dialog._open_prescription_editor_from_chat(
|
||||
dialog._generation,
|
||||
501,
|
||||
status,
|
||||
detail,
|
||||
)
|
||||
|
||||
assert created == []
|
||||
assert status.body is not None
|
||||
assert "已取消开方" in status.body.toPlainText()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_present_ai_consult_requires_diagnosis_id(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -294,9 +659,286 @@ def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -
|
||||
|
||||
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
|
||||
assert "肝郁脾虚证" in browser.toPlainText()
|
||||
assert "{\"diagnosis\"" not in browser.toPlainText()
|
||||
browser.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("business_id", "expected_title", "expected_marker"),
|
||||
[
|
||||
("patient_opened_chat", "患者进入在线问诊", "入"),
|
||||
("patient_closed_chat", "患者离开在线问诊", "离"),
|
||||
("doctor_entered_consult_room", "医生进入在线诊室", "医"),
|
||||
("consultation_complete", "本次问诊已完成", "完"),
|
||||
],
|
||||
)
|
||||
def test_custom_im_events_are_normalized_before_rendering(
|
||||
business_id: str,
|
||||
expected_title: str,
|
||||
expected_marker: str,
|
||||
) -> None:
|
||||
item = ai_consult_module._parse_chat_item(
|
||||
{
|
||||
"msg_type": "custom",
|
||||
"text": json.dumps(
|
||||
{
|
||||
"businessID": business_id,
|
||||
"patientId": "11173",
|
||||
"patientName": "尹山",
|
||||
"doctorId": "12",
|
||||
"time": 1787045982311,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"is_from_doctor": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert item is not None
|
||||
assert item.variant == "event"
|
||||
assert item.title == expected_title
|
||||
assert item.marker == expected_marker
|
||||
assert item.time_text
|
||||
assert "11173" not in item.detail
|
||||
assert "doctorId" not in item.detail
|
||||
|
||||
|
||||
def test_rtc_event_prefers_nested_command_and_formats_duration() -> None:
|
||||
payload = {
|
||||
"businessID": 1,
|
||||
"data": json.dumps(
|
||||
{
|
||||
"version": 4,
|
||||
"call_type": 2,
|
||||
"businessID": "rtc_call",
|
||||
"data": {"cmd": "hangup", "inviter": "doctor_12"},
|
||||
"call_end": 81,
|
||||
}
|
||||
),
|
||||
# A conflicting legacy action must not override the nested command.
|
||||
"actionType": 1,
|
||||
"inviteID": "sensitive-invite-id",
|
||||
}
|
||||
item = ai_consult_module._parse_chat_item(
|
||||
{
|
||||
"msg_type": "custom",
|
||||
"text": json.dumps(payload),
|
||||
"time": 1787103129,
|
||||
}
|
||||
)
|
||||
|
||||
assert item is not None
|
||||
assert item.variant == "event"
|
||||
assert item.title == "视频问诊已结束"
|
||||
assert item.detail == "通话 1 分 21 秒"
|
||||
assert "sensitive-invite-id" not in item.detail
|
||||
|
||||
|
||||
def test_transient_typing_event_is_not_added_to_history() -> None:
|
||||
item = ai_consult_module._parse_chat_item(
|
||||
{
|
||||
"msg_type": "custom",
|
||||
"text": '{"businessID":"user_typing_status","typing":true}',
|
||||
}
|
||||
)
|
||||
assert item is None
|
||||
|
||||
|
||||
def test_chat_archive_renders_system_timeline_without_raw_signals(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog._render_messages(
|
||||
[
|
||||
{
|
||||
"msg_type": "custom",
|
||||
"text": '{"businessID":"patient_opened_chat","patientName":"尹山","patientId":"11173"}',
|
||||
"time": 1787103040,
|
||||
},
|
||||
{
|
||||
"msg_type": "text",
|
||||
"text": "我已确认问诊信息无误。",
|
||||
"is_from_doctor": "0",
|
||||
"time": 1787103050,
|
||||
},
|
||||
]
|
||||
)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
event_titles = {
|
||||
label.text()
|
||||
for label in dialog.findChildren(QLabel)
|
||||
if label.objectName() == "AiConsultEventTitle"
|
||||
}
|
||||
bodies = [
|
||||
browser.toPlainText()
|
||||
for browser in dialog.findChildren(QTextBrowser)
|
||||
if browser.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
visible_copy = "\n".join([*event_titles, *bodies])
|
||||
|
||||
assert "患者进入在线问诊" in event_titles
|
||||
assert any("我已确认问诊信息无误" in text for text in bodies)
|
||||
assert "businessID" not in visible_copy
|
||||
assert "11173" not in visible_copy
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_archived_ai_clinical_reply_restores_the_structured_panel(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog._render_messages(
|
||||
[
|
||||
{
|
||||
"msg_type": "text",
|
||||
"role": "assistant",
|
||||
"text": LONG_CLINICAL_REPLY,
|
||||
"time": 1787103050,
|
||||
}
|
||||
]
|
||||
)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
panels = dialog.findChildren(QWidget, "AiConsultClinicalPanel")
|
||||
assert len(panels) == 1
|
||||
assert panels[0].isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_chat_layout_keeps_ai_answer_readable_in_compact_window(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.resize(1080, 680)
|
||||
dialog._append_bubble(
|
||||
"ai",
|
||||
"### 病情分析\n\n" + "需要结合病历与检查结果综合判断。" * 8,
|
||||
time_text="AI 分析",
|
||||
)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
ai_frames = [
|
||||
frame
|
||||
for frame in dialog.findChildren(ai_consult_module.QFrame)
|
||||
if frame.objectName() == "AiConsultBubbleAi"
|
||||
]
|
||||
assert ai_frames
|
||||
assert ai_frames[-1].width() >= dialog.chat_scroll.viewport().width() * 0.6
|
||||
assert dialog.input.height() <= 50
|
||||
assert dialog.input.parentWidget().height() <= 120
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_completed_clinical_reply_is_parsed_into_scan_first_sections() -> None:
|
||||
model = ai_consult_module._parse_clinical_analysis(LONG_CLINICAL_REPLY)
|
||||
|
||||
assert model is not None
|
||||
assert "脾肾两虚" in model.summary
|
||||
assert [item.label for item in model.evidence] == [
|
||||
"空腹血糖",
|
||||
"既往史",
|
||||
"主诉",
|
||||
"基本信息",
|
||||
]
|
||||
assert len(model.hypotheses) == 2
|
||||
assert len(model.gaps) == 3
|
||||
assert len(model.steps) == 4
|
||||
assert len(model.risks) == 2
|
||||
assert "不能替代执业医师" in model.disclaimer
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
"请继续补充舌象与脉象。",
|
||||
'{"businessID":"patient_opened_chat"}',
|
||||
"<p>普通 HTML 回复</p>",
|
||||
],
|
||||
)
|
||||
def test_short_or_machine_payloads_keep_the_standard_chat_bubble(payload: str) -> None:
|
||||
assert ai_consult_module._parse_clinical_analysis(payload) is None
|
||||
|
||||
|
||||
def test_completed_clinical_reply_switches_one_bubble_to_structured_panel(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
bubble = dialog._append_bubble("ai", LONG_CLINICAL_REPLY, time_text="千问 · 11:48")
|
||||
assert bubble.body is not None and not bubble.body.isHidden()
|
||||
|
||||
assert bubble.finalize_clinical_analysis()
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||||
assert panel is not None and panel.isVisible()
|
||||
assert bubble.body.isHidden()
|
||||
assert "可能证候分析" in bubble.body.toPlainText()
|
||||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultEvidenceCard")) == 4
|
||||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow")) == 3
|
||||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultWorkflowStep")) == 4
|
||||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultRiskCard")) == 1
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_structured_clinical_reply_has_no_horizontal_overflow_at_minimum_size(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.resize(1080, 680)
|
||||
bubble = dialog._append_bubble("ai", LONG_CLINICAL_REPLY, time_text="千问 · 11:48")
|
||||
assert bubble.finalize_clinical_analysis()
|
||||
dialog.show()
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||||
assert panel is not None
|
||||
assert dialog.chat_scroll.horizontalScrollBar().maximum() == 0
|
||||
assert panel.width() <= dialog.chat_scroll.viewport().width()
|
||||
assert panel.minimumSizeHint().width() <= dialog.chat_scroll.viewport().width()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_sidebar_replacement_hides_old_cards_immediately() -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog._set_key_facts([("病程", "2个月"), ("BMI", "22.1")])
|
||||
previous = [
|
||||
frame
|
||||
for frame in dialog.findChildren(ai_consult_module.QFrame)
|
||||
if frame.objectName() == "AiConsultKeyCard"
|
||||
]
|
||||
assert previous
|
||||
|
||||
dialog._set_key_facts([("病程", "3个月")])
|
||||
|
||||
assert all(frame.isHidden() for frame in previous)
|
||||
assert all(frame.parentWidget() is None for frame in previous)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
@@ -342,6 +984,51 @@ def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_long_stream_stays_markdown_until_done_then_switches_in_place(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.show()
|
||||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||||
bubble = dialog._stream_bubble
|
||||
assert bubble is not None
|
||||
generation = dialog._generation
|
||||
stream_generation = dialog._stream_generation
|
||||
midpoint = len(LONG_CLINICAL_REPLY) // 2
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": LONG_CLINICAL_REPLY[:midpoint]},
|
||||
)
|
||||
dialog._flush_timer.stop()
|
||||
dialog._flush_stream_chunks()
|
||||
application.processEvents()
|
||||
assert bubble.findChild(QWidget, "AiConsultClinicalPanel") is None
|
||||
assert bubble.body is not None and not bubble.body.isHidden()
|
||||
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "delta", "text": LONG_CLINICAL_REPLY[midpoint:]},
|
||||
)
|
||||
dialog._stream_event(
|
||||
generation,
|
||||
stream_generation,
|
||||
{"event": "done", "model_label": "千问"},
|
||||
)
|
||||
application.processEvents()
|
||||
|
||||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||||
assert panel is not None and panel.isVisible()
|
||||
assert bubble.body.toPlainText().startswith("基于本诊单资料")
|
||||
assert bubble.body.isHidden()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
@@ -407,3 +1094,200 @@ def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_i
|
||||
assert dialog._follow_chat
|
||||
assert bar.value() == bar.maximum()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_build_patient_ai_context_covers_videos_tongue_blood_sugar_and_reports() -> None:
|
||||
detail = {
|
||||
"diagnosis": {
|
||||
"fasting_blood_sugar": "6.8",
|
||||
"tongue": "舌淡红,苔薄白",
|
||||
"pulse": "弦细",
|
||||
},
|
||||
"tongue_images": ["a.jpg", "b.jpg"],
|
||||
}
|
||||
tracking = {
|
||||
"blood_sugar": {
|
||||
"entries": [
|
||||
{"date": "2026-08-18", "value": "7.2", "period": "空腹"},
|
||||
{"date": "2026-08-19", "value": "9.1", "period": "餐后"},
|
||||
],
|
||||
}
|
||||
}
|
||||
analysis = {"summary": "血糖控制欠佳", "risk_assessment": ["低血糖风险"]}
|
||||
prescriptions = [{"prescription_name": "逍遥散", "prescription_remark": "疏肝健脾"}]
|
||||
call_records = [
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"transcript_text": "患者:睡眠好转。医生:继续观察。",
|
||||
"start_time_text": "2026-08-20 09:10:00",
|
||||
},
|
||||
{
|
||||
"diagnosis_id": 999,
|
||||
"transcript_text": "其他诊单的文字不应混入",
|
||||
},
|
||||
]
|
||||
|
||||
text, labels = build_patient_ai_context(
|
||||
detail=detail,
|
||||
tracking=tracking,
|
||||
analysis=analysis,
|
||||
prescriptions=prescriptions,
|
||||
call_records=call_records,
|
||||
diagnosis_id=501,
|
||||
)
|
||||
|
||||
assert labels == ["每日血糖", "舌苔/脉象", "视频问诊文字", "历史AI报告", "处方记录"]
|
||||
assert text.startswith("【患者综合资料】")
|
||||
assert "【每日血糖】" in text and "7.2" in text and "9.1" in text
|
||||
assert "【舌苔/脉象】" in text and "舌淡红" in text and "舌苔图片 2 张" in text
|
||||
assert "【视频问诊文字】" in text and "睡眠好转" in text
|
||||
assert "其他诊单的文字不应混入" not in text
|
||||
assert "【历史AI报告】" in text and "血糖控制欠佳" in text
|
||||
assert "【处方记录】" in text and "逍遥散" in text
|
||||
assert len(text) <= 360
|
||||
|
||||
|
||||
def test_build_patient_ai_context_with_minimal_detail_returns_empty() -> None:
|
||||
text, labels = build_patient_ai_context(
|
||||
detail={"patient_name": "张三", "age": 45},
|
||||
diagnosis_id=1,
|
||||
)
|
||||
assert text == ""
|
||||
assert labels == []
|
||||
|
||||
text_none, labels_none = build_patient_ai_context(diagnosis_id=0)
|
||||
assert text_none == ""
|
||||
assert labels_none == []
|
||||
|
||||
|
||||
def test_compose_ai_prompt_truncates_long_question_within_limit() -> None:
|
||||
context = "【患者综合资料】\n【每日血糖】诊时6.8"
|
||||
long_question = "请详细分析血糖波动原因与调护建议:" * 30
|
||||
|
||||
composed = ai_consult_module._compose_ai_prompt(long_question, context)
|
||||
assert composed.startswith(context)
|
||||
assert "— 医生提问 —" in composed
|
||||
assert composed.endswith("…")
|
||||
assert len(composed) <= ai_consult_module.AI_PROMPT_LIMIT
|
||||
|
||||
short = ai_consult_module._compose_ai_prompt("睡眠如何?", context)
|
||||
assert short == f"{context}\n\n— 医生提问 —\n睡眠如何?"
|
||||
|
||||
assert ai_consult_module._compose_ai_prompt("", context) == ""
|
||||
assert ai_consult_module._compose_ai_prompt(" ", context) == ""
|
||||
assert ai_consult_module._compose_ai_prompt("问题内容", "") == "问题内容"
|
||||
|
||||
|
||||
def test_ai_consult_dialog_renders_patient_context_bubble(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
assert dialog._patient_ai_context.startswith("【患者综合资料】")
|
||||
assert "舌苔/脉象" in dialog._patient_ai_context_labels
|
||||
assert "视频问诊文字" in dialog._patient_ai_context_labels
|
||||
assert "睡眠比上周好一些" in dialog._patient_ai_context
|
||||
|
||||
bodies = [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
assert any("实际 AI 请求由服务端实时聚合全量纵向资料" in text for text in bodies)
|
||||
assert any("共 1 条视频问诊记录" in text for text in bodies)
|
||||
|
||||
toggles = [
|
||||
widget
|
||||
for widget in dialog.findChildren(QPushButton)
|
||||
if widget.objectName() == "AiConsultContextToggle"
|
||||
]
|
||||
assert toggles
|
||||
assert toggles[0].text() == "查看本地资料预览"
|
||||
|
||||
reveals = [
|
||||
widget
|
||||
for widget in dialog.findChildren(QLabel)
|
||||
if widget.objectName() == "AiConsultContextReveal"
|
||||
]
|
||||
assert reveals
|
||||
assert reveals[0].text() == dialog._patient_ai_context
|
||||
assert reveals[0].isHidden()
|
||||
|
||||
toggles[0].click()
|
||||
application.processEvents()
|
||||
assert reveals[0].isVisible()
|
||||
assert toggles[0].text() == "收起本地资料预览"
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ask_sends_only_question_and_relies_on_server_full_context(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
class FakeSignal:
|
||||
def connect(self, _slot: Any) -> None:
|
||||
return None
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
*,
|
||||
diagnosis_id: int,
|
||||
prompt: str,
|
||||
task: str,
|
||||
) -> None:
|
||||
captured.append(prompt)
|
||||
self.signals = SimpleNamespace(
|
||||
event=FakeSignal(),
|
||||
error=FakeSignal(),
|
||||
finished=FakeSignal(),
|
||||
)
|
||||
|
||||
def cancel(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ai_consult_module, "_AiStreamWorker", FakeWorker)
|
||||
monkeypatch.setattr(
|
||||
ai_consult_module,
|
||||
"QThreadPool",
|
||||
SimpleNamespace(
|
||||
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
|
||||
),
|
||||
)
|
||||
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
assert dialog._patient_ai_context
|
||||
|
||||
dialog._ask("请结合资料分析当前证候")
|
||||
assert captured, "AI stream worker should have been started with a prompt"
|
||||
|
||||
prompt = captured[0]
|
||||
assert prompt == "请结合资料分析当前证候"
|
||||
assert dialog._patient_ai_context not in prompt
|
||||
assert len(prompt) <= ai_consult_module.AI_PROMPT_LIMIT
|
||||
|
||||
all_bubble_texts = [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
||||
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
||||
dialog.close()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Repository contracts for privacy-safe AI patient diagnosis options."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from inspect import signature
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import DoctorRepository, RemoteDoctorRepository
|
||||
|
||||
|
||||
class AiPatientOptionsClient:
|
||||
"""Record the exact AI option request and return deliberately unsafe extras."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
self.calls.append((endpoint, dict(params or {})))
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"diagnosis_id": 9001,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "测试患者",
|
||||
"phone_masked": "13800138000",
|
||||
"phone": "13800138000",
|
||||
"id_card": "110101199001011234",
|
||||
"gender": 2,
|
||||
"age": 36,
|
||||
"diagnosis_date": "2026-08-19",
|
||||
"diagnosis_summary": "随访诊单",
|
||||
"last_visit_at": "2026-08-19 09:30:00",
|
||||
"next_appointment_at": "2026-08-26 09:30:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_protocol_exposes_ai_patient_option_page_defaults() -> None:
|
||||
method = signature(DoctorRepository.list_ai_patient_options)
|
||||
|
||||
assert method.parameters["page_no"].default == 1
|
||||
assert method.parameters["page_size"].default == 20
|
||||
assert method.parameters["keyword"].default == ""
|
||||
|
||||
|
||||
def test_remote_ai_patient_options_use_exact_endpoint_params_and_safe_dto() -> None:
|
||||
client = AiPatientOptionsClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
page = repository.list_ai_patient_options(
|
||||
page_no=3,
|
||||
page_size=7,
|
||||
keyword=" 测试 ",
|
||||
)
|
||||
|
||||
assert client.calls == [
|
||||
(
|
||||
"tcm.diagnosis/aiPatientOptions",
|
||||
{"keyword": "测试", "page_no": 3, "page_size": 7},
|
||||
)
|
||||
]
|
||||
assert page.total == 1
|
||||
assert page.page_no == 3
|
||||
assert page.page_size == 7
|
||||
assert page.items == [
|
||||
{
|
||||
"diagnosis_id": 9001,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "测试患者",
|
||||
"phone_masked": "138****8000",
|
||||
"gender": 2,
|
||||
"age": 36,
|
||||
"diagnosis_date": "2026-08-19",
|
||||
"diagnosis_summary": "随访诊单",
|
||||
"last_visit_at": "2026-08-19 09:30:00",
|
||||
"next_appointment_at": "2026-08-26 09:30:00",
|
||||
}
|
||||
]
|
||||
assert page.items[0]["diagnosis_id"] != page.items[0]["source_patient_id"]
|
||||
assert "phone" not in page.items[0]
|
||||
assert "id_card" not in page.items[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("page_no", "page_size"), [(0, 20), (1, 0)])
|
||||
def test_remote_ai_patient_options_reject_invalid_pagination_before_get(
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
client = AiPatientOptionsClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match="must be positive"):
|
||||
repository.list_ai_patient_options(page_no=page_no, page_size=page_size)
|
||||
|
||||
assert client.calls == []
|
||||
|
||||
|
||||
def test_demo_ai_patient_options_are_stable_searchable_paginated_and_private() -> None:
|
||||
repository = DemoDoctorRepository(today=date(2026, 8, 20))
|
||||
|
||||
first = repository.list_ai_patient_options(page_no=1, page_size=2)
|
||||
second = repository.list_ai_patient_options(page_no=2, page_size=2)
|
||||
repeated = repository.list_ai_patient_options(page_no=1, page_size=2)
|
||||
|
||||
assert first.total == 4
|
||||
assert first.pages == 2
|
||||
assert [row["diagnosis_id"] for row in first.items] == [504, 503]
|
||||
assert [row["diagnosis_id"] for row in second.items] == [502, 501]
|
||||
assert repeated.items == first.items
|
||||
assert all(row["diagnosis_id"] != row["source_patient_id"] for row in first.items)
|
||||
assert all("****" in row["phone_masked"] for row in first.items)
|
||||
assert all("phone" not in row and "id_card" not in row for row in first.items)
|
||||
assert "13700006618" not in repr(first.items)
|
||||
|
||||
by_diagnosis = repository.list_ai_patient_options(keyword=" 503 ")
|
||||
by_plain_phone = repository.list_ai_patient_options(keyword="15900007732")
|
||||
|
||||
assert [row["diagnosis_id"] for row in by_diagnosis.items] == [503]
|
||||
assert [row["diagnosis_id"] for row in by_plain_phone.items] == [503]
|
||||
assert by_plain_phone.items[0]["phone_masked"] == "159****7732"
|
||||
assert "15900007732" not in repr(by_plain_phone.items)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Desktop auto-update check, download and payload discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.app_update import (
|
||||
AppUpdateError,
|
||||
compare_version,
|
||||
discover_payload,
|
||||
download_package,
|
||||
fetch_update_offer,
|
||||
normalize_version,
|
||||
parse_update_offer,
|
||||
safe_extract_zip,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_and_compare_versions() -> None:
|
||||
assert normalize_version("0.2") == "0.2.0"
|
||||
assert normalize_version("1.2.3.4") == "1.2.3"
|
||||
assert normalize_version("nope") == ""
|
||||
assert compare_version("0.1.0", "0.2.0") < 0
|
||||
assert compare_version("0.2.0", "0.2.0") == 0
|
||||
assert compare_version("1.0.0", "0.9.9") > 0
|
||||
|
||||
|
||||
def test_parse_offer_requires_hash_before_install() -> None:
|
||||
offer = parse_update_offer(
|
||||
{
|
||||
"has_update": True,
|
||||
"force": True,
|
||||
"enabled": True,
|
||||
"latest_version": "0.2.0",
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/app.zip",
|
||||
"sha256": "",
|
||||
"size": 12,
|
||||
"filename": "app.zip",
|
||||
},
|
||||
"can_install": True,
|
||||
},
|
||||
current_version="0.1.0",
|
||||
)
|
||||
assert offer.has_update is True
|
||||
assert offer.can_install is False
|
||||
assert offer.force is False
|
||||
assert offer.package is None
|
||||
|
||||
|
||||
def test_fetch_update_offer_uses_check_endpoint() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"code": 1,
|
||||
"data": {
|
||||
"has_update": True,
|
||||
"force": True,
|
||||
"enabled": True,
|
||||
"latest_version": "0.2.0",
|
||||
"title": "医生工作站 0.2.0",
|
||||
"notes": "修复登录",
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation.zip",
|
||||
"sha256": "a" * 64,
|
||||
"size": 2048,
|
||||
"filename": "DoctorWorkstation.zip",
|
||||
},
|
||||
"can_install": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
|
||||
offer = fetch_update_offer(
|
||||
client,
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
|
||||
assert offer.has_update is True
|
||||
assert offer.force is True
|
||||
assert offer.can_install is True
|
||||
assert offer.package is not None
|
||||
assert "setting.desktop_workstation/check" in str(requests[0].url)
|
||||
assert "current_version=0.1.0" in str(requests[0].url)
|
||||
assert "platform=windows" in str(requests[0].url)
|
||||
|
||||
|
||||
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "evil.zip"
|
||||
with zipfile.ZipFile(archive, "w") as bundle:
|
||||
bundle.writestr("../outside.txt", "nope")
|
||||
with pytest.raises(AppUpdateError, match="非法路径"):
|
||||
safe_extract_zip(archive, tmp_path / "out")
|
||||
|
||||
|
||||
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
|
||||
wrapped = tmp_path / "DoctorWorkstation"
|
||||
wrapped.mkdir()
|
||||
(wrapped / "_internal").mkdir()
|
||||
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
|
||||
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
|
||||
assert discover_payload(tmp_path, platform_name="windows") == wrapped
|
||||
|
||||
|
||||
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
|
||||
app = tmp_path / "DoctorWorkstation.app"
|
||||
macos = app / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
|
||||
assert discover_payload(tmp_path, platform_name="macos") == app
|
||||
|
||||
|
||||
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
|
||||
payload = b"doctor-workstation-zip"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
progress: list[tuple[int, int]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
del request
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=payload,
|
||||
headers={"content-length": str(len(payload))},
|
||||
)
|
||||
|
||||
destination = tmp_path / "pkg.zip"
|
||||
download_package(
|
||||
"https://cdn.example.com/pkg.zip",
|
||||
destination,
|
||||
sha256=digest,
|
||||
progress=lambda received, total: progress.append((received, total)),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
assert destination.read_bytes() == payload
|
||||
assert progress[-1][0] == len(payload)
|
||||
|
||||
|
||||
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
del request
|
||||
return httpx.Response(200, content=b"tampered")
|
||||
|
||||
destination = tmp_path / "pkg.zip"
|
||||
with pytest.raises(AppUpdateError, match="校验失败"):
|
||||
download_package(
|
||||
"https://cdn.example.com/pkg.zip",
|
||||
destination,
|
||||
sha256="b" * 64,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
assert not destination.exists()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Update dialog contract for optional and forced desktop upgrades."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.app_update import UpdateOffer, UpdatePackage
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
|
||||
package = (
|
||||
UpdatePackage(
|
||||
url="https://cdn.example.com/DoctorWorkstation.zip",
|
||||
sha256="a" * 64,
|
||||
size=1024,
|
||||
filename="DoctorWorkstation.zip",
|
||||
)
|
||||
if can_install
|
||||
else None
|
||||
)
|
||||
return UpdateOffer(
|
||||
has_update=True,
|
||||
force=force,
|
||||
enabled=True,
|
||||
current_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
min_version="",
|
||||
title="医生工作站 0.2.0",
|
||||
notes="修复若干问题",
|
||||
platform="windows",
|
||||
arch="x64",
|
||||
package=package,
|
||||
can_install=can_install,
|
||||
)
|
||||
|
||||
|
||||
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
dialog = AppUpdateDialog(_offer(force=False))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
assert dialog.later_button.isVisible()
|
||||
assert dialog.update_button.text() == "立即更新"
|
||||
assert dialog.notes.toPlainText() == "修复若干问题"
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_forced_update_dialog_hides_defer_and_blocks_escape(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
assert not dialog.later_button.isVisible()
|
||||
assert "必须更新" in dialog.badge.text()
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
dialog.offer = _offer(force=False)
|
||||
dialog._busy = False
|
||||
dialog.close()
|
||||
@@ -886,7 +886,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
application.processEvents()
|
||||
|
||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||
assert page.header.height() == 26
|
||||
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
|
||||
assert page.header.height() == 62
|
||||
assert page.filter_panel.height() <= 84
|
||||
assert all(60 <= height <= 66 for height in heights)
|
||||
assert page.table.viewport().height() // max(heights) >= 4
|
||||
|
||||
@@ -282,12 +282,15 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
assert model.hover_row == 1
|
||||
second_fixed_cell.hovered_row.emit(-1)
|
||||
assert model.hover_row == -1
|
||||
direct_links = {
|
||||
# 行内只保留“看诊单 / 开方”这两个闭环主操作,其余操作一律降级进“更多”,
|
||||
# 保证每一行的操作列宽度一致、数据列不再被挤成省略号。
|
||||
direct_links = [
|
||||
button.text() for button in action_cell.findChildren(QToolButton) if button.menu() is None
|
||||
}
|
||||
assert {"查看", "诊单", "开方", "预约", "补全身份证"} <= direct_links
|
||||
]
|
||||
assert direct_links == ["查看", "诊单"]
|
||||
more = next(button for button in action_cell.findChildren(QToolButton) if button.menu())
|
||||
menu_texts = [action.text() for action in more.menu().actions() if not action.isSeparator()]
|
||||
assert menu_texts[:4] == ["开方", "AI 分析", "预约", "补全身份证"]
|
||||
assert "指派" in menu_texts
|
||||
assert "取消挂号" in menu_texts
|
||||
assert {"视频二维码", "二维码", "挂号日志", "创建订单"}.isdisjoint(menu_texts)
|
||||
@@ -452,7 +455,12 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
page.table_host.set_rows([record])
|
||||
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11))
|
||||
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
|
||||
# 前三项是从行内降级下来的次要操作,其后才是本就属于“更多”的能力项。
|
||||
assert [action.text() for action in more.menu().actions() if not action.isSeparator()] == [
|
||||
"开方",
|
||||
"AI 分析",
|
||||
"预约",
|
||||
"补全身份证",
|
||||
"指派",
|
||||
"取消指派",
|
||||
"视频二维码",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for friendly_error's handling of AI upstream error messages.
|
||||
|
||||
These cover the DifyChatService error_code → user-facing copy mapping that
|
||||
the doctor workstation must apply when the server-side AI assistant returns
|
||||
``ok=false`` with a Chinese ``error`` string. The legacy behaviour simply
|
||||
echoed the raw text, which made incidents like ``UPSTREAM_REJECTED`` opaque
|
||||
to clinicians.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiBusinessError,
|
||||
ApiHttpError,
|
||||
ApiProtocolError,
|
||||
ApiTimeoutError,
|
||||
ApiTransportError,
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import _ai_upstream_hint, friendly_error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected_fragment"),
|
||||
[
|
||||
# 当上游 Dify 服务 HTTP 4xx 时返回的"UPSTREAM_REJECTED"文案。
|
||||
("模型未能处理本次请求", "稍后重试"),
|
||||
("AI 服务凭据无效或无权限", "联系管理员"),
|
||||
("AI 服务配置无效", "联系管理员"),
|
||||
("AI 报告功能未启用", "联系管理员"),
|
||||
("不支持的 AI 模型", "联系管理员"),
|
||||
("该模型服务尚未完整配置", "联系管理员"),
|
||||
("病例数据编码失败", "联系管理员"),
|
||||
("无法初始化 AI 请求", "联系管理员"),
|
||||
("暂时无法连接 AI 服务,请稍后重试", "网络"),
|
||||
("模型服务繁忙,请稍后重试", "稍后重试"),
|
||||
("模型响应超时,请稍后重试", "稍后重试"),
|
||||
("AI 助手未返回内容,请重试", "稍后重试"),
|
||||
],
|
||||
)
|
||||
def test_friendly_error_translates_ai_upstream_strings(raw: str, expected_fragment: str) -> None:
|
||||
"""Upstream Dify messages should be replaced with actionable copy."""
|
||||
|
||||
rendered = friendly_error(raw)
|
||||
assert expected_fragment in rendered
|
||||
# 上游原文不应该再原样透传。
|
||||
assert rendered != raw
|
||||
|
||||
|
||||
def test_friendly_error_passes_through_unrelated_chinese_text() -> None:
|
||||
"""中文业务文案不属于上游错误时,必须原样透传,避免误伤。"""
|
||||
|
||||
text = "AI 返回的处方草稿格式不符合要求,请重试"
|
||||
assert friendly_error(text) == text
|
||||
|
||||
|
||||
def test_friendly_error_handles_api_business_error_with_upstream_payload() -> None:
|
||||
"""服务端通过 ApiBusinessError(code=0) 透传时仍要触发映射。"""
|
||||
|
||||
err = ApiBusinessError("模型未能处理本次请求", code=0)
|
||||
rendered = friendly_error(err)
|
||||
assert "稍后重试" in rendered
|
||||
assert "联系管理员" in rendered
|
||||
|
||||
|
||||
def test_friendly_error_keeps_existing_transport_mappings() -> None:
|
||||
"""对网络/超时/未授权等既有规则的回归保护。"""
|
||||
|
||||
assert "证书" in friendly_error(Exception("certificate_verify_failed"))
|
||||
assert "网络" in friendly_error(ApiTransportError("connection refused"))
|
||||
assert "重新登录" in friendly_error(AuthenticationExpiredError("expired"))
|
||||
timeout_render = friendly_error(ApiTimeoutError("timed out"))
|
||||
assert "超时" in timeout_render
|
||||
http_render = friendly_error(ApiHttpError("boom", status_code=503))
|
||||
assert "503" in http_render
|
||||
protocol_render = friendly_error(ApiProtocolError("api response envelope invalid"))
|
||||
assert "数据格式" in protocol_render
|
||||
|
||||
|
||||
def test_friendly_error_returns_default_for_empty_string() -> None:
|
||||
"""Fallback 应当落到"操作未完成"而不是崩溃。"""
|
||||
|
||||
assert friendly_error(SimpleNamespace(__str__=lambda self: " ")) == "操作未完成,请稍后重试。"
|
||||
|
||||
|
||||
def test_ai_upstream_hint_returns_none_for_unrelated_text() -> None:
|
||||
assert _ai_upstream_hint("AI 返回的处方草稿格式不符合要求,请重试") is None
|
||||
assert _ai_upstream_hint("connection refused") is None
|
||||
assert _ai_upstream_hint("") is None
|
||||
@@ -669,13 +669,13 @@ def test_patient_list_reference_geometry_and_row_actions(
|
||||
assert workspace.content_stack.minimumHeight() == 0
|
||||
assert workspace.table.minimumHeight() == 0
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
assert workspace.table.viewport().height() // 40 >= 6
|
||||
assert workspace.table.viewport().height() // 36 >= 6
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
if workspace.table.rowCount():
|
||||
assert workspace.table.rowHeight(0) == 40
|
||||
assert workspace.table.rowHeight(0) == 36
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
page.close()
|
||||
|
||||
@@ -10,7 +10,14 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
@@ -201,6 +208,20 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and 84 <= filters.height() <= 92
|
||||
actions_host = page.table.cellWidget(0, 2)
|
||||
assert actions_host is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in actions_host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
|
||||
edit_bottom_right = row_edit.mapTo(
|
||||
page.table.viewport(), row_edit.rect().bottomRight()
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert page.table.viewport().rect().contains(edit_top_left)
|
||||
assert page.table.viewport().rect().contains(edit_bottom_right)
|
||||
else:
|
||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||
assert filters is not None
|
||||
|
||||
@@ -303,10 +303,17 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
||||
page._selection_changed()
|
||||
|
||||
assert page.table.columnCount() == 11
|
||||
assert page.table.horizontalHeaderItem(10).text() == "操作"
|
||||
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.table.cellWidget(0, 2) is not None
|
||||
assert page.table.cellWidget(0, 5) is not None
|
||||
assert page.table.cellWidget(0, 10) is not None
|
||||
assert page.table.cellWidget(0, 3) is not None
|
||||
assert page.table.cellWidget(0, 6) is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert row_edit.isEnabled()
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
@@ -343,6 +350,88 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_issued_row_edit_targets_clicked_prescription_without_checkbox(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
requested: list[int] = []
|
||||
opened: list[dict[str, Any]] = []
|
||||
callbacks: list[str] = []
|
||||
rows = [
|
||||
{
|
||||
"id": 11,
|
||||
"sn": "CF-11",
|
||||
"patient_name": "患者甲",
|
||||
"audit_status": 0,
|
||||
"void_status": 0,
|
||||
"creator_id": 7,
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"sn": "CF-22",
|
||||
"patient_name": "患者乙",
|
||||
"audit_status": 0,
|
||||
"void_status": 0,
|
||||
"creator_id": 7,
|
||||
},
|
||||
]
|
||||
|
||||
class Repository:
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": rows, "count": len(rows)}
|
||||
|
||||
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
|
||||
requested.append(prescription_id)
|
||||
return {
|
||||
**next(row for row in rows if row["id"] == prescription_id),
|
||||
"clinical_diagnosis": "脾气虚",
|
||||
}
|
||||
|
||||
page = PrescriptionsPage(
|
||||
Repository(),
|
||||
PermissionSet(["cf.prescription/edit"]),
|
||||
SimpleNamespace(id=7, name="周医生"),
|
||||
)
|
||||
page.refresh()
|
||||
page._open_editor = lambda detail: opened.append(detail) # type: ignore[method-assign]
|
||||
|
||||
assert page.table.current_data()["id"] == 11
|
||||
assert all(
|
||||
page.table.item(row_index, 0).checkState() == Qt.CheckState.Unchecked
|
||||
for row_index in range(page.table.rowCount())
|
||||
)
|
||||
second_actions = page.table.cellWidget(1, 2)
|
||||
assert second_actions is not None
|
||||
second_edit = next(
|
||||
button
|
||||
for button in second_actions.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
|
||||
second_edit.click()
|
||||
application.processEvents()
|
||||
|
||||
assert requested == [22]
|
||||
assert [detail["id"] for detail in opened] == [22]
|
||||
assert page.table.current_data()["id"] == 22
|
||||
assert all(
|
||||
page.table.item(row_index, 0).checkState() == Qt.CheckState.Unchecked
|
||||
for row_index in range(page.table.rowCount())
|
||||
)
|
||||
|
||||
page._run_row_action({"id": 999}, lambda: callbacks.append("stale"))
|
||||
assert callbacks == []
|
||||
page._set_mutation_pending(True)
|
||||
assert not second_edit.isEnabled()
|
||||
page._set_mutation_pending(False)
|
||||
assert second_edit.isEnabled()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_editor_builds_complete_add_payload(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
@@ -109,8 +109,9 @@ def test_appointments_navigation_is_named_reception_and_always_first() -> None:
|
||||
demo_mode=False,
|
||||
)
|
||||
|
||||
# 挂号与诊单是两条队列,侧边栏此前两项同名。现在与服务端菜单的“挂号列表”一致。
|
||||
assert [(item.key, title) for item, title in resolved] == [
|
||||
("appointments", "问诊列表"),
|
||||
("appointments", "挂号列表"),
|
||||
("patients", "我的患者"),
|
||||
]
|
||||
|
||||
@@ -123,7 +124,7 @@ def shell_window(
|
||||
navigation = [
|
||||
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
|
||||
for key, title, glyph, permission in (
|
||||
("appointments", "问诊列表", "号", "doctor.appointment/lists"),
|
||||
("appointments", "挂号列表", "号", "doctor.appointment/lists"),
|
||||
("reception", "接诊台", "◎", "doctor.appointment/lists"),
|
||||
(
|
||||
"prescription_library",
|
||||
@@ -262,52 +263,93 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||
assert "在线" in shell_window.assistant_status.text()
|
||||
|
||||
|
||||
def test_shell_ai_entry_opens_the_current_selected_diagnosis(
|
||||
def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
current = shell_window.pages["appointments"]
|
||||
assert isinstance(current, _ShellPageDouble)
|
||||
current.ai_context_available = True
|
||||
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"select_and_present_ai_consult",
|
||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||
)
|
||||
|
||||
shell_window.assistant_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert current.ai_open_count == 1
|
||||
assert current.ai_open_count == 0
|
||||
assert len(opened) == 1
|
||||
assert opened[0][0][0] is shell_window.repository
|
||||
assert opened[0][0][2] is shell_window
|
||||
assert shell_window.stack.currentWidget() is current
|
||||
|
||||
|
||||
def test_shell_ai_entry_falls_back_to_reception_and_opens_its_selection(
|
||||
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
appointments = shell_window.pages["appointments"]
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(appointments, _ShellPageDouble)
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
reception.ai_context_available = True
|
||||
opened: list[dict[str, Any]] = []
|
||||
|
||||
def open_picker(repository: Any, permissions: Any, parent: Any, **kwargs: Any) -> bool:
|
||||
opened.append(
|
||||
{
|
||||
"repository": repository,
|
||||
"permissions": permissions,
|
||||
"parent": parent,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(shell_module, "select_and_present_ai_consult", open_picker)
|
||||
shell_window.global_search.setText("张医生")
|
||||
|
||||
shell_window.ai_top_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert appointments.ai_open_count == 1
|
||||
assert reception.ai_open_count == 1
|
||||
assert shell_window.stack.currentWidget() is reception
|
||||
assert appointments.ai_open_count == 0
|
||||
assert reception.ai_open_count == 0
|
||||
assert shell_window.stack.currentWidget() is appointments
|
||||
assert opened == [
|
||||
{
|
||||
"repository": shell_window.repository,
|
||||
"permissions": shell_window.permissions,
|
||||
"parent": shell_window,
|
||||
"initial_query": "张医生",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_shell_ai_entry_on_reception_opens_chat_instead_of_noop(
|
||||
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
assert shell_window.navigate("reception")
|
||||
reception.ai_context_available = True
|
||||
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"select_and_present_ai_consult",
|
||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||
)
|
||||
|
||||
shell_window.ai_top_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert reception.ai_open_count == 1
|
||||
assert reception.ai_open_count == 0
|
||||
assert len(opened) == 1
|
||||
assert shell_window.stack.currentWidget() is reception
|
||||
|
||||
|
||||
@@ -346,7 +388,7 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_ai_entry_reports_when_reception_is_not_available(
|
||||
def test_shell_ai_entry_does_not_require_reception_page(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -366,11 +408,11 @@ def test_shell_ai_entry_reports_when_reception_is_not_available(
|
||||
(item, item.title) for item in navigation
|
||||
],
|
||||
)
|
||||
messages: list[str] = []
|
||||
opened: list[Any] = []
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"show_toast",
|
||||
lambda _parent, message, *_args, **_kwargs: messages.append(message),
|
||||
"select_and_present_ai_consult",
|
||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||
)
|
||||
window = ShellWindow(
|
||||
object(),
|
||||
@@ -386,8 +428,9 @@ def test_shell_ai_entry_reports_when_reception_is_not_available(
|
||||
window.assistant_button.click()
|
||||
application.processEvents()
|
||||
|
||||
assert any("没有可用的接诊台" in message for message in messages)
|
||||
assert all("已进入接诊台" not in message for message in messages)
|
||||
assert len(opened) == 1
|
||||
assert opened[0][0][0] is window.repository
|
||||
assert opened[0][0][2] is window
|
||||
assert window.stack.currentWidget() is window.pages["appointments"]
|
||||
|
||||
window.close()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from doctor_workstation.ui.widgets import format_record_time
|
||||
|
||||
|
||||
def test_format_record_time_with_unix_seconds_returns_minute_precision() -> None:
|
||||
stamp = 1787119871
|
||||
expected = datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M")
|
||||
assert format_record_time(stamp) == expected
|
||||
assert format_record_time(str(stamp)) == expected
|
||||
assert format_record_time(float(stamp)) == expected
|
||||
|
||||
|
||||
def test_format_record_time_with_unix_milliseconds_returns_minute_precision() -> None:
|
||||
stamp_ms = 1_787_119_871_234
|
||||
expected = datetime.fromtimestamp(stamp_ms / 1000).strftime("%Y-%m-%d %H:%M")
|
||||
assert format_record_time(stamp_ms) == expected
|
||||
assert format_record_time(str(stamp_ms)) == expected
|
||||
assert format_record_time(float(stamp_ms)) == expected
|
||||
|
||||
|
||||
def test_format_record_time_with_iso_string_truncates_to_minute() -> None:
|
||||
assert format_record_time("2026-08-20T17:31:11Z") == "2026-08-20 17:31"
|
||||
assert format_record_time("2026-08-20 17:31:11") == "2026-08-20 17:31"
|
||||
|
||||
|
||||
def test_format_record_time_with_preformatted_string_passes_through_when_short() -> None:
|
||||
assert format_record_time("—") == "—"
|
||||
assert format_record_time("刚刚") == "刚刚"
|
||||
|
||||
|
||||
def test_format_record_time_with_blank_or_none_returns_default() -> None:
|
||||
assert format_record_time(None) == "—"
|
||||
assert format_record_time("") == "—"
|
||||
assert format_record_time(" ") == "—"
|
||||
assert format_record_time(None, default="?") == "?"
|
||||
|
||||
|
||||
def test_format_record_time_with_datetime_returns_minute_precision() -> None:
|
||||
assert format_record_time(datetime(2026, 8, 20, 17, 31, 11)) == "2026-08-20 17:31"
|
||||
assert format_record_time(date(2026, 8, 20)) == "2026-08-20"
|
||||
|
||||
|
||||
def test_format_record_time_with_garbage_returns_raw_value() -> None:
|
||||
assert format_record_time("not-a-timestamp") == "not-a-timestamp"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user