更新
This commit is contained in:
@@ -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