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() def test_roster_slot_states_conflict_refresh_and_submit_contract( application: QApplication, ) -> 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() 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": "video", "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()