This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 deletions
+231
View File
@@ -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
+49 -4
View File
@@ -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,
+76
View File
@@ -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,
+41 -1
View File
@@ -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()
+363 -2
View File
@@ -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"