更新
This commit is contained in:
@@ -1291,3 +1291,234 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
|
||||
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
||||
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def _bubble_texts(dialog: AiConsultDialog) -> list[str]:
|
||||
return [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
|
||||
|
||||
def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
|
||||
"""A dialog whose stream workers are never actually started."""
|
||||
|
||||
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)
|
||||
return dialog
|
||||
|
||||
|
||||
def test_full_context_notice_is_shown_once_per_conversation(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
notice = "服务端将按当前诊单实时附带患者全部纵向资料"
|
||||
counts = []
|
||||
for question in ("第一个问题", "第二个问题", "第三个问题"):
|
||||
dialog._cancel_stream()
|
||||
dialog._ask(question)
|
||||
counts.append(sum(1 for text in _bubble_texts(dialog) if notice in text))
|
||||
assert counts == [1, 1, 1]
|
||||
|
||||
# 换患者视为新会话,需要重新提示一次。
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
application.processEvents()
|
||||
dialog._ask("新患者的问题")
|
||||
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 1
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 服务端既不发 done 也不报错时,占位气泡会永远停在空白状态。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
dialog._ask("请评估当前用药是否合理")
|
||||
assert dialog._stream_bubble is not None
|
||||
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
assert any(
|
||||
ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in _bubble_texts(dialog)
|
||||
)
|
||||
|
||||
dialog._stream_finished(
|
||||
dialog._generation, dialog._stream_generation, dialog._stream_worker
|
||||
)
|
||||
application.processEvents()
|
||||
assert dialog._stream_text == ai_consult_module.AI_STREAM_SILENT_TEXT
|
||||
assert any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in _bubble_texts(dialog))
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_answered_stream_replaces_the_pending_placeholder(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
dialog._ask("请总结当前病情")
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||
application.processEvents()
|
||||
|
||||
assert dialog._stream_text == "证候:脾肾两虚"
|
||||
texts = _bubble_texts(dialog)
|
||||
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||||
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_window_can_be_maximized(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 这个窗口信息密度很高,必须允许医生放大到整屏。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
flags = dialog.windowFlags()
|
||||
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||
assert dialog.isSizeGripEnabled()
|
||||
dialog.showMaximized()
|
||||
application.processEvents()
|
||||
assert dialog.isMaximized()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_long_reply_gets_section_hierarchy_and_breathing_room(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""QTextDocument's markdown importer ignores setDefaultStyleSheet.
|
||||
|
||||
Spacing therefore has to be applied to the parsed document; without it every
|
||||
block renders at one size with 6px margins and the reply reads as a wall.
|
||||
"""
|
||||
|
||||
browser = ai_consult_module._RichMessage("ai")
|
||||
browser.resize(660, 400)
|
||||
browser.set_payload(
|
||||
"概述段落。\n\n"
|
||||
"1. **症状演变与疗效评估**\n"
|
||||
" - **麻木症状:** 服药十四天后是否缓解?\n"
|
||||
" - **皮肤瘙痒:** 目前是否仍有发作?\n"
|
||||
"2. **血糖控制与监测细节**\n"
|
||||
" - **监测习惯:** 是否规律监测餐后血糖?\n"
|
||||
)
|
||||
|
||||
document = browser.document()
|
||||
base_px = browser.font().pixelSize()
|
||||
seen: dict[str, list] = {"section": [], "item": [], "paragraph": []}
|
||||
block = document.begin()
|
||||
while block.isValid():
|
||||
text_list = block.textList()
|
||||
indent = text_list.format().indent() if text_list is not None else 0
|
||||
kind = "item" if indent >= 2 else "section" if indent == 1 else "paragraph"
|
||||
seen[kind].append(block)
|
||||
block = block.next()
|
||||
|
||||
assert seen["section"] and seen["item"], "both list levels must be present"
|
||||
|
||||
# 小节标题比正文更大更重,否则四个小节无法一眼分辨。
|
||||
section = seen["section"][0]
|
||||
section_size = section.begin().fragment().charFormat().font().pixelSize()
|
||||
assert section_size > base_px
|
||||
|
||||
# 小节之间的留白必须大于同一小节内要点之间的留白。
|
||||
section_top = seen["section"][0].blockFormat().topMargin()
|
||||
item_top = seen["item"][0].blockFormat().topMargin()
|
||||
assert section_top > item_top > 0
|
||||
|
||||
# 一条要点的折行必须比两条要点之间更紧,否则整段会散成碎片。
|
||||
item_line = seen["item"][0].blockFormat().lineHeight()
|
||||
assert 0 < item_line < 170
|
||||
|
||||
# 首块不带上边距,避免气泡顶部出现一段空白。
|
||||
assert document.begin().blockFormat().topMargin() == 0
|
||||
|
||||
|
||||
def test_short_reply_is_not_over_spaced(application: QApplication) -> None:
|
||||
browser = ai_consult_module._RichMessage("ai")
|
||||
browser.resize(660, 200)
|
||||
browser.set_payload("血糖控制尚可,暂无需调整降糖方案。")
|
||||
block = browser.document().begin()
|
||||
assert block.blockFormat().topMargin() == 0
|
||||
assert block.next().isValid() is False
|
||||
|
||||
|
||||
def test_sectioned_reply_becomes_a_structured_report(application: QApplication) -> None:
|
||||
"""The clinical panel only knows four fixed section names.
|
||||
|
||||
Real answers are sectioned as 症状演变 / 血糖控制 / 用药依从性 …, which matched
|
||||
none of them and therefore fell back to a plain wall of text.
|
||||
"""
|
||||
|
||||
reply = (
|
||||
"以下是针对该患者当前情况,建议向患者确认的关键问诊问题,用于补充现有病历中的信息缺口:\n\n"
|
||||
"1. **症状演变与疗效评估**\n"
|
||||
" - **麻木症状:** 服药十四天后四肢麻木是否有所缓解?\n"
|
||||
" - **皮肤瘙痒:** 目前是否仍有发作?是否与血糖波动有关?\n"
|
||||
"2. **血糖控制与监测细节**\n"
|
||||
" - **空腹血糖波动:** 近期是否有反复的低血糖发作?\n"
|
||||
"3. **用药依从性与生活方式**\n"
|
||||
" - **西药服用情况:** 近期是否有漏服或自行调整剂量?\n\n"
|
||||
"**提示:** 以上问题基于现有脱敏病例资料梳理,需由执业医师复核后确定。\n"
|
||||
)
|
||||
parsed = ai_consult_module.parse_structured_report(reply)
|
||||
assert parsed is not None
|
||||
intro, sections, disclaimer = parsed
|
||||
assert [section.title for section in sections] == [
|
||||
"症状演变与疗效评估",
|
||||
"血糖控制与监测细节",
|
||||
"用药依从性与生活方式",
|
||||
]
|
||||
assert [len(section.items) for section in sections] == [2, 1, 1]
|
||||
assert "信息缺口" in intro
|
||||
assert "执业医师" in disclaimer
|
||||
|
||||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 11:01")
|
||||
assert bubble.finalize_clinical_analysis() is True
|
||||
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||||
assert panel is not None
|
||||
titles = [
|
||||
label.text()
|
||||
for label in panel.findChildren(QLabel)
|
||||
if label.objectName() == "AiConsultClinicalSectionTitle"
|
||||
]
|
||||
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
|
||||
bubble.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reply",
|
||||
[
|
||||
"血糖控制尚可,暂无需调整降糖方案。",
|
||||
"1. **只有一个小节**\n - 一条要点\n",
|
||||
'{"summary": "结构化 JSON 走既有解析路径"}',
|
||||
],
|
||||
)
|
||||
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
|
||||
assert ai_consult_module.parse_structured_report(reply) is None
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.errors import ApiTimeoutError
|
||||
@@ -385,7 +385,10 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
||||
task: str,
|
||||
) -> dict[str, Any]:
|
||||
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
||||
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
|
||||
return {
|
||||
"answer": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
|
||||
"model_key": "openai",
|
||||
}
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
||||
@@ -393,15 +396,57 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
||||
assert calls == [
|
||||
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
||||
]
|
||||
assert dialog.answer_label.text() == "建议复核肾功能与眼底。"
|
||||
assert dialog.answer_label.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
|
||||
assert "###" not in dialog.answer_label.toPlainText()
|
||||
assert "**" not in dialog.answer_label.toPlainText()
|
||||
rendered_html = dialog.answer_label.toHtml().lower()
|
||||
assert "<h3" in rendered_html
|
||||
assert "font-weight:700" in rendered_html.replace(" ", "")
|
||||
assert "openai" in dialog.model_label.text()
|
||||
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
|
||||
assert isinstance(dialog.answer_label, QTextBrowser)
|
||||
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
|
||||
assert dialog.answer_label.openLinks() is False
|
||||
assert dialog.answer_label.openExternalLinks() is False
|
||||
assert dialog.loading is False
|
||||
assert dialog.retry_button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
|
||||
"model_key": "qwen",
|
||||
}
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "复诊建议", task="custom")
|
||||
|
||||
assert "安全内容" in dialog.answer_label.toPlainText()
|
||||
assert "建议复诊" in dialog.answer_label.toPlainText()
|
||||
assert "<img" in dialog.answer_label.toPlainText()
|
||||
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
|
||||
assert (
|
||||
dialog.answer_label.loadResource(
|
||||
ai_module.QTextDocument.ResourceType.ImageResource,
|
||||
"https://invalid.example/pixel",
|
||||
)
|
||||
is None
|
||||
)
|
||||
dialog.answer_label.selectAll()
|
||||
copied = dialog.answer_label.createMimeDataFromSelection()
|
||||
assert copied.hasText()
|
||||
assert copied.hasHtml() is False
|
||||
assert set(copied.formats()) == {"text/plain"}
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
@@ -288,6 +288,82 @@ def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
|
||||
assert shell_window.stack.currentWidget() is current
|
||||
|
||||
|
||||
def test_shell_global_diagnosis_entry_reuses_dialog_and_obeys_permissions(
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[tuple[str, int]] = []
|
||||
refresh_callbacks: list[Any] = []
|
||||
created: list[Any] = []
|
||||
|
||||
class _SavedSignal:
|
||||
def connect(self, callback: Any) -> None:
|
||||
refresh_callbacks.append(callback)
|
||||
|
||||
class _DiagnosisDialogDouble:
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
parent: Any,
|
||||
*,
|
||||
permissions: Any,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.parent = parent
|
||||
self.permissions = permissions
|
||||
self.saved = _SavedSignal()
|
||||
self.raise_count = 0
|
||||
self.activate_count = 0
|
||||
created.append(self)
|
||||
|
||||
def refresh_permissions(self, permissions: Any) -> None:
|
||||
self.permissions = permissions
|
||||
|
||||
def open_for(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
editable: bool,
|
||||
modeless: bool,
|
||||
) -> None:
|
||||
assert editable is True
|
||||
assert modeless is True
|
||||
opened.append(("edit", diagnosis_id))
|
||||
|
||||
def open_view_only(self, diagnosis_id: int, *, modeless: bool) -> None:
|
||||
assert modeless is True
|
||||
opened.append(("view", diagnosis_id))
|
||||
|
||||
def raise_(self) -> None:
|
||||
self.raise_count += 1
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible test double
|
||||
self.activate_count += 1
|
||||
|
||||
monkeypatch.setattr(shell_module, "DiagnosisDialog", _DiagnosisDialogDouble)
|
||||
shell_window._global_diagnosis_dialog = None
|
||||
|
||||
shell_window.permissions = {"tcm.diagnosis/edit"}
|
||||
assert shell_window.open_diagnosis_by_id(501, modeless=True) is created[0]
|
||||
shell_window.permissions = {"tcm.diagnosis/readonlyDetail"}
|
||||
assert shell_window.open_diagnosis_by_id("502", modeless=True) is created[0]
|
||||
shell_window.permissions = {"tcm.diagnosis/*"}
|
||||
assert shell_window.open_diagnosis_by_id(503, modeless=True) is created[0]
|
||||
|
||||
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||
assert len(created) == 1
|
||||
assert created[0].parent is shell_window
|
||||
assert len(refresh_callbacks) == 1
|
||||
assert created[0].raise_count == 3
|
||||
assert created[0].activate_count == 3
|
||||
|
||||
shell_window.permissions = set()
|
||||
assert shell_window.open_diagnosis_by_id(504, modeless=True) is None
|
||||
assert shell_window.open_diagnosis_by_id(0, modeless=True) is None
|
||||
assert shell_window.open_diagnosis_by_id("invalid", modeless=True) is None
|
||||
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||
|
||||
|
||||
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
|
||||
@@ -95,7 +95,13 @@ def test_navigation_requires_each_pages_actual_list_capability() -> None:
|
||||
def test_video_release_does_not_remove_a_newer_call() -> None:
|
||||
older = object()
|
||||
newer = object()
|
||||
controller = SimpleNamespace(video_calls={"501": newer})
|
||||
# _release_video_call also clears the preview slot, so the double needs the
|
||||
# same attributes the real controller sets up in __init__.
|
||||
controller = SimpleNamespace(
|
||||
video_calls={"501": newer},
|
||||
_video_preview_state=None,
|
||||
_video_preview_generation=0,
|
||||
)
|
||||
|
||||
ApplicationController._release_video_call(controller, "501", older)
|
||||
assert controller.video_calls == {"501": newer}
|
||||
@@ -505,3 +511,37 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||
assert "信任自签名证书" in window.error_banner.label.text()
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_business_dialogs_can_be_maximized_but_prompts_cannot() -> None:
|
||||
"""Dense AI panels and editors were stuck at their constructed size."""
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
|
||||
|
||||
from doctor_workstation.ui.theme import allow_dialog_resize
|
||||
|
||||
application = QApplication.instance() or QApplication([])
|
||||
assert application is not None
|
||||
|
||||
dialog = QDialog()
|
||||
dialog.resize(600, 400)
|
||||
allow_dialog_resize(dialog)
|
||||
flags = dialog.windowFlags()
|
||||
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||
assert dialog.isSizeGripEnabled()
|
||||
dialog.deleteLater()
|
||||
|
||||
# Transient prompts keep their plain frame.
|
||||
prompt = QMessageBox()
|
||||
allow_dialog_resize(prompt)
|
||||
assert not (prompt.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||
prompt.deleteLater()
|
||||
|
||||
# A dialog that pinned itself to a fixed size keeps that decision.
|
||||
fixed = QDialog()
|
||||
fixed.setFixedSize(420, 300)
|
||||
allow_dialog_resize(fixed)
|
||||
assert not (fixed.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||
fixed.deleteLater()
|
||||
|
||||
@@ -5,7 +5,8 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from types import MethodType, SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -52,7 +53,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
|
||||
|
||||
assert "context.createMediaStreamDestination()" in source
|
||||
assert "cloud.getAudioTrack({ processed: true })" in source
|
||||
assert "userId: activeConfig.targetUserId" in source
|
||||
assert "attachPatientAudioTrack(cloud, activeConfig.targetUserId)" in source
|
||||
assert "new MediaRecorder(destination.stream" in source
|
||||
assert "recorder.start(1000)" in source
|
||||
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
|
||||
@@ -134,6 +135,12 @@ def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallback
|
||||
assert "stream.getAudioTracks()" in source
|
||||
assert "navigator.mediaDevices.getUserMedia" in source
|
||||
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
|
||||
assert "if (!attached)" in source
|
||||
assert "cloud.getAudioTrack(userId)" in source
|
||||
assert "event.sourceTrack" in source
|
||||
assert "cloud.on('remote-audio-available'" in source
|
||||
assert "localAudioCloud.off('remote-audio-available'" in source
|
||||
assert "!event.userId || event.userId === activeConfig?.userID" in source
|
||||
assert "localRecordingAttachedSourceCount <= 0" in source
|
||||
assert "localRecordingBytes < 1024" in source
|
||||
assert "已阻止上传空文件" in source
|
||||
@@ -201,6 +208,360 @@ def test_companion_shows_incremental_subtitles_but_only_persists_final_segments(
|
||||
assert "caption.text" in component_source
|
||||
|
||||
|
||||
def test_companion_keeps_transcript_and_patient_case_visible_in_a_side_rail() -> None:
|
||||
companion_root = PROJECT_ROOT / "video_companion" / "src"
|
||||
main_source = (companion_root / "main.ts").read_text(encoding="utf-8")
|
||||
component_source = (companion_root / "App.vue").read_text(encoding="utf-8")
|
||||
styles = (companion_root / "style.css").read_text(encoding="utf-8")
|
||||
window_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "liveCaptions.value = [...previous, caption].slice(-120)" in main_source
|
||||
assert "liveCaptionClearTimer" not in main_source
|
||||
assert 'aria-label="患者病例与实时对话"' in component_source
|
||||
assert 'id="patient-case-title"' in component_source
|
||||
assert 'id="live-transcript-title"' in component_source
|
||||
assert 'aria-label="打开完整诊单"' in component_source
|
||||
assert "runAction(onOpenDiagnosis)" in component_source
|
||||
assert "detail.clinicalDiagnosis" in component_source
|
||||
assert 'v-for="field in caseFields"' in component_source
|
||||
assert "{{ field.value }}" in component_source
|
||||
assert "{{ caption.time }}" in component_source
|
||||
assert "'caption-entry--partial': !caption.completed" in component_source
|
||||
assert ':allowed-full-screen="false"' in component_source
|
||||
assert ".video-layer--with-rail" in styles
|
||||
assert ".consultation-rail" in styles
|
||||
assert '"patientCase": self.patient_case' in window_source
|
||||
assert 'event == "open-diagnosis-request"' in window_source
|
||||
assert "QTimer.singleShot(0, self._open_diagnosis_safely)" in window_source
|
||||
stop_source = main_source.split("async function stopTranscription", 1)[1].split(
|
||||
"function transcriptionResult", 1
|
||||
)[0]
|
||||
assert "clearLiveCaptions()" not in stop_source
|
||||
|
||||
|
||||
def test_video_diagnosis_entry_uses_existing_permission_scoped_drawer() -> None:
|
||||
app_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "app.py"
|
||||
).read_text(encoding="utf-8")
|
||||
launcher_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "launcher.py"
|
||||
).read_text(encoding="utf-8")
|
||||
main_source = (
|
||||
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
shell_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
|
||||
).read_text(encoding="utf-8")
|
||||
diagnosis_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "dialogs" / "diagnosis.py"
|
||||
).read_text(encoding="utf-8")
|
||||
styles = (
|
||||
PROJECT_ROOT / "video_companion" / "src" / "style.css"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "shell.open_diagnosis_by_id(diagnosis_id, modeless=True)" in app_source
|
||||
assert "self._show_video_preview(video_window, dialog)" in app_source
|
||||
assert "WindowStaysOnTopHint" in app_source
|
||||
assert "dialog.finished.connect" in app_source
|
||||
assert "modeless=modeless" in shell_source
|
||||
assert "not modeless and not self._standalone_readonly" in diagnosis_source
|
||||
compact_styles = styles.split("@media (max-width: 700px)", 1)[1]
|
||||
assert ".consultation-rail { display: none; }" in compact_styles
|
||||
assert ".capture-button { display: none; }" in compact_styles
|
||||
assert "on_open_diagnosis=on_open_diagnosis" in launcher_source
|
||||
assert "event: 'open-diagnosis-request'" in main_source
|
||||
|
||||
|
||||
def test_video_preview_is_compact_and_restores_after_diagnosis_closes() -> None:
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from doctor_workstation.app import ApplicationController
|
||||
|
||||
class _Rect:
|
||||
def x(self) -> int:
|
||||
return 0
|
||||
|
||||
def y(self) -> int:
|
||||
return 0
|
||||
|
||||
def width(self) -> int:
|
||||
return 1920
|
||||
|
||||
def height(self) -> int:
|
||||
return 1040
|
||||
|
||||
class _Screen:
|
||||
def availableGeometry(self) -> _Rect: # noqa: N802 - Qt-compatible double
|
||||
return _Rect()
|
||||
|
||||
class _Window:
|
||||
def __init__(self) -> None:
|
||||
self.original_geometry = object()
|
||||
self.original_minimum = object()
|
||||
self.minimum = self.original_minimum
|
||||
self.geometry_value = self.original_geometry
|
||||
self.size = (900, 600)
|
||||
self.position = (30, 40)
|
||||
self.stays_on_top = False
|
||||
self.activated = 0
|
||||
|
||||
def geometry(self) -> object:
|
||||
return self.geometry_value
|
||||
|
||||
def minimumSize(self) -> object: # noqa: N802 - Qt-compatible double
|
||||
return self.minimum
|
||||
|
||||
def isMaximized(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||
return False
|
||||
|
||||
def isFullScreen(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||
return False
|
||||
|
||||
def windowFlags(self) -> Qt.WindowType: # noqa: N802 - Qt-compatible double
|
||||
return Qt.WindowType.Window
|
||||
|
||||
def screen(self) -> _Screen:
|
||||
return _Screen()
|
||||
|
||||
def showNormal(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def showMaximized(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def showFullScreen(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def setMinimumSize(self, *value: object) -> None: # noqa: N802
|
||||
self.minimum = value[0] if len(value) == 1 else value
|
||||
|
||||
def setWindowFlag(self, _flag: Any, enabled: bool) -> None: # noqa: N802
|
||||
self.stays_on_top = enabled
|
||||
|
||||
def resize(self, width: int, height: int) -> None:
|
||||
self.size = (width, height)
|
||||
|
||||
def move(self, x: int, y: int) -> None:
|
||||
self.position = (x, y)
|
||||
|
||||
def setGeometry(self, geometry: object) -> None: # noqa: N802
|
||||
self.geometry_value = geometry
|
||||
|
||||
def show(self) -> None:
|
||||
return None
|
||||
|
||||
def raise_(self) -> None:
|
||||
return None
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
self.activated += 1
|
||||
|
||||
class _Signal:
|
||||
def __init__(self) -> None:
|
||||
self.callbacks: list[Any] = []
|
||||
|
||||
def connect(self, callback: Any) -> None:
|
||||
self.callbacks.append(callback)
|
||||
|
||||
class _Dialog:
|
||||
def __init__(self) -> None:
|
||||
self.finished = _Signal()
|
||||
|
||||
def raise_(self) -> None:
|
||||
return None
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
controller = SimpleNamespace(
|
||||
_video_preview_state=None,
|
||||
_video_preview_generation=0,
|
||||
)
|
||||
controller._restore_video_preview = MethodType( # type: ignore[attr-defined]
|
||||
ApplicationController._restore_video_preview,
|
||||
controller,
|
||||
)
|
||||
window = _Window()
|
||||
dialog = _Dialog()
|
||||
|
||||
ApplicationController._show_video_preview(controller, window, dialog)
|
||||
|
||||
assert window.minimum == (440, 300)
|
||||
assert window.size == (540, 356)
|
||||
assert window.position == (1362, 18)
|
||||
assert window.stays_on_top is True
|
||||
assert len(dialog.finished.callbacks) == 1
|
||||
|
||||
dialog.finished.callbacks[0](0)
|
||||
|
||||
assert window.minimum is window.original_minimum
|
||||
assert window.geometry_value is window.original_geometry
|
||||
assert window.stays_on_top is False
|
||||
assert window.activated == 1
|
||||
|
||||
|
||||
def test_video_patient_case_snapshot_is_bounded_and_clinically_useful() -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
{
|
||||
"diagnosis": {
|
||||
"patient_name": "张三",
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"gender": 1,
|
||||
"age": 47,
|
||||
"chief_complaint": "反复口渴三个月",
|
||||
"present_illness": "近期空腹血糖偏高",
|
||||
"allergy_history": "青霉素",
|
||||
"current_medicine": ["二甲双胍", "阿卡波糖"],
|
||||
"clinical_diagnosis": "2 型糖尿病",
|
||||
},
|
||||
"appointment": {"appointment_date": "2026-08-26"},
|
||||
"internal_audit": {"token": "must-not-cross-the-bridge"},
|
||||
},
|
||||
{},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
|
||||
assert summary["diagnosisId"] == "8279"
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["gender"] == "男"
|
||||
assert summary["age"] == "47"
|
||||
assert summary["chiefComplaint"] == "反复口渴三个月"
|
||||
assert summary["currentMedication"] == "二甲双胍、阿卡波糖"
|
||||
assert summary["allergyHistory"] == "青霉素"
|
||||
assert "internal_audit" not in summary
|
||||
assert set(summary) == {
|
||||
"diagnosisId",
|
||||
"name",
|
||||
"gender",
|
||||
"age",
|
||||
"height",
|
||||
"weight",
|
||||
"diagnosisDate",
|
||||
"appointmentDate",
|
||||
"clinicalDiagnosis",
|
||||
"chiefComplaint",
|
||||
"presentIllness",
|
||||
"pastHistory",
|
||||
"allergyHistory",
|
||||
"personalHistory",
|
||||
"familyHistory",
|
||||
"currentMedication",
|
||||
"tongue",
|
||||
"pulse",
|
||||
"prescriptionOpinion",
|
||||
"remark",
|
||||
}
|
||||
|
||||
bounded = _build_video_patient_case(
|
||||
{},
|
||||
{
|
||||
"diagnosis_id": 8279,
|
||||
"patient_id": 42,
|
||||
"patient_name": "患" * 180,
|
||||
"age": "4" * 40,
|
||||
"remark": "病" * 2400,
|
||||
},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
assert len(bounded["name"]) == 120
|
||||
assert len(bounded["age"]) == 20
|
||||
assert len(bounded["remark"]) == 2000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"detail",
|
||||
[
|
||||
{
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
}
|
||||
},
|
||||
{
|
||||
"diagnosis": {
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_video_patient_case_accepts_supported_readonly_detail_shapes(detail: object) -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
detail,
|
||||
{},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["chiefComplaint"] == "口渴"
|
||||
|
||||
|
||||
def test_video_patient_case_fails_closed_on_identity_mismatch() -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
{
|
||||
"diagnosis": {
|
||||
"id": 9001,
|
||||
"source_patient_id": 7,
|
||||
"patient_name": "其他患者",
|
||||
"chief_complaint": "不得展示",
|
||||
"allergy_history": "不得展示",
|
||||
}
|
||||
},
|
||||
{
|
||||
"diagnosis_id": 9001,
|
||||
"patient_id": 7,
|
||||
"chief_complaint": "也不得展示",
|
||||
},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="张三",
|
||||
)
|
||||
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["chiefComplaint"] == ""
|
||||
assert summary["allergyHistory"] == ""
|
||||
|
||||
|
||||
def test_built_video_companion_contains_the_patient_case_rail() -> None:
|
||||
dist_root = PROJECT_ROOT / "video_companion" / "dist"
|
||||
styles = "\n".join(
|
||||
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.css")
|
||||
)
|
||||
scripts = "\n".join(
|
||||
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.js")
|
||||
)
|
||||
|
||||
assert ".consultation-rail" in styles
|
||||
assert ".video-layer--with-rail" in styles
|
||||
assert "patientCase" in scripts
|
||||
assert "患者病例与实时对话" in scripts
|
||||
|
||||
|
||||
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
|
||||
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
||||
encoding="utf-8"
|
||||
|
||||
Reference in New Issue
Block a user