Files
zyt/app/tests/test_consultations_parity_ui.py
T
2026-08-11 10:02:40 +08:00

1446 lines
46 KiB
Python

from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Signal
from PySide6.QtWidgets import (
QApplication,
QDialog,
QInputDialog,
QLabel,
QMessageBox,
QToolButton,
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_video_payload,
appointment_rows,
is_diagnosis_confirmed,
is_valid_id_card,
is_video_available,
prescription_action_label,
)
class _ListDiagnosisDialog(QWidget):
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
def open_for(self, *_args: Any, **_kwargs: Any) -> None:
return None
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
def isolate_list_from_detail_dialog(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(consultations_module, "DiagnosisDialog", _ListDiagnosisDialog)
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(consultations_module, "run_async", run_immediately)
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
"patient_id": 301,
"patient_name": "林晓岚",
"gender": 2,
"age": 36,
"status": 4,
"has_appointment": 1,
"appointment_id": 101,
"appointment_status": 1,
"appointments": [
{
"id": 101,
"status": 1,
"doctor_name": "陈医生",
"appointment_date": "2026-08-10",
"time_text": "09:00",
}
],
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_prescription": 0,
}
row.update(changes)
return row
def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert is_video_available(_row(status=4, appointment_status=1))
assert not is_video_available(_row(status=1, appointment_status=4))
assert not is_video_available(_row(status=1, appointment_status=1, has_appointment=0))
payload = _video_payload(_row(id=777, diagnosis_id=777, appointment_id=222))
assert payload["appointment_id"] == 222
assert payload["diagnosis_id"] == 777
assert payload["patient_id"] == 301
def test_create_and_order_dialogs_use_production_diagnosis_contract(
application: QApplication,
) -> None:
create_dialog = consultations_module._DiagnosisCreateDialog()
assert create_dialog.gender.itemData(create_dialog.gender.findText("女")) == 0
create_dialog.patient_name.setText("林晓岚")
create_dialog.phone.setText("13800138000")
create_dialog.gender.setCurrentIndex(create_dialog.gender.findData(0))
create_dialog.diagnosis_type.setText("复诊")
create_dialog.local_hospital_name.setText("杭州市第一人民医院")
create_dialog.local_hospital_diagnosis.setText("2型糖尿病")
payload = create_dialog.payload()
assert payload["gender"] == 0
assert payload["local_hospital_name"] == "杭州市第一人民医院"
create_dialog.close()
order_dialog = consultations_module._DiagnosisOrderDialog(_row(), None)
order_dialog.order_type.setCurrentIndex(order_dialog.order_type.findData(2))
order_dialog.amount.setValue(88.5)
assert order_dialog.payload()["patient_id"] == 501
order_dialog.close()
application.processEvents()
def test_diagnosis_appointment_uses_doctor_route_capability_and_diagnosis_owner(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def create_diagnosis_appointment(
self, payload: dict[str, Any] | None = None, **fields: Any
) -> dict[str, Any]:
calls.append(dict(payload or fields))
return {"ok": True}
class AcceptedAppointmentDialog:
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {
"diagnosis_id": 501,
"patient_id": 301,
"doctor_id": 77,
"appointment_date": "2026-08-12",
"appointment_time": "09:00-09:30",
}
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
monkeypatch.setattr(patients_module, "_AppointmentDialog", AcceptedAppointmentDialog)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._book_selected_appointment()
assert calls == [
{
"diagnosis_id": 501,
"patient_id": 501,
"doctor_id": 77,
"appointment_date": "2026-08-12",
"appointment_time": "09:00-09:30",
}
]
page.close()
application.processEvents()
def test_assigned_assistant_can_enter_receive_only_live_watch(
application: QApplication,
) -> None:
class Repository:
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
return {"diagnosis_id": diagnosis_id}
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 2001, "name": "周医助"},
)
row = _row(
assistant_id=2001,
video_call_hint={"state": "live", "label": "通话中", "room_id": 9001},
)
page.table.set_rows([row])
page.table.selectRow(0)
requested: list[dict[str, Any]] = []
page.watch_requested.connect(requested.append)
watch_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert watch_cell is not None
watch_buttons = [
button
for button in watch_cell.findChildren(QToolButton)
if button.text() == "进入旁观"
]
assert len(watch_buttons) == 1
assert watch_buttons[0].isEnabled()
assert "不会开启摄像头与麦克风" in watch_buttons[0].toolTip()
watch_buttons[0].click()
assert requested == [{"diagnosis_id": 501, "patient_name": "林晓岚"}]
page.close()
application.processEvents()
def test_watch_entry_is_disabled_until_room_is_live_and_hidden_from_other_assistants(
application: QApplication,
) -> None:
class Repository:
def get_assistant_watch_ticket(self, diagnosis_id: int) -> dict[str, Any]:
return {"diagnosis_id": diagnosis_id}
pending = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 2001},
)
pending.table.set_rows(
[
_row(
assistant_id=2001,
video_call_hint={"state": "pending_room", "label": "接通中"},
)
]
)
pending_cell = pending.table_host.fixed.indexWidget(pending.table_host.model.index(0, 10))
pending_button = next(
button
for button in pending_cell.findChildren(QToolButton)
if button.text() == "进入旁观"
)
assert not pending_button.isEnabled()
pending.close()
other = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 2002},
)
other.table.set_rows(
[_row(assistant_id=2001, video_call_hint={"state": "live", "label": "通话中"})]
)
other_cell = other.table_host.fixed.indexWidget(other.table_host.model.index(0, 10))
assert all(button.text() != "进入旁观" for button in other_cell.findChildren(QToolButton))
assert any(label.text() == "通话中" for label in other_cell.findChildren(QLabel))
other.close()
application.processEvents()
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
DiagnosisViewRecord=[{"is_confirmed": 0}, {"is_confirmed": "1"}],
)
assert appointment_rows(row)[0]["id"] == 101
assert is_diagnosis_confirmed(row)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 0})
== "查看处方"
)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 1})
== "开方"
)
def test_default_query_matches_admin_today_and_page_size_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_consultations(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
assert len(calls) == 1
query = calls[0]
assert query["page_no"] == 1
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert "status" not in query
assert query["has_appointment"] == ""
assert query["diagnosis_confirmed"] == ""
assert {
"diagnosis_type",
"syndrome_type",
"assistant_id",
"latest_appointment_start_date",
"latest_appointment_end_date",
"latest_appointment_channel_source",
"latest_assign_start_date",
"latest_assign_end_date",
"sort_unserved_days",
}.issubset(query)
assert "consultation_type" not in query
page.close()
application.processEvents()
def test_double_click_opens_readonly_and_never_emits_video(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(
SimpleNamespace(),
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/videoQr"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
opened: list[tuple[int, bool]] = []
videos: list[dict[str, Any]] = []
monkeypatch.setattr(
page._diagnosis_dialog,
"open_for",
lambda diagnosis_id, *, editable=False, seed=None: opened.append((diagnosis_id, editable)),
)
page.video_requested.connect(videos.append)
page.table.itemDoubleClicked.emit(page.table.item(0, 0))
application.processEvents()
assert opened == [(501, False)]
assert videos == []
page.close()
def test_action_visibility_requires_exact_canonical_permissions(
application: QApplication,
) -> None:
aliases = PermissionSet(
[
"tcm.diagnosis.readonlyDetail",
"tcm.diagnosis.edit",
"tcm.diagnosis.add",
"tcm.diagnosis.delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=aliases)
assert not page.view_button.isVisible()
assert not page.edit_button.isVisible()
assert not page.add_button.isVisible()
assert not page.delete_button.isVisible()
page.close()
exact = PermissionSet(
[
"tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/edit",
"tcm.diagnosis/add",
"tcm.diagnosis/delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=exact)
assert not page.view_button.isHidden()
assert not page.edit_button.isHidden()
assert not page.add_button.isHidden()
assert not page.delete_button.isHidden()
page.close()
application.processEvents()
def test_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
page.refresh(silent=True)
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
application.processEvents()
assert page.table.rowCount() == 1
assert page.table.item(0, 0).text().startswith("902")
page.close()
def test_current_appointment_is_the_only_prescription_authority(
application: QApplication,
) -> None:
calls: list[tuple[str, int]] = []
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
assert calls == [("appointment", 202)]
page.close()
application.processEvents()
def test_empty_appointment_wrapper_is_treated_as_a_new_prescription(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 202
return {"data": {}}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
page.close()
application.processEvents()
def test_prescription_query_error_is_fail_closed_without_diagnosis_fallback(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
raise RuntimeError(f"appointment {appointment_id} unavailable")
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
with pytest.raises(RuntimeError, match="appointment 202 unavailable"):
page._load_context_prescription(_row(appointment_id=202))
page.close()
application.processEvents()
def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, int]] = []
created: list[dict[str, Any]] = []
dialog_seeds: list[dict[str, Any]] = []
case_record = {
"diagnosis": {"id": 501, "patient_name": "林晓岚", "chief_complaint": "咳嗽"},
"patient": {"id": 301, "gender": 2, "age": 36},
}
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
calls.append(("diagnosis_detail", diagnosis_id))
return case_record
def create_prescription(self, prescription: Any) -> dict[str, Any]:
created.append(dict(prescription))
return {"id": 901}
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
class AcceptedEditor:
def __init__(
self,
_repository: Any,
seed: dict[str, Any],
**_kwargs: Any,
) -> None:
dialog_seeds.append(seed)
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"formula_name": "止咳方", "medicines": []}
monkeypatch.setattr(consultations_module, "PrescriptionEditorDialog", AcceptedEditor)
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page._begin_prescription_load(_row(appointment_id=202), mode="open")
assert calls[:2] == [("appointment", 202), ("diagnosis_detail", 501)]
assert len(created) == 1
assert created[0]["diagnosis_id"] == 501
assert created[0]["appointment_id"] == 202
assert created[0]["case_record"] == case_record
assert created[0]["case_record"] is not case_record
assert dialog_seeds[0]["case_record"] == case_record
assert dialog_seeds[0]["case_record"] is not case_record
page.close()
application.processEvents()
def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page.table.set_rows(
[
_row(id=501, diagnosis_id=501, appointment_id=101),
_row(id=502, diagnosis_id=502, appointment_id=202),
]
)
page.table.selectRow(0)
page._begin_prescription_load(page.table.current_data(), mode="open")
assert page._prescription_busy
assert not page.prescription_button.isEnabled()
page.table.selectRow(1)
application.processEvents()
assert not page._prescription_busy
assert page.prescription_button.isEnabled()
callbacks[0]["on_success"]({"id": 88, "appointment_id": 101})
callbacks[0]["on_finished"]()
assert page.table.current_data()["appointment_id"] == 202
assert not page._prescription_busy
page.close()
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> 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()])
page.table.selectRow(0)
application.processEvents()
assert not page.video_button.isHidden()
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()
@pytest.mark.parametrize(
"value",
[
"11010519491231002X",
"110105491231002",
"44052420000101001x",
],
)
def test_id_card_preflight_accepts_admin_15_and_18_digit_shapes(value: str) -> None:
assert is_valid_id_card(value)
@pytest.mark.parametrize(
"value",
[
"",
"11010519491331002X",
"11010519491232002X",
"01010519491231002X",
"110105491331002",
"not-an-id",
],
)
def test_id_card_preflight_rejects_invalid_shapes(value: str) -> None:
assert not is_valid_id_card(value)
def test_invalid_id_card_never_reaches_mutation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["tcm.diagnosis/edit"]))
page.table.set_rows([_row()])
page.table.selectRow(0)
mutations: list[tuple[Any, str]] = []
messages: list[str] = []
monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("123456", True))
monkeypatch.setattr(
page, "_run_mutation", lambda operation, message: mutations.append((operation, message))
)
monkeypatch.setattr(
consultations_module,
"show_toast",
lambda _owner, message, *_args, **_kwargs: messages.append(message),
)
page._fill_selected_id_card()
assert mutations == []
assert messages == ["请输入15或18位有效身份证号。"]
page.close()
application.processEvents()
def test_valid_id_card_reaches_the_existing_repository_dto(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[int, str]] = []
class Repository:
def fill_diagnosis_id_card(self, diagnosis_id: int, id_card: str) -> None:
calls.append((diagnosis_id, id_card))
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/edit"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
monkeypatch.setattr(
QInputDialog,
"getText",
lambda *_args, **_kwargs: ("11010519491231002X", True),
)
monkeypatch.setattr(page, "_run_mutation", lambda operation, _message: operation())
page._fill_selected_id_card()
assert calls == [(501, "11010519491231002X")]
page.close()
application.processEvents()
def test_multi_appointment_cancel_is_revalidated_before_repository_mutation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
record = _row(
appointments=[
{"id": 101, "status": 1, "doctor_name": "陈医生"},
{"id": 102, "status": 4, "doctor_name": "李医生"},
]
)
page = ConsultationsPage(
SimpleNamespace(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
page.table.selectRow(0)
mutations: list[Any] = []
questions: list[Any] = []
messages: list[str] = []
monkeypatch.setattr(page, "_run_mutation", lambda *args: mutations.append(args))
monkeypatch.setattr(
QMessageBox,
"question",
lambda *args, **kwargs: questions.append((args, kwargs)),
)
monkeypatch.setattr(
consultations_module,
"show_toast",
lambda _owner, message, *_args, **_kwargs: messages.append(message),
)
page._cancel_selected_appointment()
assert mutations == []
assert questions == []
assert messages == ["该诊单有多条挂号,请在具体挂号记录中取消。"]
page.close()
application.processEvents()
def test_single_appointment_cancel_uses_the_visible_nested_appointment_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
cancelled: list[int] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int, **_kwargs: Any) -> None:
cancelled.append(appointment_id)
record = _row(
appointment_id=999,
appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}],
)
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
page.table.selectRow(0)
monkeypatch.setattr(
QMessageBox,
"question",
lambda *_args, **_kwargs: QMessageBox.StandardButton.Yes,
)
monkeypatch.setattr(
page,
"_run_mutation",
lambda operation, _message: operation(),
)
page._cancel_selected_appointment()
assert cancelled == [101]
page.close()
application.processEvents()
def test_multi_appointment_card_cancel_mutates_only_the_signalled_exact_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
cancelled: list[int] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
cancelled.append(appointment_id)
record = _row(
appointment_id=999,
appointments=[
{"id": 101, "status": 1, "doctor_name": "陈医生", "time_text": "09:00"},
{"id": 102, "status": 4, "doctor_name": "李医生", "time_text": "10:00"},
],
)
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
monkeypatch.setattr(
QMessageBox,
"question",
lambda *_args, **_kwargs: QMessageBox.StandardButton.Yes,
)
monkeypatch.setattr(page, "_run_mutation", lambda operation, _message: operation())
page._cancel_appointment_item(record, 102)
assert cancelled == [102]
page.close()
application.processEvents()
@pytest.mark.parametrize("appointment_id", [0, 999, 103])
def test_exact_cancel_rejects_unknown_or_non_cancellable_appointment_before_confirmation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
appointment_id: int,
) -> None:
cancelled: list[int] = []
questions: list[Any] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
cancelled.append(appointment_id)
record = _row(
appointments=[
{"id": 101, "status": 1, "doctor_name": "陈医生"},
{"id": 103, "status": 3, "doctor_name": "李医生"},
]
)
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
monkeypatch.setattr(
QMessageBox,
"question",
lambda *args, **kwargs: questions.append((args, kwargs)),
)
page._cancel_appointment_item(record, appointment_id)
assert questions == []
assert cancelled == []
page.close()
application.processEvents()
def test_exact_cancel_revalidates_status_after_confirmation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
cancelled: list[int] = []
messages: list[str] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
cancelled.append(appointment_id)
record = _row(appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}])
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
def finish_while_confirming(*_args: Any, **_kwargs: Any) -> Any:
record["appointments"][0]["status"] = 3
return QMessageBox.StandardButton.Yes
monkeypatch.setattr(QMessageBox, "question", finish_while_confirming)
monkeypatch.setattr(
consultations_module,
"show_toast",
lambda _owner, message, *_args, **_kwargs: messages.append(message),
)
page._cancel_appointment_item(record, 101)
assert cancelled == []
assert messages == ["挂号状态已刷新,本次未执行取消。"]
page.close()
application.processEvents()
def test_exact_cancel_confirmation_cancel_never_starts_mutation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[Any] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
calls.append(appointment_id)
record = _row(appointments=[{"id": 101, "status": 1, "doctor_name": "陈医生"}])
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/guahao"]),
)
page.table.set_rows([record])
monkeypatch.setattr(
QMessageBox,
"question",
lambda *_args, **_kwargs: QMessageBox.StandardButton.Cancel,
)
monkeypatch.setattr(page, "_run_mutation", lambda *args: calls.append(args))
page._cancel_appointment_item(record, 101)
assert calls == []
page.close()
application.processEvents()
def test_exact_cancel_direct_handler_rechecks_permission_before_confirmation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[Any] = []
class Repository:
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
calls.append(appointment_id)
record = _row(appointments=[{"id": 101, "status": 1}])
page = ConsultationsPage(Repository(), permissions=PermissionSet([]))
page.table.set_rows([record])
monkeypatch.setattr(
QMessageBox,
"question",
lambda *args, **kwargs: calls.append((args, kwargs)),
)
page._cancel_appointment_item(record, 101)
assert calls == []
page.close()
application.processEvents()
def test_menu_handlers_call_only_permission_gated_real_repository_methods(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, Any]] = []
class Repository:
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, str]:
calls.append(
(
"video_qr",
{
"doctor_id": doctor_id,
"patient_id": patient_id,
"share_user_id": share_user_id,
},
)
)
return {"qrcode_url": "https://example.invalid/video.png"}
def generate_diagnosis_qrcode(
self,
diagnosis_id: int,
doctor_id: int,
patient_id: int,
share_user_id: int,
) -> dict[str, str]:
calls.append(
(
"confirm_qr",
{
"diagnosis_id": diagnosis_id,
"doctor_id": doctor_id,
"patient_id": patient_id,
"share_user_id": share_user_id,
},
)
)
return {"qrcode_url": "https://example.invalid/confirm.png"}
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
calls.append(("logs", diagnosis_id))
return [{"id": 1, "action_desc": "挂号"}]
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
calls.append(
(
"order",
{
"patient_id": patient_id,
"order_type": order_type,
"amount": amount,
"remark": remark,
},
)
)
return {"order_no": "O-1"}
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
calls.append(("order_qr", order_no))
return {"qrcode_url": "https://example.invalid/payment.png"}
record = _row(
appointment_doctor_id=77,
appointments=[
{
"id": 101,
"status": 1,
"doctor_id": 77,
"doctor_name": "陈医生",
}
],
)
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(
[
"tcm.diagnosis/videoQr",
"tcm.diagnosis/guahao",
"tcm.diagnosis/guahaoLogList",
"tcm.diagnosis/order",
]
),
current_user={"id": 66},
)
page.table.set_rows([record])
page.table.selectRow(0)
shown_qr: list[tuple[str, Any]] = []
shown_logs: list[Any] = []
monkeypatch.setattr(
page,
"_show_qr_result",
lambda title, _record, result: shown_qr.append((title, result)),
)
monkeypatch.setattr(
page,
"_show_appointment_logs",
lambda _record, result: shown_logs.append(result),
)
class AcceptedOrderDialog:
def __init__(self, _record: Any, _parent: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"}
monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog)
monkeypatch.setattr(consultations_module._QrImagePreview, "load_url", lambda *_args: None)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._request_video_qr()
page._request_confirm_qr()
page._request_appointment_logs()
page._create_diagnosis_order()
qr_payload = {
"patient_id": 301,
"doctor_id": 77,
"share_user_id": 66,
}
assert calls == [
("video_qr", qr_payload),
("confirm_qr", {**qr_payload, "diagnosis_id": 501}),
("logs", 501),
(
"order",
{"patient_id": 501, "order_type": 2, "amount": 88.5, "remark": "复诊"},
),
("order_qr", "O-1"),
]
assert [title for title, _result in shown_qr] == ["视频二维码", "诊单二维码"]
assert shown_logs == [[{"id": 1, "action_desc": "挂号"}]]
assert page._order_qr_dialog is not None
assert page._order_qr_dialog.order_no == "O-1"
assert page._order_qr_dialog.qrcode_url == "https://example.invalid/payment.png"
page._order_qr_dialog.reject()
page.close()
application.processEvents()
def test_menu_handler_direct_calls_fail_closed_without_permission_or_active_state(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[str] = []
class Repository:
def generate_video_qrcode(self, payload: dict[str, Any]) -> dict[str, str]:
calls.append(str(payload))
return {"qrcode_url": "https://example.invalid/video.png"}
denied = ConsultationsPage(Repository(), permissions=PermissionSet([]))
denied.table.set_rows([_row(appointment_doctor_id=77)])
denied.table.selectRow(0)
denied._request_video_qr()
assert calls == []
denied.close()
inactive = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/videoQr"]))
inactive.table.set_rows(
[_row(appointment_status=4, appointments=[{"id": 101, "status": 4, "doctor_id": 77}])]
)
inactive.table.selectRow(0)
inactive._request_video_qr()
assert calls == []
inactive.close()
application.processEvents()
def test_menu_request_generation_ignores_result_after_row_selection_changes(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Repository:
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, str]:
del doctor_id, patient_id, share_user_id
return {"qrcode_url": "https://example.invalid/video.png"}
callbacks: list[Any] = []
def hold_request(
_operation: Any,
*,
on_success: Any = None,
on_error: Any = None,
**_kwargs: Any,
) -> object:
callbacks.append((on_success, on_error))
return object()
monkeypatch.setattr(consultations_module, "run_async", hold_request)
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/videoQr"]),
current_user={"id": 66},
)
rows = [
_row(appointment_doctor_id=77),
_row(
id=502,
diagnosis_id=502,
patient_id=302,
appointment_id=102,
appointment_doctor_id=78,
appointments=[{"id": 102, "status": 1, "doctor_id": 78}],
),
]
page.table.set_rows(rows)
page.table.selectRow(0)
shown: list[Any] = []
monkeypatch.setattr(page, "_show_qr_result", lambda *args: shown.append(args))
page._request_video_qr()
assert len(callbacks) == 1
page.table.selectRow(1)
callbacks[0][0]({"qrcode_url": "https://example.invalid/video.png"})
assert shown == []
page.close()
application.processEvents()
def test_diagnosis_order_qr_failure_retries_without_creating_a_second_order(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, Any]] = []
class Repository:
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
calls.append(("create", (patient_id, order_type, amount, remark)))
return {"order_no": "PAY-RETRY-1"}
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
calls.append(("qr", order_no))
if sum(kind == "qr" for kind, _payload in calls) == 1:
raise RuntimeError("二维码服务暂不可用")
return {"qrcode_url": "https://example.invalid/payment-retry.png"}
class AcceptedOrderDialog:
def __init__(self, _record: Any, _parent: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"patient_id": 301, "order_type": 2, "amount": 88.5, "remark": "复诊"}
page = ConsultationsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/order"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog)
monkeypatch.setattr(consultations_module._QrImagePreview, "load_url", lambda *_args: None)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._create_diagnosis_order()
dialog = page._order_qr_dialog
assert dialog is not None
assert dialog.order_no == "PAY-RETRY-1"
assert dialog.retry_button.isEnabled()
assert "生成失败" in dialog.status_label.text()
assert calls == [
("create", (501, 2, 88.5, "复诊")),
("qr", "PAY-RETRY-1"),
]
dialog.retry_button.click()
assert calls == [
("create", (501, 2, 88.5, "复诊")),
("qr", "PAY-RETRY-1"),
("qr", "PAY-RETRY-1"),
]
assert dialog.qrcode_url == "https://example.invalid/payment-retry.png"
assert dialog.url_edit.text() == dialog.qrcode_url
dialog.reject()
assert not page._mutation_pending
page.close()
application.processEvents()
def test_diagnosis_order_without_order_no_never_requests_payment_qr(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
qr_calls: list[str] = []
class Repository:
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
del patient_id, order_type, amount, remark
return {"id": 99}
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
qr_calls.append(order_no)
return {"qrcode_url": "https://example.invalid/should-not-open.png"}
class AcceptedOrderDialog:
def __init__(self, _record: Any, _parent: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"]))
page.table.set_rows([_row()])
page.table.selectRow(0)
monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._create_diagnosis_order()
assert qr_calls == []
assert page._order_qr_dialog is None
assert not page._mutation_pending
page.close()
application.processEvents()
@pytest.mark.parametrize("invalidate", ["selection", "permission"])
def test_diagnosis_order_create_result_is_rejected_when_active_context_changes(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
invalidate: str,
) -> None:
qr_calls: list[str] = []
class Repository:
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
del patient_id, order_type, amount, remark
return {"order_no": "STALE-1"}
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
qr_calls.append(order_no)
return {"qrcode_url": "https://example.invalid/stale.png"}
callbacks: list[tuple[Any, Any, Any]] = []
def hold_request(
operation: Any,
*,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**_kwargs: Any,
) -> object:
callbacks.append((operation, on_success, on_finished))
return object()
class AcceptedOrderDialog:
def __init__(self, _record: Any, _parent: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"]))
rows = [
_row(),
_row(id=502, diagnosis_id=502, patient_id=302, appointment_id=102),
]
page.table.set_rows(rows)
page.table.selectRow(0)
monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog)
monkeypatch.setattr(consultations_module, "run_async", hold_request)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._create_diagnosis_order()
assert len(callbacks) == 1
operation, on_success, _on_finished = callbacks[0]
result = operation()
if invalidate == "selection":
page.table.selectRow(1)
else:
page.permissions = PermissionSet([])
on_success(result)
assert qr_calls == []
assert page._order_qr_dialog is None
assert not page._mutation_pending
page.close()
application.processEvents()
def test_closing_payment_dialog_invalidates_inflight_qr_result(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Repository:
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
del patient_id, order_type, amount, remark
return {"order_no": "CLOSE-1"}
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
return {"qrcode_url": f"https://example.invalid/{order_no}.png"}
staged: list[tuple[Any, Any, Any]] = []
call_count = 0
def stage_request(
operation: Any,
*,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**_kwargs: Any,
) -> object:
nonlocal call_count
call_count += 1
if call_count == 1:
on_success(operation())
if on_finished:
on_finished()
else:
staged.append((operation, on_success, on_finished))
return object()
class AcceptedOrderDialog:
def __init__(self, _record: Any, _parent: Any) -> None:
pass
def exec(self) -> Any:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"patient_id": 301, "order_type": 2, "amount": 10.0, "remark": ""}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["tcm.diagnosis/order"]))
page.table.set_rows([_row()])
page.table.selectRow(0)
monkeypatch.setattr(consultations_module, "_DiagnosisOrderDialog", AcceptedOrderDialog)
monkeypatch.setattr(consultations_module, "run_async", stage_request)
monkeypatch.setattr(page, "refresh", lambda *_args, **_kwargs: None)
page._create_diagnosis_order()
dialog = page._order_qr_dialog
assert dialog is not None
assert len(staged) == 1
dialog.reject()
assert page._order_qr_dialog is None
assert not page._mutation_pending
operation, on_success, on_finished = staged[0]
on_success(operation())
if on_finished:
on_finished()
assert page._order_qr_dialog is None
assert not page._mutation_pending
page.close()
application.processEvents()