更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
+39
View File
@@ -3,7 +3,10 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier, get_ident
from typing import Any
import httpx
import pytest
@@ -17,6 +20,7 @@ from doctor_workstation.core.errors import (
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
@@ -48,6 +52,41 @@ def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
)
def test_default_client_allows_parallel_requests_with_independent_transports(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Production workers must not queue behind one process-wide HTTP lock."""
rendezvous = Barrier(2, timeout=2)
created: list[Any] = []
request_threads: set[int] = set()
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
request_threads.add(get_ident())
rendezvous.wait()
return httpx.Response(200, json={"code": 1, "data": url.rsplit("/", 1)[-1]})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_retries=0)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(client.get, "patient/first")
second = executor.submit(client.get, "patient/second")
assert {first.result(timeout=3), second.result(timeout=3)} == {"first", "second"}
client.close()
assert len(created) == 2
assert len(request_threads) == 2
assert all(item.closed for item in created)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
+248 -22
View File
@@ -10,7 +10,14 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QPushButton,
)
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
@@ -188,6 +195,106 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
)
assert prescription_action_label(pending) == "编辑处方"
historical_only = Appointment.from_dict(
{
"id": 104,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
}
)
assert prescription_action_label(historical_only) == "开方"
def test_appointment_view_intent_never_degrades_to_edit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"current_has_prescription": 1,
"current_prescription_id": 81,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
page = AppointmentsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
monkeypatch.setattr(page, "_current_row", lambda: row)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda source, *, mode="open": requested.append((source, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 81,
"appointment_id": 101,
"audit_status": 2,
"void_status": 0,
},
row,
page._prescription_generation,
"view",
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_appointment_im_action_opens_chat_without_an_existing_live_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
row = {
"id": 101,
"appointment_id": 101,
"diagnosis_id": 501,
"patient_id": 501,
"source_patient_id": 301,
"patient_name": "测试患者",
"status": 1,
}
emitted: list[dict[str, Any]] = []
monkeypatch.setattr(page, "_current_row", lambda: row)
page.video_requested.connect(emitted.append)
page._request_video()
assert len(emitted) == 1
assert emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert emitted[0]["diagnosis_id"] == 501
assert emitted[0]["patient_id"] == 301
warnings: list[str] = []
monkeypatch.setattr(
appointments_module,
"show_toast",
lambda _parent, message, _kind: warnings.append(message),
)
row["status"] = 2
page._request_video()
assert len(emitted) == 1
assert warnings == ["已取消的挂号不可进入 IM 问诊。"]
page.close()
application.processEvents()
def test_appointment_pending_prescription_uses_full_edit_contract(
application: QApplication,
@@ -508,15 +615,20 @@ def test_appointment_ai_report_button_visible_with_reception_permission(
application.processEvents()
def test_appointments_reference_split_layout_and_video_list(
def test_appointments_use_full_width_table_with_per_row_im_consult(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object())
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
permissions=PermissionSet(
["doctor.appointment/lists", "doctor.appointment/prescription"]
),
current_user={"id": 1001, "role_id": 1},
)
page.resize(1460, 820)
page.show()
page._apply_responsive_layout()
page._loaded(
{
@@ -524,6 +636,7 @@ def test_appointments_reference_split_layout_and_video_list(
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "赵俊霞",
"gender": 0,
"age": 53,
@@ -532,6 +645,7 @@ def test_appointments_reference_split_layout_and_video_list(
"appointment_time": "09:50",
"status": 1,
"status_desc": "已挂号",
"video_call_hint": {"state": "live", "label": "视频通话进行中"},
}
],
"count": 1,
@@ -541,32 +655,143 @@ def test_appointments_reference_split_layout_and_video_list(
)
application.processEvents()
assert page.video_list.count() == 1
assert "赵俊霞" in page.video_list.item(0).text()
assert 300 <= page.video_panel.width() <= 420
assert page.content_layout.count() == 1
assert not hasattr(page, "video_panel")
assert not hasattr(page, "video_list")
assert page.table_card.width() == page.content_host.width()
assert page.table.objectName() == "AppointmentTable"
assert (
page.table.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert (
page.video_list.verticalScrollMode()
== QAbstractItemView.ScrollMode.ScrollPerPixel
)
assert page.table.horizontalHeaderItem(10).text() == "IM 问诊"
im_host = page.table.cellWidget(0, 10)
assert im_host is not None
im_button = im_host.findChild(QPushButton, "AppointmentImConsultButton")
assert im_button is not None
assert im_button.text() == "IM 问诊"
assert im_button.isEnabled()
assert im_button.accessibleName() == "与赵俊霞进行 IM 问诊"
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
im_button.click()
assert emitted and emitted[0]["mode"] == "im"
assert emitted[0]["appointment_id"] == 101
assert page.date_buttons["today"].isChecked()
page.resize(1024, 640)
page._apply_responsive_layout()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
application.processEvents()
assert page.table_card.width() == page.content_host.width()
assert not page.date_overflow_button.isHidden()
assert page.date_buttons["yesterday"].isHidden()
assert not page.date_buttons["today"].isHidden()
page.video_panel_button.click()
assert not page.video_panel.isHidden()
assert 250 <= page.video_panel.width() <= 300
page.video_panel_button.click()
assert page.video_panel.isHidden()
page.close()
application.processEvents()
def test_im_entry_does_not_require_a_live_video_hint(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": 101,
"diagnosis_id": 501,
"source_patient_id": 301,
"patient_name": "已接通患者",
"status": 1,
"video_call_hint": {"state": "ended", "label": "视频通话已结束"},
},
{
"id": 102,
"diagnosis_id": 502,
"source_patient_id": 302,
"patient_name": "等待患者",
"status": 1,
},
]
page._loaded({"lists": rows, "count": 2}, page._generation, False)
application.processEvents()
buttons = [
page.table.cellWidget(index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
for index in range(page.table.rowCount())
]
buttons_by_name = {
button.accessibleName(): button for button in buttons if button is not None
}
assert set(buttons_by_name) == {
"与已接通患者进行 IM 问诊",
"与等待患者进行 IM 问诊",
}
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
page.close()
application.processEvents()
def test_im_entry_allows_fulfillable_statuses_and_rejects_terminal_or_unknown_statuses(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/prescription"]),
)
rows = [
{
"id": index + 100,
"diagnosis_id": index + 500,
"source_patient_id": index + 300,
"patient_name": name,
"status": status,
}
for index, (name, status) in enumerate(
(
("已预约患者", 1),
("已过号患者", 4),
("已取消患者", 2),
("已完成患者", 3),
("未知状态患者", 0),
)
)
]
page._loaded({"lists": rows, "count": len(rows)}, page._generation, False)
application.processEvents()
buttons = {
button.accessibleName(): button
for row_index in range(page.table.rowCount())
if (
button := page.table.cellWidget(row_index, 10).findChild(
QPushButton,
"AppointmentImConsultButton",
)
)
is not None
}
assert buttons["与已预约患者进行 IM 问诊"].isEnabled()
assert buttons["与已过号患者进行 IM 问诊"].isEnabled()
assert not buttons["与已取消患者进行 IM 问诊"].isEnabled()
assert buttons["与已取消患者进行 IM 问诊"].toolTip() == (
"已取消的挂号不可进入 IM 问诊"
)
assert not buttons["与已完成患者进行 IM 问诊"].isEnabled()
assert buttons["与已完成患者进行 IM 问诊"].toolTip() == (
"已完成的挂号不可再进入 IM 问诊"
)
assert not buttons["与未知状态患者进行 IM 问诊"].isEnabled()
assert buttons["与未知状态患者进行 IM 问诊"].toolTip() == (
"当前挂号状态不可进入 IM 问诊"
)
page.close()
application.processEvents()
@@ -601,19 +826,20 @@ def test_identical_appointment_poll_keeps_existing_cell_widgets(
page._loaded(result, page._generation, True)
selector = page.table.cellWidget(0, 0)
appointment_info = page.table.cellWidget(0, 4)
video_card = page.video_list.itemWidget(page.video_list.item(0))
im_action = page.table.cellWidget(0, 10)
page._loaded(deepcopy(result), page._generation, True)
assert page.table.cellWidget(0, 0) is selector
assert page.table.cellWidget(0, 4) is appointment_info
assert page.video_list.itemWidget(page.video_list.item(0)) is video_card
assert page.table.cellWidget(0, 10) is im_action
changed = deepcopy(result)
changed["lists"][0]["assistant_name"] = "新医助"
page._loaded(changed, page._generation, True)
assert page.table.cellWidget(0, 0) is not selector
assert page.table.cellWidget(0, 4) is not appointment_info
assert page.table.cellWidget(0, 10) is not im_action
page.close()
application.processEvents()
@@ -665,11 +891,11 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
assert all(60 <= height <= 66 for height in heights)
assert page.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page)
assert 300 <= page.video_panel.width() < 420
assert page.content_layout.count() == 1
assert page.table_card.width() == page.content_host.width()
page.resize(1024, 640)
application.processEvents()
assert page.video_panel.isHidden()
assert not page.video_panel_button.isHidden()
assert page.table_card.width() == page.content_host.width()
page.close()
application.processEvents()
+118 -12
View File
@@ -169,7 +169,7 @@ def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
@@ -195,6 +195,62 @@ def test_nested_appointments_confirmation_and_prescription_labels() -> None:
)
== "编辑处方"
)
assert (
prescription_action_label(
{
"has_prescription": 1,
"current_has_prescription": 0,
"prescription_audit_status": 1,
"prescription_void_status": 0,
}
)
== "开方"
)
def test_view_prescription_intent_is_readonly_and_fails_closed_on_state_change(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row = _row(
has_prescription=1,
current_has_prescription=1,
current_prescription_id=701,
prescription_audit_status=1,
prescription_void_status=0,
)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.table_host.set_rows([row])
page.table.selectRow(0)
requested: list[tuple[Any, str]] = []
monkeypatch.setattr(
page,
"_begin_prescription_load",
lambda record, *, mode: requested.append((record, mode)),
)
page._open_prescription()
assert requested == [(row, "view")]
monkeypatch.setattr(
page,
"_open_existing_prescription_editor",
lambda _existing: pytest.fail("view intent must never open the editor"),
)
page._prescription_loaded(
{
"id": 701,
"appointment_id": 101,
"audit_status": 0,
"void_status": 0,
},
row,
"view",
page._prescription_generation,
)
assert "状态已变化" in page.banner.label.text()
page.close()
application.processEvents()
def test_default_query_matches_admin_today_and_page_size_contract(
@@ -611,7 +667,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -627,10 +683,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
live_row = _row(
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
page.table.selectRow(0)
application.processEvents()
@@ -638,12 +701,55 @@ def test_native_call_does_not_reuse_video_qr_permission(
assert page.video_button.isEnabled()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
video_action = next(button for button in video_cell.findChildren(QToolButton))
assert video_action.text() == "进入视频问诊"
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
assert video_action.text() == "进入视频问诊"
assert emitted == []
assert "摄像头和麦克风" in video_action.toolTip()
page._request_video()
assert emitted == [_video_payload(live_row)]
assert emitted[0]["mode"] == "im"
page.close()
application.processEvents()
@pytest.mark.parametrize(
("hint", "status_text"),
[
({"state": "none", "label": ""}, "暂无通话"),
({"state": "pending_room", "label": "通话发起中,待同步房间"}, "等待接通"),
],
)
def test_video_join_action_is_hidden_until_doctor_session_is_live(
application: QApplication,
hint: dict[str, Any],
status_text: str,
) -> None:
class VideoRepository:
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
pass
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
pass
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
pass
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert video_cell.findChildren(QToolButton) == []
assert status_text in " ".join(label.text() for label in video_cell.findChildren(QLabel))
page._request_video()
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize(
+2
View File
@@ -146,6 +146,8 @@ def _row(identifier: int, **changes: Any) -> dict[str, Any]:
"assistant_name": "赵医助",
"assign_read_at": None,
"has_prescription": 1,
"current_has_prescription": 0,
"current_prescription_id": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"unserved_days": 2,
+136 -1
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import threading
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton, QVBoxLayout
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QLabel, QPushButton, QVBoxLayout
from doctor_workstation.ui import diagnosis_media
from doctor_workstation.ui.diagnosis_drawer import RecordTable
@@ -378,6 +380,7 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
[
{
"id": 48,
"room_id": "doctor-501-20260819-143247",
"recording_urls_list": [
"https://media.example.invalid/replay.mp4",
"https://media.example.invalid/replay-backup.m3u8",
@@ -407,6 +410,12 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
table = dialog._table_registry["video"][1]
assert table.horizontalHeaderItem(1).text() == "房间号"
assert table.item(0, 1).text() == "doctor-501-20260819-143247"
assert table.item(1, 1).text() == "历史记录未保存"
room_rect = table.visualItemRect(table.item(0, 1))
assert room_rect.left() >= 0
assert room_rect.right() < table.viewport().width()
playback = table.cellWidget(0, 0)
assert isinstance(playback, RecordingPlaybackCell)
assert playback.property("callRecordId") == 48
@@ -465,6 +474,132 @@ def test_video_table_embeds_player_and_preserves_row_bound_upload(
application.processEvents()
def test_video_table_exposes_local_audio_separately_from_cloud_video_and_text(
application: QApplication,
) -> None:
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._editable = False
dialog._can_video_upload = False
dialog._diagnosis_id = 501
opened: list[str] = []
dialog._open_recording_player = lambda target: opened.append(target) # type: ignore[method-assign]
audio_url = "https://cos.example.invalid/calls/local-audio.webm"
dialog._fill_video(
[
{
"id": 49,
"recording_urls_list": [
"https://cos.example.invalid/calls/cloud-mixed-video.mp4"
],
"local_audio_urls_list": [audio_url],
"local_audio_status_text": "已保存",
"transcript_text": "医生:请描述症状。\n患者:最近口渴。",
"transcription_status_text": "已完成",
"call_type": 2,
"status": 2,
"recording_status_text": "已生成",
}
]
)
table = dialog._table_registry["video"][1]
action_host = table.cellWidget(0, 8)
assert action_host is not None
audio = action_host.findChild(QPushButton, "DiagnosisLocalAudioPlayback")
transcript = action_host.findChild(QPushButton, "DiagnosisVideoTranscriptView")
assert audio is not None and audio.property("callRecordId") == 49
assert transcript is not None and transcript.property("callRecordId") == 49
status = table.item(0, 7).text()
assert "云端视频:已生成" in status
assert "本机录音:已保存" in status
assert "转写文字:已完成" in status
audio.click()
assert opened == [audio_url]
dialog.close()
application.processEvents()
def test_local_audio_upload_and_queue_close_refresh_server_backed_video_rows(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[QDialog] = []
class _UploadManager:
def __init__(self) -> None:
self.listeners: set[Any] = set()
def add_upload_listener(self, listener: Any) -> None:
self.listeners.add(listener)
def remove_upload_listener(self, listener: Any) -> None:
self.listeners.discard(listener)
def complete(self, diagnosis_id: int, call_record_id: int) -> None:
record = SimpleNamespace(
diagnosis_id=diagnosis_id,
call_record_id=call_record_id,
)
for listener in tuple(self.listeners):
listener(record)
manager = _UploadManager()
class _QueueDialog(QDialog):
def __init__(
self,
_repository: Any,
_diagnosis_id: int,
parent: QDialog,
) -> None:
super().__init__(parent)
self.manager = manager
created.append(self)
monkeypatch.setattr(diagnosis_module, "LocalAudioQueueDialog", _QueueDialog)
dialog = DiagnosisDialog(_Repository(), permissions=["*"])
dialog._diagnosis_id = 501
dialog._can_video_upload = True
reloads: list[tuple[str, bool]] = []
dialog._ensure_tab_loaded = ( # type: ignore[method-assign]
lambda key, force=False: reloads.append((key, force))
)
dialog._open_local_audio_queue()
assert len(created) == 1
queue_dialog = created[0]
assert reloads == [("video", True)]
dialog._loading_tabs.add("video")
manager.complete(501, 49)
manager.complete(501, 50)
manager.complete(999, 51)
application.processEvents()
assert reloads == [("video", True)]
assert dialog._video_reload_pending is True
dialog._loading_tabs.discard("video")
dialog._flush_video_reload_if_pending(501)
application.processEvents()
assert reloads == [("video", True), ("video", True)]
queue_dialog.accept()
application.processEvents()
assert reloads == [("video", True), ("video", True)]
assert manager.listeners
worker = threading.Thread(target=lambda: manager.complete(501, 52))
worker.start()
worker.join(timeout=5)
assert worker.is_alive() is False
application.processEvents()
assert reloads == [("video", True), ("video", True), ("video", True)]
dialog.reject()
application.processEvents()
assert manager.listeners == set()
def test_inline_player_rejects_stale_owner_generation(application: QApplication) -> None:
class _Owner:
_tab_generations = {"video": 4}
+482
View File
@@ -0,0 +1,482 @@
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from typing import Any
import pytest
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
)
from doctor_workstation.ui.dialogs.local_audio_queue import (
LocalAudioQueueDialog,
_display_time,
)
def _application() -> QApplication:
return QApplication.instance() or QApplication([])
def _ready_record(
store: LocalAudioQueueStore,
*,
diagnosis_id: int,
call_record_id: int,
session_id: str,
room_id: str = "",
) -> int:
record = store.begin_recording(
session_id=session_id,
diagnosis_id=diagnosis_id,
mime_type="audio/webm",
call_record_id=call_record_id,
room_id=room_id,
)
payload = b"\x1aE\xdf\xa3" + (b"local-call-audio" * 128)
record.file_path.write_bytes(payload)
finalized = store.finalize_recording(record.id, size_bytes=len(payload))
return finalized.id
class _ConcurrentRepository:
def __init__(self, expected: int) -> None:
self.expected = expected
self.lock = threading.Lock()
self.release = threading.Event()
self.all_started = threading.Event()
self.active = 0
self.maximum_active = 0
self.calls: list[dict[str, Any]] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
path = Path(payload["path"])
assert path.is_file()
with self.lock:
self.active += 1
self.maximum_active = max(self.maximum_active, self.active)
self.calls.append(payload)
if self.active >= self.expected:
self.all_started.set()
try:
assert self.release.wait(5), "concurrent uploads did not receive release"
return {"file_url": f"cos://recordings/{path.name}"}
finally:
with self.lock:
self.active -= 1
class _RetryRepository:
def __init__(self) -> None:
self.calls = 0
self.call_records: dict[int, list[dict[str, Any]]] = {}
self.list_calls: list[int] = []
def upload_call_recording(self, **payload: Any) -> dict[str, str]:
self.calls += 1
if self.calls == 1:
raise RuntimeError("COS 暂时不可用")
return {"file_url": f"cos://recordings/{Path(payload['path']).name}"}
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
self.list_calls.append(int(diagnosis_id))
return list(self.call_records.get(int(diagnosis_id), []))
def test_existing_queue_schema_adds_room_id_without_losing_rows(
tmp_path: Path,
) -> None:
root = tmp_path / "old-audio-queue"
root.mkdir()
database = root / "queue.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"""
CREATE TABLE local_audio_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
diagnosis_id INTEGER NOT NULL,
call_record_id INTEGER,
mime_type TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
uploaded_url TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT ''
)
"""
)
connection.execute(
"""
INSERT INTO local_audio_uploads (
session_id, diagnosis_id, call_record_id, mime_type, file_path,
size_bytes, status, error_text, uploaded_url, attempts,
created_at, updated_at, uploaded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"legacy-uploaded-session",
8169,
901,
"audio/webm",
str(root / "legacy.webm"),
2048,
"uploaded",
"",
"cos://recordings/legacy.webm",
2,
"2026-08-20T01:00:00+00:00",
"2026-08-20T01:02:00+00:00",
"2026-08-20T01:02:00+00:00",
),
)
store = LocalAudioQueueStore(root)
record = store.list_records()[0]
with sqlite3.connect(database) as connection:
columns = {
str(row[1])
for row in connection.execute(
"PRAGMA table_info(local_audio_uploads)"
).fetchall()
}
assert "room_id" in columns
assert record.room_id == ""
assert record.status == "uploaded"
assert record.uploaded_url == "cos://recordings/legacy.webm"
assert record.attempts == 2
assert LocalAudioQueueStore(root).require(record.id).room_id == ""
def test_local_audio_identity_binding_is_idempotent_and_rejects_conflicts(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record = store.begin_recording(
session_id="identity-binding-session",
diagnosis_id=8169,
mime_type="audio/webm",
call_record_id=901,
room_id="00123456",
)
assert record.call_record_id == 901
assert record.room_id == "00123456"
assert store.bind_identity(
record.id,
call_record_id=901,
room_id="00123456",
).room_id == "00123456"
assert store.bind_identity(record.id, call_record_id=901).room_id == "00123456"
with pytest.raises(RuntimeError, match="房间号发生冲突"):
store.bind_identity(record.id, call_record_id=901, room_id="99887766")
with pytest.raises(RuntimeError, match="通话记录 ID 或房间号发生冲突"):
store.bind_identity(record.id, call_record_id=902, room_id="00123456")
assert store.require(record.id).call_record_id == 901
assert store.require(record.id).room_id == "00123456"
def test_local_audio_queue_uploads_three_files_concurrently(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_ids = [
_ready_record(
store,
diagnosis_id=8169,
call_record_id=900 + index,
session_id=f"session-{index}",
)
for index in range(3)
]
repository = _ConcurrentRepository(expected=3)
manager = LocalAudioUploadManager(repository, store, max_workers=3)
futures = [manager.submit(record_id) for record_id in record_ids]
try:
assert repository.all_started.wait(5)
assert repository.maximum_active == 3
finally:
repository.release.set()
assert [future.result(timeout=5) for future in futures] == [True, True, True]
records = [store.require(record_id) for record_id in record_ids]
assert all(record.status == "uploaded" for record in records)
assert all(record.exists for record in records)
assert all(record.uploaded_url.startswith("cos://recordings/") for record in records)
assert {call["call_record_id"] for call in repository.calls} == {900, 901, 902}
def test_failed_local_audio_is_kept_and_can_be_retried(tmp_path: Path) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="retry-session",
)
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
assert manager.submit(record_id).result(timeout=5) is False
failed = store.require(record_id)
assert failed.status == "failed"
assert failed.exists
assert failed.attempts == 1
assert "COS 暂时不可用" in failed.error_text
store.retry(record_id)
assert manager.submit(record_id).result(timeout=5) is True
uploaded = store.require(record_id)
assert uploaded.status == "uploaded"
assert uploaded.exists
assert uploaded.attempts == 2
assert uploaded.uploaded_url.startswith("cos://recordings/")
def test_local_audio_manager_notifies_when_an_upload_reaches_uploaded(
tmp_path: Path,
) -> None:
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.calls = 1
manager = LocalAudioUploadManager(repository, store, max_workers=1)
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="upload-notification-session",
)
uploads: list[tuple[int, int, str]] = []
def record_upload(record: Any) -> None:
uploads.append(
(record.diagnosis_id, int(record.call_record_id or 0), record.uploaded_url)
)
manager.add_upload_listener(record_upload)
assert manager.submit(record_id).result(timeout=5) is True
assert manager.submit(record_id).result(timeout=5) is True
manager.remove_upload_listener(record_upload)
assert uploads == [
(
8169,
901,
store.require(record_id).uploaded_url,
)
]
def test_local_audio_manager_does_not_report_success_without_uploaded_url(
tmp_path: Path,
) -> None:
class _IncompleteRepository:
@staticmethod
def upload_call_recording(**_payload: Any) -> dict[str, bool]:
return {"completed": True}
store = LocalAudioQueueStore(tmp_path / "audio-queue")
record_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="missing-url-session",
)
manager = LocalAudioUploadManager(_IncompleteRepository(), store, max_workers=1)
uploads: list[Any] = []
manager.add_upload_listener(uploads.append)
assert manager.submit(record_id).result(timeout=5) is False
record = store.require(record_id)
assert record.status == "failed"
assert "文件地址" in record.error_text
assert uploads == []
def test_local_audio_dialog_displays_utc_recording_time_in_business_timezone() -> None:
assert _display_time("2026-08-20T01:51:00+00:00") == (
"2026-08-20 09:51:00"
)
def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="uploaded-session",
room_id="1692231119",
)
store.mark_uploaded(uploaded_id, "cos://recordings/uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.objectName() == "LocalAudioQueueDialog"
assert dialog.table.columnCount() == 8
assert dialog.table.rowCount() == 2
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
assert dialog.retry_failed_button.isEnabled()
assert dialog.table.horizontalHeaderItem(1).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "房间号"
assert dialog.table.horizontalHeaderItem(5).text() == "上传状态"
assert dialog.table.horizontalHeaderItem(6).text() == "失败原因"
assert dialog.table.columnWidth(dialog._room_column) == 180
rooms = {
dialog.table.item(row, 2).text() for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "1692231119"}
statuses = {
dialog.table.item(row, 5).text() for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
finally:
dialog.close()
application.processEvents()
def test_global_local_audio_dialog_lists_all_diagnoses_and_outcomes(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
manager = LocalAudioUploadManager(repository, store, max_workers=1)
failed_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="global-failed-session",
room_id="67534825",
)
store.update_status(failed_id, "failed", "等待医生重试")
uploaded_id = _ready_record(
store,
diagnosis_id=9001,
call_record_id=902,
session_id="global-uploaded-session",
room_id="407179477",
)
store.mark_uploaded(uploaded_id, "cos://recordings/global-uploaded.webm")
dialog = LocalAudioQueueDialog(
repository,
None,
store=store,
manager=manager,
)
dialog.show()
application.processEvents()
try:
assert dialog.title_label.text() == "本机录音上传管理"
assert dialog.table.columnCount() == 9
assert dialog.table.rowCount() == 2
assert dialog.table.horizontalHeaderItem(1).text() == "诊单 ID"
assert dialog.table.horizontalHeaderItem(2).text() == "通话记录 ID"
assert dialog.table.horizontalHeaderItem(3).text() == "房间号"
assert dialog.table.horizontalHeaderItem(6).text() == "上传状态"
diagnosis_ids = {
dialog.table.item(row, 1).text()
for row in range(dialog.table.rowCount())
}
assert diagnosis_ids == {"8169", "9001"}
statuses = {
dialog.table.item(row, 6).text()
for row in range(dialog.table.rowCount())
}
assert statuses == {"上传失败", "已上传"}
rooms = {
dialog.table.item(row, 3).text()
for row in range(dialog.table.rowCount())
}
assert rooms == {"67534825", "407179477"}
assert dialog.summary_failed.text() == "失败 1"
assert dialog.summary_uploaded.text() == "已上传 1"
finally:
dialog.close()
application.processEvents()
def test_dialog_fetches_and_persists_historical_room_ids_once_per_diagnosis(
tmp_path: Path,
) -> None:
application = _application()
store = LocalAudioQueueStore(tmp_path / "audio-queue")
repository = _RetryRepository()
repository.call_records[8169] = [
{"id": 902, "room_id": "1692231119"},
{"id": 901, "room_id": "67534825"},
]
manager = LocalAudioUploadManager(repository, store, max_workers=1)
first_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=901,
session_id="legacy-room-first",
)
second_id = _ready_record(
store,
diagnosis_id=8169,
call_record_id=902,
session_id="legacy-room-second",
)
dialog = LocalAudioQueueDialog(
repository,
8169,
store=store,
manager=manager,
)
dialog.show()
try:
for _ in range(200):
application.processEvents()
if store.require(first_id).room_id and store.require(second_id).room_id:
break
QTest.qWait(10)
assert store.require(first_id).room_id == "67534825"
assert store.require(second_id).room_id == "1692231119"
assert repository.list_calls == [8169]
for _ in range(3):
dialog.refresh_records()
application.processEvents()
assert repository.list_calls == [8169]
assert {
dialog.table.item(row, 2).text()
for row in range(dialog.table.rowCount())
} == {"67534825", "1692231119"}
finally:
dialog.close()
application.processEvents()
+28
View File
@@ -295,6 +295,34 @@ def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
assert "final words" in record["transcript_text"]
def test_demo_local_audio_is_preserved_separately_from_cloud_video_and_text(
repository: DemoDoctorRepository,
tmp_path: Path,
) -> None:
started = repository.start_call(501, 301)
call_record_id = int(started["id"])
audio = tmp_path / "local-call.webm"
audio.write_bytes(b"webm-opus-audio")
result = repository.upload_call_recording(
audio,
501,
call_record_id=call_record_id,
mime_type="audio/webm;codecs=opus",
)
record = next(
row for row in repository.list_call_records(501) if row["id"] == call_record_id
)
assert result["media_kind"] == "local_audio"
assert result["call_record_id"] == call_record_id
assert record["local_audio_status"] == 2
assert record["local_audio_status_text"] == "已保存"
assert record["local_audio_urls_list"] == [result["file_url"]]
assert record["recording_urls_list"] == []
assert record["transcript_text"] == ""
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
"""List parsing handles nullable fields, aliases and non-object rows safely."""
+620
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import date
from typing import Any
@@ -40,8 +41,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -305,6 +309,64 @@ def test_openai_failure_keeps_new_qwen_snapshot(
page.close()
def test_finished_only_cached_regeneration_unlocks_retry_and_keeps_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(109, 301, 509)
saved = _snapshot("qwen", 1, "2026-08-14 10:45:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": [saved]})
jobs[1]["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_analysis_regenerate_button.isEnabled()
page.ai_analysis_regenerate_button.click()
assert len(jobs) == 3
assert page._ai_analysis_regenerating
assert not page.ai_analysis_regenerate_button.isEnabled()
jobs[2]["on_finished"]()
assert not page._ai_analysis_regenerating
assert page._ai_analysis_regeneration_model is None
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
assert page.ai_analysis_regenerate_button.isEnabled()
assert "未返回有效结果" in page.ai_analysis_secondary_status.text()
page.close()
def test_late_patient_history_response_is_discarded_after_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -353,6 +415,7 @@ def test_late_patient_history_response_is_discarded_after_switch(
first_history_job = jobs[1]
page._select_record(second["appointment"])
assert first_history_job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
finish(jobs[2])
second_history_job = jobs[3]
finish(second_history_job)
@@ -364,6 +427,563 @@ def test_late_patient_history_response_is_discarded_after_switch(
page.close()
def test_aba_switch_attaches_to_inflight_patient_generation_without_duplicate_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(110, 301, 510)
second = _detail(111, 302, 511)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:00:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
first_qwen_job = jobs[2]
qwen_result = first_qwen_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 5
first_qwen_job["on_success"](qwen_result)
first_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == 6
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_history_get_is_singleflight_across_aba_switch(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(112, 301, 512)
second = _detail(113, 302, 513)
saved = _snapshot("qwen", 1, "2026-08-14 12:10:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history exists: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
first_list_job = jobs[1]
list_result = first_list_job["function"]()
page._select_record(second["appointment"])
page._select_record(first["appointment"])
jobs[3]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 4
assert repository.list_calls == [301]
first_list_job["on_success"](list_result)
first_list_job["on_finished"]()
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
def test_patient_ai_finished_only_tracks_qwen_workers_not_unrelated_openai(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(114, 301, 514)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"worker must remain queued: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
current_list_job = jobs[1]
stale_qwen = (page._ai_analysis_generation - 2, 114, 301, "qwen")
stale_openai = (page._ai_analysis_generation - 1, 114, 301, "openai")
page._patient_ai_generation_requests.add(stale_qwen)
page._patient_ai_generation_finished(*stale_qwen)
assert page._ai_analysis_model_states["qwen"] == "loading"
page._patient_ai_generation_requests.add(stale_openai)
current_list_job["on_finished"]()
page._patient_ai_generation_finished(*stale_openai)
assert not page._patient_ai_list_requests
assert not page._patient_ai_generation_requests
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert "未返回有效结果" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_aba_cancelled_generation_is_replaced_instead_of_becoming_false_error(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(115, 301, 515)
second = _detail(116, 302, 516)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
5.0,
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 1, "2026-08-14 12:20:00")
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
old_qwen_job = jobs[2]
with ThreadPoolExecutor(max_workers=1) as executor:
cancelled_future = executor.submit(old_qwen_job["function"])
page._select_record(second["appointment"])
cancelled = cancelled_future.result(timeout=1.0)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
assert cancelled is reception_module._ASYNC_REQUEST_CANCELLED
assert repository.generate_calls == []
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
assert len(jobs) == 6
current_list_job = jobs[5]
current_list_job["on_success"]({"patient_id": 301, "reports": []})
assert len(jobs) == 7
replacement_qwen_job = jobs[6]
current_list_job["on_finished"]()
assert len(jobs) == 7
qwen_result = replacement_qwen_job["function"]()
replacement_qwen_job["on_success"](qwen_result)
replacement_qwen_job["on_finished"]()
jobs_after_replacement = len(jobs)
old_qwen_job["on_success"](cancelled)
old_qwen_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_replacement
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 1 版诊断建议"
page.close()
application.processEvents()
@pytest.mark.parametrize("late_completion", ["cancelled", "error"])
def test_late_history_completion_cannot_clear_newer_generated_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
late_completion: str,
) -> None:
detail = _detail(117, 301, 517)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"history worker remains pending: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
self.generate_calls.append((patient_id, model))
generated = _snapshot(model, 9, "2026-08-14 12:30:00")
generated["id"] = 901 if model == "qwen" else 902
generated["patient_id"] = patient_id
return {
"patient_id": patient_id,
"generated_report": generated,
"report": generated,
}
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
qwen_job = jobs[2]
qwen_result = qwen_job["function"]()
qwen_job["on_success"](qwen_result)
qwen_job["on_finished"]()
jobs_after_generation = len(jobs)
if late_completion == "cancelled":
history_job["on_success"](reception_module._ASYNC_REQUEST_CANCELLED)
else:
history_job["on_error"](RuntimeError("late history failure"))
history_job["on_finished"]()
assert repository.generate_calls == [(301, "qwen")]
assert len(jobs) == jobs_after_generation
assert page._ai_analysis_model_states["qwen"] == "success"
assert page._ai_analysis_payloads["qwen"]["id"] == 901
assert page.ai_summary_label.text() == "千问第 9 版诊断建议"
page.close()
application.processEvents()
def test_cancelled_queued_generation_does_not_invalidate_valid_history_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = _detail(118, 301, 518)
second = _detail(119, 302, 519)
saved = _snapshot("qwen", 10, "2026-08-14 12:40:00")
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
monkeypatch.setattr(reception_module, "run_async", queue)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
return {"patient_id": patient_id, "reports": [saved]}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("cancelled queued POST must not enter repository")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(first["appointment"])
jobs[0]["on_success"]({"detail": first, "warnings": []})
history_job = jobs[1]
history_result = history_job["function"]()
current_context = page._current_patient_ai_context(301)
assert current_context is not None
generation, appointment_id, _patient_id = current_context
page._request_patient_ai_generation("qwen", generation, appointment_id, 301)
queued_qwen_job = jobs[2]
page._select_record(second["appointment"])
cancelled = queued_qwen_job["function"]()
page._select_record(first["appointment"])
jobs[4]["on_success"]({"detail": first, "warnings": []})
queued_qwen_job["on_success"](cancelled)
queued_qwen_job["on_finished"]()
history_job["on_success"](history_result)
history_job["on_finished"]()
assert repository.list_calls == [301]
assert repository.generate_calls == []
assert len(jobs) == 5
assert page._ai_analysis_model_states["qwen"] == "success"
assert page.ai_summary_label.text() == "千问第 10 版诊断建议"
page.close()
application.processEvents()
def test_saturated_automatic_ai_slots_end_in_retryable_state_without_post(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(120, 301, 520)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.generate_calls: list[tuple[int, str]] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
return {"patient_id": patient_id, "reports": []}
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
self.generate_calls.append((patient_id, model))
raise AssertionError("busy automatic work must not submit a POST")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
jobs[1]["on_success"]({"patient_id": 301, "reports": []})
automatic_qwen_job = jobs[2]
deferred = automatic_qwen_job["function"]()
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.generate_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert not page._ai_analysis_regenerating
assert page.ai_analysis_regenerate_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_saturated_history_slots_end_in_retryable_state_without_get(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(121, 301, 521)
jobs: list[dict[str, Any]] = []
def queue(function: Any, *args: Any, **options: Any) -> object:
jobs.append({"function": function, "args": args, **options})
return object()
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(reception_module, "run_async", queue)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def __init__(self) -> None:
self.list_calls: list[int] = []
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
self.list_calls.append(patient_id)
raise AssertionError("busy automatic read must not enter repository")
def generate_patient_ai_report(self, patient_id: int, *, model: str) -> Any:
raise AssertionError(f"history did not complete: {patient_id}/{model}")
repository = Repository()
page = ReceptionPage(
repository,
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
jobs[0]["on_success"]({"detail": detail, "warnings": []})
history_job = jobs[1]
deferred = history_job["function"]()
history_job["on_success"](deferred)
history_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert repository.list_calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "查询任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_get_history_requires_exact_top_level_and_row_patient_ids() -> None:
row = _snapshot("qwen", 1, "2026-08-14 11:00:00")
valid = {"patient_id": 301, "reports": [row]}
+25 -5
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtCore import QDate, QPoint
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
@@ -636,15 +636,35 @@ def test_patient_list_reference_geometry_and_row_actions(
button.minimumHeight() == 44 and button.maximumHeight() == 44
for button in workspace.summary_buttons.values()
)
assert all(
widget.minimumWidth() == 0 and widget.maximumWidth() > 1000
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost"
assert (
workspace.keyword_edit.maximumWidth(),
workspace.status_host.maximumWidth(),
workspace.quick_host.maximumWidth(),
workspace.date_host.maximumWidth(),
) == (620, 440, 620, 420)
assert workspace.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514):
page.resize(width, 680)
application.processEvents()
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.search_button,
workspace.reset_button,
workspace.quick_host,
workspace.date_host,
)
)
workspace.custom_date_button,
):
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
assert top_left.x() >= 0
assert top_left.x() + widget.width() <= workspace.filter_card.width()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
+413 -2
View File
@@ -10,14 +10,32 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QDate, QPoint, Qt
from PySide6.QtCore import (
QDate,
QEvent,
QEventLoop,
QObject,
QPoint,
Qt,
QThreadPool,
QTimer,
)
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget
from PySide6.QtWidgets import (
QApplication,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import api_client as api_client_module
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui import widgets as widgets_module
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import (
NOTE_LIMIT,
@@ -41,8 +59,11 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
pool: Any = None,
priority: int = 0,
**kwargs: Any,
) -> object:
del pool, priority
try:
result = function(*args, **kwargs)
except Exception as error:
@@ -79,6 +100,135 @@ def queued_async(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
return jobs
def test_api_client_reuses_a_bounded_transport_across_qt_runnables(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created: list[Any] = []
class PooledClient:
def __init__(self, **_options: Any) -> None:
self.closed = False
created.append(self)
def request(self, _method: str, url: str, **_options: Any) -> httpx.Response:
return httpx.Response(200, json={"code": 1, "data": url})
def close(self) -> None:
self.closed = True
monkeypatch.setattr(api_client_module.httpx, "Client", PooledClient)
client = ApiClient("https://example.test", max_parallel_requests=2)
pool = QThreadPool()
pool.setMaxThreadCount(1)
pool.setExpiryTimeout(-1)
remaining = 40
results: list[str] = []
loop = QEventLoop()
def finished() -> None:
nonlocal remaining
remaining -= 1
if remaining == 0:
loop.quit()
for index in range(remaining):
widgets_module.run_async(
lambda index=index: client.get(f"patient/{index}"),
on_success=results.append,
on_finished=finished,
pool=pool,
)
QTimer.singleShot(5_000, loop.quit)
loop.exec()
assert pool.waitForDone(2_000)
client.close()
assert remaining == 0
assert len(results) == 40
assert len(created) == 1
assert created[0].closed
application.processEvents()
def test_clearing_ai_layout_does_not_promote_children_to_windows(
application: QApplication,
) -> None:
host = QWidget()
layout = QVBoxLayout(host)
dynamic_label = QLabel("正在加载患者 AI 分析", host)
layout.addWidget(dynamic_label)
host.show()
application.processEvents()
destroyed: list[bool] = []
dynamic_label.destroyed.connect(lambda: destroyed.append(True))
reception_module._clear_ai_layout(layout)
assert layout.count() == 0
assert destroyed == [True]
assert dynamic_label not in application.topLevelWidgets()
host.close()
host.deleteLater()
def test_queue_row_never_shows_loading_fields_as_parentless_windows(
application: QApplication,
) -> None:
tracked_names = {
"StatusBadge",
"ReceptionQueueTime",
"ReceptionQueueMetric",
}
class OrphanShowRecorder(QObject):
def __init__(self) -> None:
super().__init__()
self.object_names: list[str] = []
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QWidget)
and watched.objectName() in tracked_names
and watched.parentWidget() is None
and watched.isWindow()
):
self.object_names.append(watched.objectName())
return False
recorder = OrphanShowRecorder()
application.installEventFilter(recorder)
try:
active_row = QueueRow(
{
"patient_name": "问诊患者",
"status": 2,
"status_desc": "问诊中",
"fasting_blood_sugar": 8.6,
}
)
waiting_row = QueueRow(
{
"patient_name": "待接诊患者",
"status": 1,
"appointment_time": "14:30",
}
)
finally:
application.removeEventFilter(recorder)
assert recorder.object_names == []
for row in (active_row, waiting_row):
for object_name in tracked_names:
widget = row.findChild(QWidget, object_name)
assert widget is not None
assert widget.parentWidget() is row
assert not widget.isWindow()
row.deleteLater()
@pytest.mark.parametrize(
("path", "expected"),
[
@@ -714,6 +864,101 @@ def test_fast_patient_switch_rejects_late_detail(
application.processEvents()
def test_rapid_aba_switch_prioritizes_current_detail_and_skips_stale_bundles(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(73, name="甲患者")
second = _detail(74, name="乙患者")
class Repository:
def __init__(self) -> None:
self.calls: list[int] = []
def get_reception(self, appointment_id: int) -> dict[str, Any]:
self.calls.append(appointment_id)
return first if appointment_id == 73 else second
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
page._select_record(second["appointment"])
page._select_record(first["appointment"])
assert [job["priority"] for job in queued_async] == [1, 2, 3]
for stale_job in queued_async[:2]:
assert stale_job["function"]() == {"cancelled": True}
stale_job["on_finished"]()
assert repository.calls == []
current_job = queued_async[2]
current_job["on_success"](current_job["function"]())
current_job["on_finished"]()
assert repository.calls == [73]
assert page._selected_appointment_id == 73
assert page.patient_name_label.text() == "甲患者"
assert not page._detail_loading
page.close()
application.processEvents()
def test_switching_patient_resets_stale_daily_panel_loading(
application: QApplication,
) -> None:
page = ReceptionPage(object(), PermissionSet([]))
page.daily_panel.set_loading(True)
assert page.daily_panel._loading
assert not page.daily_panel.refresh_button.isEnabled()
page._reset_detail_content(
seed={"id": 75, "patient_name": "新患者", "diagnosis_id": 275}
)
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert all(button.isEnabled() for button in page.daily_panel.range_buttons.values())
assert page.daily_panel.start_date.isEnabled()
assert page.daily_panel.end_date.isEnabled()
page.close()
application.processEvents()
def test_stale_daily_worker_skips_repository_after_patient_switch(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
first = _detail(76, name="日常甲患者")
second = _detail(77, name="日常乙患者")
class Repository:
def __init__(self) -> None:
self.tracking_calls: list[int] = []
def get_tracking_window(self, diagnosis_id: int, **_options: Any) -> dict[str, Any]:
self.tracking_calls.append(diagnosis_id)
return {}
repository = Repository()
page = ReceptionPage(repository, PermissionSet([]))
page._select_record(first["appointment"])
queued_async[0]["on_success"]({"detail": first, "warnings": []})
page._request_daily_range("2026-08-13", "2026-08-19")
stale_daily_job = queued_async[1]
page._select_record(second["appointment"])
assert stale_daily_job["function"]() == {"cancelled": True}
assert repository.tracking_calls == []
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summary(
application: QApplication,
) -> None:
@@ -1169,6 +1414,28 @@ def test_phone_permission_and_ungated_notify_video_actions(
application.processEvents()
def test_im_consult_is_visible_immediately_after_history(
application: QApplication,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page.resize(1280, 760)
page.detail_stack.setCurrentIndex(1)
page.show()
application.processEvents()
assert page.video_button.text() == "IM 问诊"
assert page.video_button.objectName() == "ReceptionImButton"
assert not page.video_button.isHidden()
assert page.history_button.geometry().right() < page.video_button.geometry().left()
assert page.video_button.geometry().right() < page.more_button.geometry().left()
assert "IM 问诊" not in [
action.text() for action in page.more_button.menu().actions()
]
page.close()
application.processEvents()
def test_reception_ai_report_button_follows_permission(
application: QApplication,
immediate_async: None,
@@ -1423,6 +1690,65 @@ def test_reception_dual_ai_queues_openai_only_after_qwen_is_visible(
application.processEvents()
def test_saturated_fallback_ai_slots_do_not_leave_current_analysis_loading(
application: QApplication,
queued_async: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
detail = _detail(86, name="后台分析繁忙患者")
calls: list[tuple[int, str]] = []
class BusyAutomaticSlots:
@staticmethod
def acquire(*, blocking: bool) -> bool:
assert not blocking
return False
@staticmethod
def release() -> None:
raise AssertionError("an unacquired slot must not be released")
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS",
0.0,
)
monkeypatch.setattr(
reception_module,
"_AI_AUTOMATIC_REQUEST_SLOTS",
BusyAutomaticSlots(),
)
class Repository:
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
model: str,
) -> dict[str, Any]:
calls.append((diagnosis_id, model))
raise AssertionError("busy automatic work must not enter repository")
page = ReceptionPage(
Repository(),
PermissionSet(["tcm.diagnosis/aiAnalysis"]),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
automatic_qwen_job = queued_async[1]
deferred = automatic_qwen_job["function"](*automatic_qwen_job["args"])
automatic_qwen_job["on_success"](deferred)
automatic_qwen_job["on_finished"]()
assert deferred is reception_module._ASYNC_REQUEST_DEFERRED
assert calls == []
assert page._ai_analysis_model_states["qwen"] == "error"
assert not page._ai_analysis_loading
assert page.ai_analysis_retry_button.isEnabled()
assert "后台分析任务较多" in page.ai_analysis_state_label.text()
page.close()
application.processEvents()
def test_reception_openai_failure_keeps_qwen_success_visible(
application: QApplication,
queued_async: list[dict[str, Any]],
@@ -1886,6 +2212,89 @@ def test_detail_failure_stops_ai_loading_and_keeps_retry_available(
jobs[0]["on_finished"]()
assert not page._detail_loading
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
page.close()
application.processEvents()
def test_finished_only_detail_request_cannot_leave_loading_placeholders(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
page = ReceptionPage(object(), PermissionSet(["*"]))
record = {
"id": 86,
"patient_id": 186,
"diagnosis_id": 286,
"patient_name": "无结果患者",
}
page._select_record(record)
queued_async[0]["on_finished"]()
assert not page._detail_loading
assert page._selected_detail is None
assert page.detail_banner.property("kind") == "danger"
assert "正在" not in page.patient_meta_label.text()
assert "正在" not in page.diagnosis_text.text()
assert "正在" not in page.health_summary_label.text()
assert "正在" not in page.followup_text.text()
assert not page.daily_panel._loading
assert page.daily_panel.refresh_button.isEnabled()
assert page._ai_analysis_state == "error"
page.close()
application.processEvents()
def test_finished_only_patient_ai_query_becomes_retryable(
application: QApplication,
queued_async: list[dict[str, Any]],
) -> None:
detail = _detail(87, name="AI 无结果患者")
class Repository:
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}")
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: str,
) -> dict[str, Any]:
raise AssertionError(f"queued worker must not run inline: {patient_id}/{model}")
page = ReceptionPage(
Repository(),
PermissionSet(
[
"tcm.diagnosis/patientAiReports",
"tcm.diagnosis/generatePatientAiReport",
]
),
)
page._select_record(detail["appointment"])
queued_async[0]["on_success"]({"detail": detail, "warnings": []})
assert len(queued_async) == 2
assert page._ai_analysis_state == "loading"
queued_async[1]["on_finished"]()
assert page._ai_analysis_state == "error"
assert not page._ai_analysis_loading
assert "请重试" in page.ai_analysis_state_label.text()
assert not page.ai_analysis_retry_button.isHidden()
page.ai_analysis_retry_button.click()
assert len(queued_async) == 3
assert page._ai_analysis_state == "loading"
page.close()
application.processEvents()
@@ -1960,6 +2369,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 2
page._select_record(second["appointment"])
assert queued_async[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[2]["on_success"]({"detail": second, "warnings": []})
assert len(queued_async) == 4
second_qwen = _analysis_payload("第二位千问风险")
@@ -1968,6 +2378,7 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
assert len(queued_async) == 5
page._select_record(third["appointment"])
assert queued_async[4]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
queued_async[5]["on_success"]({"detail": third, "warnings": []})
assert len(queued_async) == 7
third_qwen = _analysis_payload("第三位千问风险")
+104
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import Any
import pytest
@@ -480,6 +481,81 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
]
def test_remote_call_record_identity_is_reused_for_room_recording_and_end() -> None:
"""Room binding and COS finalization must never select a different latest call."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.bind_call_room(501, " room-901 ", call_record_id=901)
repository.end_call(501, call_record_id=901)
assert client.post_calls == [
(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": 501, "room_id": "room-901", "call_record_id": 901},
),
(
"tcm.diagnosis/endCall",
{"diagnosis_id": 501, "call_record_id": 901},
),
]
def test_remote_local_audio_upload_keeps_exact_call_identity_and_mime(
tmp_path: Path,
) -> None:
class MultipartClient(RecordingClient):
def __init__(self) -> None:
super().__init__()
self.multipart_calls: list[
tuple[str, dict[str, tuple[str, bytes, str]], dict[str, Any]]
] = []
def post_multipart(
self,
endpoint: str,
*,
files: dict[str, tuple[str, bytes, str]],
data: dict[str, Any],
) -> dict[str, Any]:
self.multipart_calls.append((endpoint, files, data))
completed = int(data["chunk_index"]) == int(data["chunk_total"]) - 1
return {
"call_record_id": 901,
"completed": completed,
"file_url": "https://cos.example.test/calls/local-audio.webm"
if completed
else "",
"media_kind": "local_audio",
}
recording = tmp_path / "local-audio.webm"
recording.write_bytes(b"a" * (4 * 1024 * 1024 + 3))
client = MultipartClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repository.upload_call_recording(
recording,
501,
call_record_id=901,
mime_type="audio/webm;codecs=opus",
)
assert result["completed"] is True
assert result["call_record_id"] == 901
assert result["media_kind"] == "local_audio"
assert len(client.multipart_calls) == 2
assert all(call[0] == "tcm.diagnosis/uploadCallRecording" for call in client.multipart_calls)
for _endpoint, files, data in client.multipart_calls:
assert data["diagnosis_id"] == 501
assert data["call_record_id"] == 901
assert data["mime_type"] == "audio/webm;codecs=opus"
assert str(data["upload_id"]).startswith("local_audio_")
assert files["file"][0] == "local-audio.webm"
assert files["file"][2] == "audio/webm;codecs=opus"
@pytest.mark.parametrize(
"response",
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
@@ -645,6 +721,34 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None:
assert client.timeouts == [105.0]
def test_ai_post_type_error_after_dispatch_is_never_retried() -> None:
class FailingAfterDispatchClient:
token = "token"
def __init__(self) -> None:
self.calls = 0
def post(
self,
endpoint: str,
payload: dict[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
assert endpoint == "tcm.diagnosis/generatePatientAiReport"
assert payload == {"patient_id": 301, "model": "qwen"}
assert timeout == 105.0
self.calls += 1
raise TypeError("transport failed after dispatch")
client = FailingAfterDispatchClient()
with pytest.raises(TypeError, match="after dispatch"):
RemoteDoctorRepository(client).generate_patient_ai_report(301, model="qwen")
assert client.calls == 1
def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
class StreamingClient(RecordingClient):
def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any):
+118 -7
View File
@@ -6,11 +6,17 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidget
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
def _logical_pixel(image: Any, x: int, y: int):
device_scale = image.devicePixelRatio()
return image.pixelColor(round(x * device_scale), round(y * device_scale))
class _ShellPageDouble(QWidget):
@@ -39,7 +45,26 @@ class _ShellPageDouble(QWidget):
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
app = QApplication.instance() or QApplication([])
apply_theme(app)
return app
@pytest.mark.parametrize(
("available_size", "expected_size"),
[
(QSize(1920, 1080), QSize(1710, 920)),
(QSize(1486, 1000), QSize(1486, 920)),
(QSize(1600, 800), QSize(1600, 800)),
(QSize(800, 600), QSize(1024, 640)),
(None, QSize(1710, 920)),
],
)
def test_shell_initial_size_is_bounded_by_logical_available_geometry(
available_size: QSize | None,
expected_size: QSize,
) -> None:
assert shell_module._bounded_initial_window_size(available_size) == expected_size
def test_patients_navigation_keeps_the_product_menu_title() -> None:
@@ -145,14 +170,60 @@ def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(20, 300).name().lower() in {
assert _logical_pixel(image, 20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
assert image.pixelColor(610, 20).name().lower() == "#ffffff"
assert image.pixelColor(220, 90).name().lower() == "#fcfdfe"
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
application: QApplication,
shell_window: ShellWindow,
) -> None:
for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height)
application.processEvents()
search_host = shell_window.topbar.findChild(QFrame, "ShellGlobalSearch")
shortcut_hint = search_host.findChild(QWidget, "ShellShortcutHint")
assert search_host.size().toTuple() == (265, 36)
assert shell_window.fold_button.size().toTuple() == (38, 38)
assert shortcut_hint.size() == shortcut_hint.sizeHint()
assert (
abs(search_host.geometry().center().y() - shell_window.fold_button.geometry().center().y())
<= 1
)
assert (
abs(
shortcut_hint.mapTo(search_host, shortcut_hint.rect().center()).y()
- search_host.rect().center().y()
)
<= 1
)
shell_window.global_search.setText("患者")
application.processEvents()
action_buttons = shell_window.global_search.findChildren(QToolButton)
assert len(action_buttons) == 2
for button in action_buttons:
assert button.size().toTuple() == (22, 18)
assert shell_window.global_search.rect().contains(button.geometry())
assert (
abs(
button.geometry().center().y()
- shell_window.global_search.rect().center().y()
)
<= 1
)
clear_button = max(action_buttons, key=lambda button: button.x())
clear_right = clear_button.mapTo(search_host, clear_button.rect().topRight()).x()
assert clear_right < shortcut_hint.x()
shell_window.global_search.clear()
def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -> None:
@@ -170,7 +241,11 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
)
assert shell_window.assistant_card.isVisible()
assert shell_window.assistant_button.text() == "开始对话"
assert "GPT-4o 医疗版" in shell_window.model_label.text()
assert shell_window.upload_settings_button.text().strip().startswith("设置")
assert (
shell_window.upload_settings_button.accessibleName() == "本机录音上传设置"
)
assert shell_window.model_label is shell_window.upload_settings_button
assert shell_window.minimize_button.text() == ""
assert shell_window.close_button.text() == ""
@@ -180,6 +255,42 @@ def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
assert "在线" in shell_window.assistant_status.text()
def test_shell_settings_opens_global_local_audio_upload_manager(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
def dialog_factory(
repository: Any,
diagnosis_id: int | None,
parent: QWidget,
) -> QDialog:
dialog = QDialog(parent)
dialog.setObjectName("LocalAudioQueueDialog")
captured.update(
repository=repository,
diagnosis_id=diagnosis_id,
parent=parent,
dialog=dialog,
)
return dialog
monkeypatch.setattr(shell_module, "LocalAudioQueueDialog", dialog_factory)
shell_window.upload_settings_button.click()
application.processEvents()
assert captured["repository"] is shell_window.repository
assert captured["diagnosis_id"] is None
assert captured["parent"] is shell_window
assert captured["dialog"].isVisible()
captured["dialog"].reject()
application.processEvents()
assert shell_window._local_audio_settings_dialog is None
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
shell_window: ShellWindow,
) -> None:
+386 -6
View File
@@ -28,6 +28,233 @@ from doctor_workstation.video.security import ( # noqa: E402
)
def test_companion_uses_legacy_safe_transcription_session_identity() -> None:
"""Generated session IDs stay below 32 chars so upgraded databases cannot truncate."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
function_source = source.split("function newTranscriptionSessionId", 1)[1].split(
"function requestTranscriptionStart", 1
)[0]
assert "replaceAll('-', '')" in function_source
assert ".slice(0, 28)" in function_source
assert "return `tr-${" in function_source
def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> None:
"""A connected call starts three independent artifacts before hangup."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "context.createMediaStreamDestination()" in source
assert "cloud.getAudioTrack({ processed: true })" in source
assert "userId: activeConfig.targetUserId" in source
assert "new MediaRecorder(destination.stream" in source
assert "recorder.start(1000)" in source
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
assert "bridge.appendLocalAudioChunk(" in source
assert "bridge.finishLocalAudioRecording(sessionId, totalBytes)" in source
assert "operations.push(stopLocalRecording())" in source
assert "operations.push(stopTranscription('completed'))" in source
assert "Promise.allSettled(operations)" in source
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
"""A slowly-created TRTC room must still bind to the exact call record."""
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
room_source = source.split("function readRoomId", 1)[1].split(
"function handleStatusChanged", 1
)[0]
assert "TUIStore.watch(StoreName.CALL, roomIdWatchOptions)" in room_source
assert "[NAME.ROOM_ID]: handleRoomIdChanged" in room_source
assert "cycle === callCycleGeneration" in room_source
assert "while (activeConfig && !endNotified" in room_source
assert "attempt < 40" not in room_source
assert "diagnosisId: activeConfig.diagnosisId" in room_source
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
companion_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
window_source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "observeRoomId(roomId)" in companion_source
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
assert "function roomBindingResult(" in companion_source
assert "if (boundRoomId) return" in companion_source
assert "roomBindingResult?.(" in window_source
def test_im_conversation_renders_deduplicated_video_call_status_timeline() -> None:
"""Video lifecycle feedback belongs in the IM timeline as local status events."""
companion_root = PROJECT_ROOT / "video_companion" / "src"
source = (companion_root / "main.ts").read_text(encoding="utf-8")
app_source = (companion_root / "App.vue").read_text(encoding="utf-8")
styles = (companion_root / "style.css").read_text(encoding="utf-8")
timeline_source = source.split("function appendVideoCallStatus", 1)[1].split(
"function onMessageReceived", 1
)[0]
assert "activeConfig.mode !== 'chat'" in timeline_source
assert "local-video-call-${callCycleGeneration}-${callStatus}" in timeline_source
assert "findIndex((item) => item.id === id)" in timeline_source
assert "appendVideoCallStatus('starting', '正在创建安全视频通话')" in source
assert "appendVideoCallStatus('dialing', '正在呼叫患者')" in source
assert "appendVideoCallStatus('connected', '视频通话已接通')" in source
assert "appendVideoCallStatus('ended', '视频通话已结束')" in source
assert "appendVideoCallStatus('failed', `视频通话发起失败:${message}`)" in source
assert "message.type === 'call-status'" in app_source
assert 'class="call-status-event"' in app_source
assert 'role="status"' in app_source
assert "IM 已连接 · ${props.statusText.value}" in app_source
assert ".message-row--call-status" in styles
assert ".call-status-event--connected" in styles
assert ".call-status-event--failed" in styles
def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallbacks() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
assert "querySelectorAll<HTMLMediaElement>('video, audio')" in source
assert "stream.getAudioTracks()" in source
assert "navigator.mediaDevices.getUserMedia" in source
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
assert "localRecordingAttachedSourceCount <= 0" in source
assert "localRecordingBytes < 1024" in source
assert "已阻止上传空文件" in source
def test_qt_close_waits_for_local_audio_finish_before_destroying_webengine() -> None:
"""A title-bar/desktop hangup must keep accepting bridge chunks until COS ack."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
request_shutdown = source.split(
"def _request_companion_shutdown", 1
)[1].split("def _force_requested_shutdown", 1)[0]
begin_shutdown = source.split("def _begin_shutdown", 1)[1].split(
"def wait_for_lifecycles", 1
)[0]
close_event = source.split("def closeEvent", 1)[1].split(
"else:", 1
)[0]
assert "window.doctorConsultation?.close?.()" in request_shutdown
assert "self._shutdown_requested = True" in request_shutdown
assert "self._closing = True" not in request_shutdown
assert "event.ignore()" in close_event
assert "self._request_companion_shutdown" in close_event
assert "self._shutdown_timer.stop()" in begin_shutdown
assert "window.doctorConsultation?.close?.()" not in begin_shutdown
assert "if not self.open_im or self._shutdown_requested" in source
def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() -> None:
"""A later IM call cycle must not relabel an earlier recording."""
source = (
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
).read_text(encoding="utf-8")
assert "lifecycle: OrderedCallLifecycle" in source
assert "lifecycle = capture.lifecycle" in source
assert "call_record_id=call_record_id" in source
assert 'room_id=lifecycle.current_room_id or ""' in source
assert "store.bind_identity(" in source
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
main_source = (
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
).read_text(encoding="utf-8")
component_source = (
PROJECT_ROOT / "video_companion" / "src" / "App.vue"
).read_text(encoding="utf-8")
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
"function subscribeTranscriber", 1
)[0]
assert "showLiveCaption(message)" in handler
assert "if (message.isCompleted !== true) return" in handler
assert handler.index("showLiveCaption(message)") < handler.index(
"if (message.isCompleted !== true) return"
)
assert 'aria-label="实时语音字幕"' in component_source
assert "liveCaptions.value" in component_source
assert "caption.speaker" in component_source
assert "caption.text" in component_source
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
capture = source.split("async function captureScreenshot", 1)[1].split(
"function discardScreenshot", 1
)[0]
confirm = source.split("async function confirmScreenshot", 1)[1].split(
"watch(", 1
)[0]
assert "screenshotPreview.value = canvas.toDataURL" in capture
assert "onSaveScreenshot" not in capture
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
assert "确认画面后再保存到患者资料" in source
assert "确认并上传" in source
assert "取消" in source
def test_companion_loads_im_history_without_an_empty_first_page_cursor() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
encoding="utf-8"
)
load_messages = source.split("async function loadMessages", 1)[1].split(
"async function sendText", 1
)[0]
assert "nextReqMessageID: prepend ? nextReqMessageID : ''" not in load_messages
assert "nextReqMessageID?: string" in load_messages
assert (
"if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID"
in load_messages
)
assert "chat.getMessageList(request)" in load_messages
def test_companion_preserves_im_scroll_position_for_live_and_older_messages() -> None:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
)
watcher = source.split("() => props.messages.value.length", 1)[1].split(
"watch(", 1
)[0]
load_earlier = source.split("async function loadEarlierMessages", 1)[1].split(
"async function runAction", 1
)[0]
assert "if (!stickToMessageBottom.value) return" in watcher
assert "stickToMessageBottom.value = false" in load_earlier
assert "container.scrollHeight - previousHeight" in load_earlier
assert '@scroll="handleMessageScroll"' in source
assert '@click="loadEarlierMessages"' in source
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
request = normalize_backend_ticket(
{
@@ -196,11 +423,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 900}
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> None:
events.append(("bind", diagnosis_id, room_id, call_record_id))
def end_call(self, diagnosis_id: int) -> None:
events.append(("end", diagnosis_id))
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
request = VideoCallRequest(
sdk_app_id=1400123456,
@@ -226,17 +455,19 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
assert lifecycle.wait(0.01) is False
assert duplicate_bind is bind_future
assert changed_bind.result(timeout=0) is False
assert lifecycle.current_room_id == "456789"
release_start.set()
assert start_future.result(timeout=2) is True
assert bind_future.result(timeout=2) is True
assert end_future.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert lifecycle.current_room_id == "456789"
assert events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("end", 123),
("bind", 123, "456789", 900),
("end", 123, 900),
]
@@ -479,6 +710,155 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
assert events == ["start"]
def test_explicit_cos_recording_failure_fails_room_binding_without_losing_call_identity() -> None:
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 904}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 904)
return {
"call_record_id": 904,
"cloud_recording": {
"started": False,
"message": "COS bucket is unavailable",
},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="COS bucket is unavailable"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.call_record_id == 904
assert lifecycle.bound_room_id is None
assert lifecycle.wait(1) is True
def test_failed_room_binding_releases_claim_and_can_retry_same_room() -> None:
bind_attempts = 0
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
del diagnosis_id, patient_id, call_type
return {"call_record_id": 906}
def bind_call_room(
self, diagnosis_id: int, room_id: str, *, call_record_id: int
) -> dict[str, object]:
nonlocal bind_attempts
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 906)
bind_attempts += 1
if bind_attempts == 1:
raise RuntimeError("temporary bind failure")
return {
"call_record_id": 906,
"cloud_recording": {"started": True},
}
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
with pytest.raises(RuntimeError, match="temporary bind failure"):
lifecycle.bind_room("456789").result(timeout=2)
assert lifecycle.current_room_id is None
assert lifecycle.bind_room("456789").result(timeout=2) is True
assert lifecycle.bound_room_id == "456789"
assert lifecycle.current_room_id == "456789"
assert bind_attempts == 2
assert lifecycle.wait(1) is True
def test_local_audio_upload_uses_exact_started_record_and_precedes_end(
tmp_path: Path,
) -> None:
events: list[tuple[object, ...]] = []
class Repository:
def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int
) -> dict[str, int]:
events.append(("start", diagnosis_id, patient_id, call_type))
return {"call_record_id": 905}
def upload_call_recording(
self,
path: Path,
diagnosis_id: int,
*,
call_record_id: int,
mime_type: str,
) -> dict[str, object]:
events.append(
(
"local-audio",
path.read_bytes(),
diagnosis_id,
call_record_id,
mime_type,
)
)
return {
"completed": True,
"call_record_id": call_record_id,
"media_kind": "local_audio",
}
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
events.append(("end", diagnosis_id, call_record_id))
recording = tmp_path / "call-audio.webm"
recording.write_bytes(b"opus-webm-audio")
request = VideoCallRequest(
sdk_app_id=1400123456,
user_id="doctor_42",
user_sig="short-lived-ticket",
target_user_id="patient_8",
diagnosis_id=123,
patient_id=8,
)
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
lifecycle.start()
uploaded = lifecycle.save_local_audio_recording(
recording,
mime_type="audio/webm;codecs=opus",
)
ended = lifecycle.end("doctor-hangup")
assert uploaded.result(timeout=2) is True
assert ended.result(timeout=2) is True
assert lifecycle.wait(1) is True
assert events == [
("start", 123, 8, 2),
("local-audio", b"opus-webm-audio", 123, 905, "audio/webm;codecs=opus"),
("end", 123, 905),
]
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
events: list[tuple[object, ...]] = []