Files
zyt/app/tests/test_appointment_drawer_visual.py
T
2026-09-09 15:47:48 +08:00

573 lines
19 KiB
Python

from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QSize, Qt
from PySide6.QtWidgets import QApplication, QStyle, QStyleOptionButton, QWidget
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, AppointmentDrawer
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _immediate_async(
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()
class _DeferredAsync:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def __call__(
self,
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
self.calls.append(
{
"function": function,
"args": args,
"kwargs": kwargs,
"on_success": on_success,
"on_error": on_error,
"on_finished": on_finished,
}
)
return object()
def succeed(self, index: int = 0) -> Any:
call = self.calls.pop(index)
try:
result = call["function"](*call["args"], **call["kwargs"])
except Exception as error:
if call["on_error"]:
call["on_error"](error)
raise
else:
if call["on_success"]:
call["on_success"](result)
return result
finally:
if call["on_finished"]:
call["on_finished"]()
class _VisualRepository:
def __init__(
self,
*,
today_conflict: bool = True,
doctors: bool = True,
rosters: bool = True,
slot_error: bool = False,
) -> None:
self.today_conflict = today_conflict
self.doctors = doctors
self.rosters = rosters
self.slot_error = slot_error
self.roster_queries: list[dict[str, Any]] = []
self.slot_queries: list[dict[str, Any]] = []
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
if not self.doctors:
return []
return [
{"id": 77, "name": "陈医生", "department_name": "中医科"},
{"id": 88, "name": "周医生", "department_name": "内科"},
{"id": 99, "name": "林医生", "department_name": "全科"},
]
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
assert dictionary_type == "channels"
return [
{"id": 2, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 20},
{"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10},
]
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
if kwargs.get("status") == 3:
return {
"lists": [
{
"appointment_date": "2026-08-01",
"appointment_time": "10:30-11:00",
}
],
"count": 1,
}
if self.today_conflict:
return {"lists": [{"status": 1}], "count": 1}
return {"lists": [], "count": 0}
def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]:
self.roster_queries.append(kwargs)
if not self.rosters:
return {"lists": [], "count": 0}
today = QDate.currentDate()
return {
"lists": [
{"date": today.toString("yyyy-MM-dd")},
{"date": today.addDays(1).toString("yyyy-MM-dd")},
{"date": today.addDays(2).toString("yyyy-MM-dd")},
],
"count": 3,
}
def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]:
self.slot_queries.append(kwargs)
if self.slot_error:
raise RuntimeError("号源服务暂时不可用")
return {
"slots": [
{"time": "09:30-10:00", "available": True, "quota": 2},
{"time": "10:00-10:30", "available": False, "quota": 0},
{"time": "14:30-15:00", "available": True, "quota": 1},
{"time": "15:00-15:30", "available": False, "quota": 0},
]
}
def _show_drawer(
application: QApplication,
size: tuple[int, int],
*,
repository: Any = None,
autoload: bool = False,
async_runner: Any = _immediate_async,
) -> tuple[QWidget, AppointmentDrawer]:
host = QWidget()
host.resize(*size)
host.show()
drawer = AppointmentDrawer(
{
"id": 501,
"diagnosis_id": 501,
"patient_id": 999,
"patient_name": "林晓岚",
"doctor_id": 77,
},
repository=repository,
parent=host,
autoload=autoload,
async_runner=async_runner,
)
drawer.show()
application.processEvents()
application.processEvents()
return host, drawer
@pytest.mark.parametrize("size,expected_width", [((1024, 640), 614), ((1440, 900), 864)])
def test_full_window_rtl_drawer_geometry_and_fixed_regions(
application: QApplication,
size: tuple[int, int],
expected_width: int,
) -> None:
application_style = application.styleSheet()
host, drawer = _show_drawer(application, size)
assert drawer.size() == host.size()
assert drawer.drawer_width == expected_width
assert drawer.panel.geometry().right() == drawer.rect().right()
assert drawer.panel.height() == drawer.height()
assert drawer.header.height() == 58
assert drawer.header.geometry().top() == 0
assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom()
assert drawer.body_scroll.geometry().top() == drawer.header.geometry().bottom() + 1
assert drawer.body_scroll.geometry().bottom() + 1 == drawer.footer.geometry().top()
assert drawer.windowFlags() & Qt.WindowType.FramelessWindowHint
assert drawer.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
footer_geometry = drawer.footer.geometry()
scroll_bar = drawer.body_scroll.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
application.processEvents()
assert drawer.footer.geometry() == footer_geometry
assert application.styleSheet() == application_style
drawer.close()
host.close()
application.processEvents()
def test_field_order_density_and_conditional_channel_row(
application: QApplication,
) -> None:
host, drawer = _show_drawer(application, (1024, 640))
assert [label.text() for label in drawer.form_labels] == [
"上次就诊:",
"预约方式:",
"预约类型:",
"选择患者:",
"渠道来源 *:",
"自媒体补充 *:",
"预约医生:",
"预约时间:",
"备注:",
]
assert all(label.width() == 100 for label in drawer.form_labels)
assert drawer.channel_source.maximumWidth() == 360
assert drawer.channel_source_detail.maximumWidth() == 360
assert drawer.channel_source.height() == 32
assert drawer.cancel_button.height() == 32
assert drawer.ok_button.height() == 32
assert drawer.ok_button.text() == "确定"
assert drawer.remark.height() == 70
assert drawer.channel_detail_row.isHidden()
assert drawer.time_empty.illustration.size() == QSize(80, 60)
assert drawer.time_empty.illustration.accessibleName() == "空状态插图"
for radio in (
drawer.appointment_method_radio,
drawer.appointment_type_radio,
drawer.patient_radio,
):
option = QStyleOptionButton()
radio.initStyleOption(option)
indicator = radio.style().subElementRect(
QStyle.SubElement.SE_RadioButtonIndicator, option, radio
)
assert indicator.size() == QSize(14, 14)
drawer._channel_names = {"self-4h": "自媒体4H", "online": "线上复诊"}
drawer.channel_source.clear()
drawer.channel_source.addItem("请选择渠道来源", "")
drawer.channel_source.addItem("自媒体4H", "self-4h")
drawer.channel_source.addItem("线上复诊", "online")
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("self-4h"))
application.processEvents()
assert not drawer.channel_detail_row.isHidden()
assert drawer.channel_source_detail.height() == 32
drawer.channel_source_detail.setText("视频号")
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
assert drawer.channel_detail_row.isHidden()
assert drawer.channel_source_detail.text() == ""
drawer.close()
host.close()
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(
application,
(1440, 900),
repository=repository,
autoload=True,
)
today = QDate.currentDate().toString("yyyy-MM-dd")
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
assert drawer.last_visit_label.text() == "2026-08-01 10:30-11:00"
assert list(drawer.doctor_buttons) == [77, 88, 99]
assert [button.text() for button in drawer.doctor_buttons.values()] == [
"陈医生",
"周医生",
"林医生",
]
assert all("·" not in button.text() for button in drawer.doctor_buttons.values())
assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values())
assert all(button.minimumSize() == QSize(130, 40) for button in drawer.date_buttons.values())
assert all(button.maximumSize() == QSize(130, 40) for button in drawer.date_buttons.values())
assert drawer.date_combo.currentData() == tomorrow
assert drawer.conflict_alert.property("kind") == "info"
assert "可为患者预约其他日期" in drawer.conflict_alert.label.text()
available = drawer.slot_buttons["09:30-10:00"]
unavailable = drawer.slot_buttons["10:00-10:30"]
assert available.minimumWidth() >= 110
assert available.minimumHeight() >= 70
assert available.property("slotState") == "available"
assert available.isEnabled()
assert unavailable.property("slotState") == "unavailable"
assert not unavailable.isEnabled()
assert unavailable.status_label.text() == "已约"
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()
expected_payload = {
"diagnosis_id": 501,
"patient_id": 501,
"doctor_id": 77,
"appointment_date": tomorrow,
"appointment_time": "09:30-10:00",
"period": "all",
"appointment_type": mode,
"remark": "",
"channel_source": "online",
"channel_source_detail": "",
}
assert drawer.payload() == expected_payload
assert repository.roster_queries[-1] == {
"doctor_id": 77,
"start_date": today,
"end_date": QDate.currentDate().addDays(6).toString("yyyy-MM-dd"),
"status": 1,
"page_no": 1,
"page_size": 100,
}
assert repository.slot_queries[-1] == {
"doctor_id": 77,
"appointment_date": tomorrow,
"period": "all",
}
query_count = len(repository.slot_queries)
drawer.refresh_slots_button.click()
assert len(repository.slot_queries) == query_count + 1
assert drawer.slot_combo.currentData() == "09:30-10:00"
assert drawer.ok_button.isEnabled()
drawer.date_buttons[today].click()
assert drawer.date_combo.currentData() == today
assert drawer.conflict_alert.property("kind") == "warning"
assert "不能重复预约今天" in drawer.conflict_alert.label.text()
assert not drawer.ok_button.isEnabled()
generation = drawer._slot_generation
drawer._apply_slots(
{"slots": [{"time": "00:00-00:30", "available": True, "quota": 1}]},
77,
today,
generation,
)
application.processEvents()
expired = drawer.slot_buttons["00:00-00:30"]
assert expired.property("slotState") == "unavailable"
assert not expired.isEnabled()
assert expired.status_label.text() == "已约"
assert expired.status_label.isVisible()
assert "已过期" not in expired.accessibleName()
drawer.close()
host.close()
application.processEvents()
def test_initial_loading_state_covers_the_drawer_body(
application: QApplication,
) -> None:
deferred = _DeferredAsync()
host, drawer = _show_drawer(
application,
(1024, 640),
repository=_VisualRepository(),
autoload=True,
async_runner=deferred,
)
assert len(deferred.calls) == 1
assert drawer._active_loading == "initial"
assert drawer.loading_overlay.isVisible()
assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…"
assert drawer.loading_overlay.geometry() == drawer.body_scroll.viewport().rect()
assert drawer.loading_overlay.spinner._timer.isActive()
assert not drawer.ok_button.isEnabled()
drawer.close()
host.close()
application.processEvents()
def test_empty_doctor_state_is_explicit_and_blocks_submit(
application: QApplication,
) -> None:
host, drawer = _show_drawer(
application,
(1024, 640),
repository=_VisualRepository(doctors=False),
autoload=True,
)
assert not drawer.doctor_buttons
assert drawer.doctor_empty.text() == "暂无可预约医生"
assert drawer.doctor_empty.isVisible()
assert drawer.time_stack.currentWidget() is drawer.time_empty
assert drawer.time_empty.label.text() == "暂无可预约医生"
assert drawer.time_empty.illustration.size() == QSize(80, 60)
assert drawer.banner.property("kind") == "warning"
assert not drawer._initial_loaded
assert not drawer.ok_button.isEnabled()
drawer.close()
host.close()
application.processEvents()
def test_empty_roster_state_is_explicit_and_blocks_submit(
application: QApplication,
) -> None:
repository = _VisualRepository(rosters=False)
host, drawer = _show_drawer(
application,
(1024, 640),
repository=repository,
autoload=True,
)
assert list(drawer.doctor_buttons) == [77, 88, 99]
assert repository.roster_queries
assert not drawer.date_buttons
assert drawer.time_stack.currentWidget() is drawer.time_empty
assert drawer.time_empty.label.text() == "该医生暂无排班"
assert drawer.banner.property("kind") == "warning"
assert "未来 7 天暂无可预约排班" in drawer.banner.label.text()
assert not drawer.ok_button.isEnabled()
drawer.close()
host.close()
application.processEvents()
def test_refreshing_state_and_generation_ignore_stale_slot_results(
application: QApplication,
) -> None:
repository = _VisualRepository(today_conflict=False)
host, drawer = _show_drawer(
application,
(1440, 900),
repository=repository,
autoload=True,
)
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
drawer.date_buttons[tomorrow].click()
selected = "09:30-10:00"
drawer.slot_buttons[selected].click()
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
assert drawer.ok_button.isEnabled()
deferred = _DeferredAsync()
drawer._run_async = deferred
drawer.refresh_slots_button.click()
application.processEvents()
assert len(deferred.calls) == 1
assert drawer._active_loading == "slots"
assert drawer.loading_overlay.isVisible()
assert drawer.loading_overlay.label.text() == "正在刷新可用号源…"
assert drawer.refresh_slots_button.text() == "刷新中…"
assert not drawer.refresh_slots_button.isEnabled()
assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty
assert drawer.slot_empty.label.text() == "正在刷新可用号源…"
assert not drawer.ok_button.isEnabled()
doctor_id = int(drawer.doctor_combo.currentData())
appointment_date = str(drawer.date_combo.currentData())
stale_generation = drawer._slot_generation
drawer._request_slots(doctor_id, appointment_date, restore_selection=selected)
current_generation = drawer._slot_generation
assert current_generation == stale_generation + 1
assert len(deferred.calls) == 2
deferred.succeed()
application.processEvents()
assert drawer._slot_generation == current_generation
assert drawer.loading_overlay.isVisible()
assert not drawer.slot_buttons
deferred.succeed()
application.processEvents()
assert drawer.loading_overlay.isHidden()
assert drawer.refresh_slots_button.text() == "刷新"
assert drawer.refresh_slots_button.isEnabled()
assert drawer.slot_combo.currentData() == selected
assert drawer.slot_buttons[selected].isChecked()
assert drawer.ok_button.isEnabled()
drawer.close()
host.close()
application.processEvents()
def test_slot_error_state_is_visible_and_recoverable(
application: QApplication,
) -> None:
host, drawer = _show_drawer(
application,
(1024, 640),
repository=_VisualRepository(today_conflict=False, slot_error=True),
autoload=True,
)
assert drawer.loading_overlay.isHidden()
assert drawer.banner.isVisible()
assert drawer.banner.property("kind") == "danger"
assert "号源加载失败" in drawer.banner.label.text()
assert drawer.slot_state_stack.currentWidget() is drawer.slot_empty
assert drawer.slot_empty.label.text() == "号源加载失败"
assert drawer.refresh_slots_button.text() == "刷新"
assert drawer.refresh_slots_button.isEnabled()
assert not drawer.slot_buttons
assert not drawer.ok_button.isEnabled()
drawer.close()
host.close()
application.processEvents()
def test_keyboard_focus_has_a_visible_state(
application: QApplication,
) -> None:
host, drawer = _show_drawer(
application,
(1440, 900),
repository=_VisualRepository(today_conflict=False),
autoload=True,
)
focus_target = list(drawer.date_buttons.values())[1]
drawer.activateWindow()
focus_target.setFocus(Qt.FocusReason.TabFocusReason)
application.processEvents()
assert focus_target.focusPolicy() != Qt.FocusPolicy.NoFocus
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
application.processEvents()