573 lines
19 KiB
Python
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()
|