This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
+7 -1
View File
@@ -278,8 +278,10 @@ def test_field_order_density_and_conditional_channel_row(
application.processEvents()
@pytest.mark.parametrize("mode", ["video", "text"])
def test_roster_slot_states_conflict_refresh_and_submit_contract(
application: QApplication,
mode: str,
) -> None:
repository = _VisualRepository(today_conflict=True)
host, drawer = _show_drawer(
@@ -318,6 +320,10 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
assert unavailable.status_label.isVisible()
assert unavailable.accessibleName() == "10:00-10:30 已约"
available.click()
if mode == "text":
drawer.text_appointment_type_radio.click()
assert drawer.text_appointment_type_radio.isChecked()
assert not drawer.appointment_type_radio.isChecked()
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
assert drawer.ok_button.isEnabled()
@@ -328,7 +334,7 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
"appointment_date": tomorrow,
"appointment_time": "09:30-10:00",
"period": "all",
"appointment_type": "video",
"appointment_type": mode,
"remark": "",
"channel_source": "online",
"channel_source_detail": "",
+1 -1
View File
@@ -742,7 +742,7 @@ def test_im_entry_does_not_require_a_live_video_hint(
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频"
assert waiting.toolTip() == "打开患者 IM,可发送消息;通话能力由本次挂号决定"
page.close()
application.processEvents()
+5 -5
View File
@@ -52,14 +52,14 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
row = _row()
before = copy.deepcopy(row)
assert _blue_appointment_text(row, row["appointments"][0]) == (
"陈医生(演示)", "2026-09-05 09:00-09:30",
"陈医生(演示)", "2026-09-05 09:00-09:30 · 视频问诊",
)
assert _blue_appointment_text(row, row["appointments"][1]) == (
"", "2026-08-06 时间 —",
"", "2026-08-06 时间 — · 视频问诊",
)
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30 · 视频问诊")
assert _blue_appointment_text(row, {}) == ("", "时间 —")
for missing_id in (0, "", None):
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("", "时间 —")
@@ -70,13 +70,13 @@ def test_display_fallback_requires_exact_current_appointment_id(application: QAp
host.set_rows([row])
assert "陈医生" not in legacy.model.index(0, 4).data()
assert blue.model.index(0, 4).data().splitlines() == [
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
"陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊", "— · 2026-08-06 时间 — · 视频问诊",
]
assert blue.model.index(0, 9).data() == ""
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
assert blue.model.index(0, 4).data() == "— · 时间 —"
blue.set_rows([{**row, "appointments": []}])
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30 · 视频问诊"
legacy.close()
blue.close()
+4 -1
View File
@@ -279,8 +279,10 @@ def test_workspace_workers_use_gui_thread_query_snapshots(
application.processEvents()
@pytest.mark.parametrize("mode", ["video", "text"])
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
application: QApplication,
mode: str,
immediate_async: None,
) -> None:
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
@@ -317,6 +319,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
"doctor_id": 77,
}
dialog = _AppointmentDialog(row, repository=Repository())
dialog.appointment_type.setCurrentIndex(dialog.appointment_type.findData(mode))
application.processEvents()
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
@@ -339,7 +342,7 @@ def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
"appointment_date": tomorrow,
"appointment_time": "09:30-10:00",
"period": "all",
"appointment_type": "video",
"appointment_type": mode,
"remark": "复诊预约",
"channel_source": "online",
"channel_source_detail": "",
+5
View File
@@ -2565,11 +2565,14 @@ def test_reception_ai_analysis_discards_late_qwen_and_openai_results(
application.processEvents()
@pytest.mark.parametrize("mode", [None, "text"])
def test_video_payload_keeps_three_identifiers_distinct(
application: QApplication,
immediate_async: None,
mode: str | None,
) -> None:
detail = _detail(41, name="视频患者")
detail["appointment"]["appointment_type"] = mode
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
@@ -2586,6 +2589,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
{
"source": "reception",
"appointment_id": 41,
"appointment_type": mode,
"patient_id": 141,
"diagnosis_id": 241,
"patient_name": "视频患者",
@@ -2593,6 +2597,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
"record": detail["appointment"],
}
]
assert page.video_button.text() == ("图文沟通" if mode == "text" else "IM 问诊")
page.close()
application.processEvents()
+1 -1
View File
@@ -476,7 +476,7 @@ def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
assert client.post_calls == [
(
"tcm.diagnosis/startCall",
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2, "appointment_id": 0},
)
]
+277
View File
@@ -0,0 +1,277 @@
"""Appointment policy must survive repository, launcher and lifecycle boundaries."""
import logging
from types import MethodType, SimpleNamespace
import pytest
from doctor_workstation.core.models import CallTicket
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.video.launcher import normalize_backend_ticket
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
def ticket(**policy):
return CallTicket.from_dict({
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
"appointment_id": 456, "appointment_type": "text", **policy,
})
@pytest.mark.parametrize("value", [None, False, 0, 1, "true", "1", []])
def test_only_actual_server_true_authorizes_calls(value):
request = normalize_backend_ticket(ticket(can_video_call=value, can_audio_call=value))
assert request.appointment_id == 456
assert request.appointment_type == "text"
assert request.patient_id == 8
assert request.to_web_config()["can_video_call"] is False
assert request.to_web_config()["can_audio_call"] is False
def test_raw_policy_preserved_and_missing_policy_denies():
request = normalize_backend_ticket(ticket())
assert not request.can_video_call and not request.can_audio_call
request = normalize_backend_ticket(ticket(appointment_type="phone", can_audio_call=True))
assert request.can_audio_call and not request.can_video_call
def test_repository_forwards_exact_appointment_and_media_type():
class Client:
def __init__(self):
self.calls = []
def post(self, endpoint, payload):
self.calls.append((endpoint, payload))
return {"call_record_id": 99} if endpoint.endswith("startCall") else ticket().raw
client = Client()
repository = RemoteDoctorRepository(client)
assert repository.get_call_ticket(8, 123, appointment_id=456).raw["appointment_type"] == "text"
repository.start_call(123, 8, call_type=1, appointment_id=456)
assert client.calls == [
("tcm.diagnosis/getCallSignature", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}),
("tcm.diagnosis/startCall", {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456, "call_type": 1}),
]
def test_lifecycle_refreshes_exact_identity_and_uses_actual_media():
class Repository:
def __init__(self):
self.calls = []
self.appointment_id = 456
def get_call_ticket(self, **payload):
self.calls.append(payload)
return ticket(appointment_id=self.appointment_id, can_audio_call=True)
def start_call(self, **payload):
self.calls.append(payload)
return {"call_record_id": 99}
def end_call(self, **payload):
return {}
repository = Repository()
lifecycle = OrderedCallLifecycle(normalize_backend_ticket(ticket()), repository, logging.getLogger(__name__))
try:
assert lifecycle.refresh_call_policy().result(timeout=2)["can_audio_call"] is True
assert repository.calls[-1] == {"patient_id": 8, "diagnosis_id": 123, "appointment_id": 456}
assert lifecycle.start(call_type=1).result(timeout=2)
assert repository.calls[-1]["call_type"] == 1
assert repository.calls[-1]["appointment_id"] == 456
repository.appointment_id = 457
with pytest.raises(ValueError, match="当前问诊已变更"):
lifecycle.refresh_call_policy().result(timeout=2)
finally:
lifecycle.end("test").result(timeout=2)
@pytest.fixture
def chat_controller(monkeypatch):
"""Exercise the real controller callbacks without launching Qt or Tencent."""
from doctor_workstation import app as app_module
queued = []
launched = []
previews = []
diagnoses = []
dialog = object()
def get_call_ticket(patient_id, diagnosis_id, *, appointment_id):
return ticket(patient_id=patient_id, diagnosis_id=diagnosis_id, appointment_id=appointment_id)
def launch_video_call(_ticket, **kwargs):
window = SimpleNamespace(
show=lambda: None,
raise_=lambda: None,
activateWindow=lambda: None,
destroyed=SimpleNamespace(connect=lambda _callback: None),
)
call = SimpleNamespace(open_im=kwargs["open_im"], qt_window=window, close=lambda: None)
launched.append((call, kwargs))
return call
def open_diagnosis_by_id(diagnosis_id, *, modeless):
diagnoses.append((diagnosis_id, modeless))
return dialog
monkeypatch.setattr(app_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
monkeypatch.setattr(app_module, "launch_video_call", launch_video_call)
monkeypatch.setattr(app_module, "show_toast", lambda *_args, **_kwargs: None)
monkeypatch.setattr(app_module, "_build_video_patient_case", lambda *_args, **_kwargs: {})
monkeypatch.setattr(app_module, "WEBENGINE_AVAILABLE", True)
monkeypatch.setattr(app_module, "QTimer", SimpleNamespace(singleShot=lambda _delay, callback: callback()))
controller = SimpleNamespace(
shell_window=SimpleNamespace(open_diagnosis_by_id=open_diagnosis_by_id),
current_repository=SimpleNamespace(get_call_ticket=get_call_ticket),
current_demo_mode=False,
video_calls={}, video_pending={}, demo_video_dialogs={}, _pending_im_request=None,
config=SimpleNamespace(video_mode="embedded", video_web_url=""),
_show_video_preview=lambda window, diagnosis_dialog: previews.append((window, diagnosis_dialog)),
)
for method in ("_request_video", "_launch_video", "_video_ticket_error", "_open_video_diagnosis"):
setattr(controller, method, MethodType(getattr(app_module.ApplicationController, method), controller))
def request(appointment_id, diagnosis_id=123):
controller._request_video({
"patient_id": 8, "diagnosis_id": diagnosis_id,
"appointment_id": appointment_id, "mode": "im",
})
def complete(index):
function, callbacks = queued[index]
callbacks["on_success"](function())
return SimpleNamespace(
controller=controller, request=request, complete=complete, queued=queued,
launched=launched, previews=previews, diagnoses=diagnoses, dialog=dialog,
)
def test_diagnosis_callback_previews_exact_appointment_session(chat_controller):
case = chat_controller
case.request(456)
case.complete(0)
call, launch_args = case.launched[0]
case.controller.video_calls["123:457"] = SimpleNamespace(qt_window=object())
launch_args["on_open_diagnosis"]()
assert case.diagnoses == [(123, True)]
assert case.previews == [(call.qt_window, case.dialog)]
case.controller.video_calls.pop("123:456")
launch_args["on_open_diagnosis"]()
assert len(case.previews) == 1 # A closed session cannot borrow another appointment's video.
@pytest.mark.parametrize("second_diagnosis", [123, 124])
@pytest.mark.parametrize("completion_order", [(0, 1), (1, 0)])
def test_latest_im_selection_retires_pending_callbacks(chat_controller, second_diagnosis, completion_order):
case = chat_controller
case.request(456)
case.request(457, diagnosis_id=second_diagnosis)
assert list(case.controller.video_pending) == [f"{second_diagnosis}:457"]
for index in completion_order:
case.complete(index)
assert list(case.controller.video_calls) == [f"{second_diagnosis}:457"]
assert len(case.launched) == 1
assert case.controller.video_pending == {}
assert case.controller._pending_im_request is None
def test_reselecting_open_im_also_retires_other_pending_selection(chat_controller):
case = chat_controller
case.request(456)
case.complete(0)
original_call = case.launched[0][0]
case.request(457)
# The previous page may still be finishing its native shutdown. Treat a
# reactivated current page like any other current selection.
case.controller.video_calls["123:456"] = original_call
case.request(456)
case.complete(1)
assert list(case.controller.video_calls) == ["123:456"]
assert case.controller.video_pending == {}
assert len(case.launched) == 1
def test_repeated_pending_im_selection_does_not_duplicate_request(chat_controller):
case = chat_controller
case.request(456)
case.request(456)
assert len(case.queued) == 1
case.complete(0)
assert list(case.controller.video_calls) == ["123:456"]
def legacy_signature_repository(**changes):
response = {
"sdkAppId": 1400123456, "userId": "doctor_1", "userSig": "test-ticket",
"patientUserId": "patient_8", "diagnosis_id": 123, "patient_id": 8,
**changes,
}
repository = RemoteDoctorRepository(SimpleNamespace(post=lambda *_args: response))
repository.patient_detail = lambda _diagnosis_id: {}
return repository, response
def test_legacy_signature_opens_im_with_requested_context_and_no_media(chat_controller):
case = chat_controller
repository, response = legacy_signature_repository()
case.controller.current_repository = repository
case.request(456)
case.complete(0)
assert list(case.controller.video_calls) == ["123:456"]
assert case.launched[0][1]["open_im"] is True
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
assert request.appointment_id == 456
assert request.target_user_id == "patient_8"
assert request.can_video_call is False and request.can_audio_call is False
assert request.call_disabled_reason
assert "appointment_id" not in response # Never mutate the HTTP response/shared cache.
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
try:
refreshed = lifecycle.refresh_call_policy().result(timeout=2)
assert refreshed["appointmentId"] == 456
assert refreshed["can_video_call"] is False and refreshed["can_audio_call"] is False
finally:
lifecycle.end("test").result(timeout=2)
@pytest.mark.parametrize("changes", [
{"diagnosis_id": 124}, {"patient_id": 9}, {"patientUserId": "patient_9"},
{"diagnosis_id": None}, {"patient_id": None}, {"patientUserId": None},
])
def test_legacy_signature_requires_full_matching_identity(changes):
repository, _ = legacy_signature_repository(**changes)
with pytest.raises(ValueError, match="患者或诊单不匹配"):
repository.get_call_ticket(8, 123, appointment_id=456)
@pytest.mark.parametrize("returned_id", [457, 0, None, "", True, -1, 456.9, 456.0, "bad"])
def test_explicit_wrong_or_invalid_appointment_is_never_replaced(returned_id):
repository, _ = legacy_signature_repository(appointment_id=returned_id)
with pytest.raises(ValueError, match="本次挂号不匹配"):
repository.get_call_ticket(8, 123, appointment_id=456)
@pytest.mark.parametrize("fragment", [
{"can_video_call": True}, {"can_audio_call": True}, {"appointment_type": "text"},
{"appointmentId": 457}, {"call_disabled_reason": ""},
])
def test_partial_policy_is_not_treated_as_legacy_signature(fragment):
repository, _ = legacy_signature_repository(**fragment)
with pytest.raises(ValueError, match="本次挂号不匹配"):
repository.get_call_ticket(8, 123, appointment_id=456)
@pytest.mark.parametrize("returned_id", [456, "456"])
def test_modern_exact_appointment_policy_is_preserved(returned_id):
repository, _ = legacy_signature_repository(
appointment_id=returned_id, appointment_type="video", can_video_call=True, can_audio_call=True,
)
request = normalize_backend_ticket(repository.get_call_ticket(8, 123, appointment_id=456))
assert request.appointment_id == 456
assert request.can_video_call is True and request.can_audio_call is True
+103
View File
@@ -0,0 +1,103 @@
"""Appointment medium stays attached to its own row and opens text chat without RTC."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.core.appointment_modes import (
appointment_type_description,
appointment_type_value,
can_appointment_video,
)
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.diagnosis_index_widgets import _blue_appointment_text
from doctor_workstation.ui.pages import appointments, consultations
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("value,label,video", [
("text", "图文问诊", False), ("video", "视频问诊", True),
(None, "视频问诊", True), (" ", "视频问诊", True),
("phone", "电话问诊", False), ("unexpected", "未知", False),
])
def test_appointment_type_never_uses_diagnosis_visit_type(value, label, video):
assert appointment_type_description(value) == label
assert can_appointment_video(value) is video
assert appointment_type_value({"appointment_type": value, "consultation_type": "复诊"}) == value or value == ""
def test_nested_type_uses_current_appointment_id_not_another_latest():
row = {"appointment_id": 12, "latest_appointment_type": "video", "appointments": [
{"id": 11, "appointment_type": "video"}, {"id": 12, "appointment_type": "text"},
]}
assert appointment_type_value(row) == "text"
assert "图文问诊" in _blue_appointment_text(row, row["appointments"][1])[1]
assert "视频问诊" in _blue_appointment_text(row, row["appointments"][0])[1]
assert appointment_type_value({"appointments": row["appointments"]}) == "unknown"
def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application, monkeypatch):
monkeypatch.setattr(appointments, "run_async", lambda *_a, **_kw: None)
notices = []
monkeypatch.setattr(appointments, "show_toast", lambda _parent, message, *_a: notices.append(message))
page = appointments.AppointmentsPage(DemoDoctorRepository(), PermissionSet(["*"]))
rows = [{"id": 71, "patient_id": 501, "diagnosis_id": 501, "source_patient_id": 901,
"patient_name": "图文患者", "doctor_id": 7, "status": 1, "appointment_type": "text"},
{"id": 72, "patient_id": 502, "diagnosis_id": 502, "source_patient_id": 902,
"patient_name": "视频患者", "doctor_id": 8, "status": 1, "appointment_type": "video"}]
try:
page._loaded({"lists": rows, "count": 2}, page._generation, False)
emitted = []
page.video_requested.connect(emitted.append)
page.table.sortItems(1, Qt.SortOrder.DescendingOrder)
text_row = next(i for i in range(2) if page.table.item(i, 0).data(Qt.ItemDataRole.UserRole)["id"] == 71)
button = page.table.cellWidget(text_row, 10).findChild(QPushButton, "AppointmentImConsultButton")
assert button.text() == "图文沟通" and button.isEnabled()
assert page.table.cellWidget(text_row, 4).findChild(QLabel, "AppointmentModeLabel").text() == "图文问诊"
button.click()
assert emitted[-1]["appointment_id"] == 71
assert emitted[-1]["diagnosis_id"] == 501
assert emitted[-1]["patient_id"] == 901
assert emitted[-1]["appointment_type"] == "text"
assert emitted[-1]["mode"] == "im"
assert not page.toolbar_qr_button.isEnabled()
page._request_video_qr()
assert notices[-1] == "本次挂号不支持视频问诊二维码。"
finally:
page.close()
page.deleteLater()
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
"appointment_status": 1, "latest_appointment_id": 71,
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
"video_call_hint": {"state": "none"}}
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
page._selection_changed()
buttons = page.table_host.findChildren(QToolButton)
text_button = next(button for button in buttons if button.text() == "图文沟通")
assert text_button.isEnabled()
assert page.video_button.isEnabled()
assert not page.video_qr_toolbar_button.isEnabled()
emitted = []
page.video_requested.connect(emitted.append)
page._request_video()
assert emitted[-1]["appointment_id"] == 71
assert emitted[-1]["appointment_type"] == "text"
assert emitted[-1]["mode"] == "im"
finally:
page.close()
page.deleteLater()
+7
View File
@@ -642,6 +642,13 @@ def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
"userSig": "short-lived-ticket",
"targetUserId": "patient_8",
"diagnosisId": 123,
"patientId": 8,
"appointmentId": 0,
"appointment_type": None,
"appointment_type_desc": "",
"can_video_call": False,
"can_audio_call": False,
"call_disabled_reason": "",
}