Files
zyt/app/tests/test_ai_consult_ui.py
T
2026-08-18 14:08:38 +08:00

331 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()