This commit is contained in:
Your Name
2026-08-18 14:08:38 +08:00
parent 8b9df1154c
commit bc1228a310
77 changed files with 10763 additions and 1181 deletions
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
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,
can_open_ai_consult,
present_ai_consult,
render_chat_payload,
)
from doctor_workstation.ui.pages.appointments import AppointmentsPage
from doctor_workstation.ui.pages.consultations import ConsultationsPage
from doctor_workstation.ui.pages.patients import PatientListWorkspace
from doctor_workstation.ui.pages.reception import ReceptionPage
@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(ai_consult_module, "run_async", run_immediately)
def test_ai_consult_dialog_matches_workspace_chrome(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"]))
dialog.open_for(
diagnosis_id=501,
patient_id=301,
seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"},
source_title="问诊列表",
)
dialog.show()
application.processEvents()
labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()]
assert "问诊详情" in labels
assert "AI 助手" in labels
assert "智能分析" in labels
assert "快捷工具" in labels
assert "对话建议" in labels
assert dialog.tabs.tabText(0) == "问诊对话"
assert dialog.send_button.objectName() == "AiConsultSend"
dialog.close()
def test_present_ai_consult_requires_diagnosis_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[int] = []
monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id))
present_ai_consult(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
None,
diagnosis_id=0,
)
assert opened == []
present_ai_consult(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
None,
diagnosis_id=501,
seed={"patient_name": "杨永"},
)
assert opened == [501]
def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None:
repository = DemoDoctorRepository()
allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"])
assert can_open_ai_consult(allowed)
patients = PatientListWorkspace(repository, allowed)
patients.show()
application.processEvents()
assert patients.ai_consult_button.text() == "AI 分析"
assert not patients.ai_consult_button.isHidden()
reception = ReceptionPage(repository, allowed)
reception.show()
application.processEvents()
menu_titles = [action.text() for action in reception.more_button.menu().actions()]
assert "AI 分析" in menu_titles
assert reception.ai_consult_button.text() == "AI 分析"
appointments = AppointmentsPage(repository, permissions=allowed)
appointments.show()
application.processEvents()
assert appointments.toolbar_ai_consult_button.text() == "AI 分析"
assert not appointments.toolbar_ai_consult_button.isHidden()
consultations = ConsultationsPage(repository, permissions=allowed)
assert consultations.table_host.action_policy.get("ai_consult") is True
patients.close()
reception.close()
appointments.close()
consultations.close()
def test_ai_consult_sidebar_loads_patient_facts_and_reports(
application: QApplication,
immediate_async: None,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"})
dialog.show()
application.processEvents()
values = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultKeyValue"
}
assert "22.1" in values
assert any("病程" in text or "3" in text for text in values)
titles = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultRecordTitle"
}
assert "血糖控制评估" in titles
assert "并发症风险评估" in titles
bodies = [
widget.toPlainText()
for widget in dialog.findChildren(QTextBrowser)
if widget.objectName() == "AiConsultBubbleText"
]
assert any("病情与证候分析" in text for text in bodies)
assert any("###" not in text for text in bodies if "病情与证候分析" in text)
dialog.close()
def test_ai_consult_sidebar_survives_chat_archive_errors(
application: QApplication,
immediate_async: None,
) -> None:
class BrokenChatRepository(DemoDoctorRepository):
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True):
raise RuntimeError("archive unavailable")
dialog = AiConsultDialog(
BrokenChatRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.open_for(diagnosis_id=501, patient_id=301)
dialog.show()
application.processEvents()
values = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultKeyValue"
}
titles = {
widget.text()
for widget in dialog.findChildren(QLabel)
if widget.objectName() == "AiConsultRecordTitle"
}
assert "22.1" in values
assert "血糖控制评估" in titles
dialog.close()
def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None:
browser = QTextBrowser()
render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干")
assert "病情摘要" in browser.toPlainText()
assert "核心病机" in browser.toPlainText()
assert "###" not in browser.toPlainText()
assert "<h3" in browser.toHtml().lower()
render_chat_payload(browser, "<p>空腹血糖 <strong>6.8</strong></p>")
assert "空腹血糖" in browser.toPlainText()
assert "6.8" in browser.toPlainText()
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
assert "肝郁脾虚证" in browser.toPlainText()
browser.deleteLater()
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.show()
dialog._stream_bubble = dialog._append_bubble("ai", "")
bubble = dialog._stream_bubble
generation = dialog._generation
stream_generation = dialog._stream_generation
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "第一段"},
)
dialog._flush_timer.stop()
dialog._flush_stream_chunks()
application.processEvents()
assert bubble is not None and bubble.body is not None
assert bubble.body.toPlainText() == "第一段"
ai_bubble_count = len(
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
)
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "第二段"},
)
dialog._stream_event(
generation,
stream_generation,
{"event": "done", "model_label": "千问"},
)
application.processEvents()
assert bubble.body.toPlainText() == "第一段第二段"
assert len(
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
) == ai_bubble_count
dialog.close()
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.show()
dialog._stream_bubble = dialog._append_bubble("ai", "")
bubble = dialog._stream_bubble
generation = dialog._generation
stream_generation = dialog._stream_generation
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "已生成"},
)
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
application.processEvents()
assert bubble is not None and bubble.body is not None
assert "已生成" in bubble.body.toPlainText()
assert "模型繁忙" in bubble.body.toPlainText()
before_cancel = bubble.body.toPlainText()
dialog.close()
application.processEvents()
dialog._stream_event(
generation,
stream_generation,
{"event": "delta", "text": "迟到内容"},
)
application.processEvents()
assert bubble.body.toPlainText() == before_cancel
def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it(
application: QApplication,
) -> None:
dialog = AiConsultDialog(
DemoDoctorRepository(),
PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
dialog.diagnosis_id = 501
dialog.show()
for index in range(28):
dialog._append_bubble("ai", f"历史消息 {index}" + "辨证内容" * 16)
application.processEvents()
bar = dialog.chat_scroll.verticalScrollBar()
bar.setValue(bar.maximum())
application.processEvents()
assert dialog._follow_chat
bar.setValue(max(0, bar.maximum() // 3))
application.processEvents()
reading_position = bar.value()
assert not dialog._follow_chat
dialog._append_bubble("ai", "新的流式内容" * 20)
application.processEvents()
assert bar.value() == reading_position
dialog._ask("请继续分析")
application.processEvents()
assert dialog._follow_chat
assert bar.value() == bar.maximum()
dialog.close()