This commit is contained in:
Your Name
2026-08-22 08:51:35 +08:00
parent 6c444a4a04
commit c06d293424
69 changed files with 11431 additions and 1601 deletions
+260
View File
@@ -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
+885 -1
View File
@@ -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)
+164
View File
@@ -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()
+70
View File
@@ -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()
+2 -1
View File
@@ -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
+11 -3
View File
@@ -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 分析",
"预约",
"补全身份证",
"指派",
"取消指派",
"视频二维码",
+93
View File
@@ -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
+2 -2
View File
@@ -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()
+22 -1
View File
@@ -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
+92 -3
View File
@@ -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,
+60 -17
View File
@@ -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()
+52
View File
@@ -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"