更新bug
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("第三位千问风险")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, ...]] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user