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
+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"