"""Native Qt interaction and layout checks for the approved appointment surface.""" from __future__ import annotations import os import socket from copy import deepcopy from datetime import date from itertools import combinations from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QPoint, QRect, QSize, Qt from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, QLabel, QLineEdit, QPushButton, QTabBar, QWidget, ) from doctor_workstation.ui import shell as shell_module from doctor_workstation.ui.pages import appointments as appointments_module from doctor_workstation.ui.pages.appointments import AppointmentsPage from doctor_workstation.ui.shell import NavigationItem, ShellWindow from doctor_workstation.ui.theme import apply_theme class _Repository: def __init__(self) -> None: self.rows = [ { "id": identifier, "patient_name": f"测试患者{identifier}", "patient_phone": "13800001234", "gender": 1, "age": 36, "status": 1, "status_desc": "待接诊", "appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30", "doctor_name": "测试医生", "doctor_id": 21, "diagnosis_id": identifier + 100, "source_patient_id": identifier + 200, "diagnosis_confirmed": True, "channel_name": "测试渠道", } for identifier in (401, 402, 403) ] self.queries: list[dict[str, Any]] = [] def list_appointments(self, **kwargs: Any) -> dict[str, Any]: self.queries.append(kwargs) return { "lists": deepcopy(self.rows), "count": len(self.rows), "extend": {"status_count": {"1": len(self.rows)}}, } def list_departments(self) -> list[dict[str, Any]]: return [{"id": 10, "name": "测试部门", "children": []}] class _QuietPage(QWidget): def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None: super().__init__(parent) def _settle(application: QApplication) -> None: for _ in range(3): application.processEvents() QTest.qWait(5) @pytest.fixture(scope="module") def application() -> QApplication: application = QApplication.instance() or QApplication([]) apply_theme(application) return application @pytest.fixture def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch): def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None): try: result = function() except Exception as error: if on_error: on_error(error) raise else: if on_success: on_success(result) finally: if on_finished: on_finished() def reject_network(*_args: Any, **_kwargs: Any): pytest.fail("The appointment visual tests must stay offline") monkeypatch.setattr(socket.socket, "connect", reject_network) monkeypatch.setattr(socket.socket, "connect_ex", reject_network) monkeypatch.setattr(socket, "create_connection", reject_network) monkeypatch.setattr(appointments_module, "run_async", run_inline) monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True) navigation = [ NavigationItem("appointments", "挂号列表", "号", AppointmentsPage, ("doctor.appointment/lists",)), NavigationItem("reception", "接诊台", "◎", _QuietPage, ("doctor.appointment/lists",)), NavigationItem("patients", "我的患者", "患", _QuietPage, ("firstvisit.myPatient/lists",)), NavigationItem("prescriptions", "已开处方", "笺", _QuietPage, ("tcm.prescription/lists",)), NavigationItem("legacy_reference", "参考页", "参", _QuietPage, ()), ] monkeypatch.setattr( shell_module, "_resolve_navigation", lambda *_args, **_kwargs: [(item, item.title) for item in navigation], ) windows = [] def create(*, admin: bool = False, width: int = 1536, height: int = 960): repository = _Repository() shell = ShellWindow( repository, {"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True}, permissions={"*"}, ) windows.append(shell) shell.resize(width, height) shell.show() _settle(application) page = shell.pages["appointments"] page.poll_timer.stop() return shell, page, repository yield create for shell in windows: shell.close() shell.deleteLater() _settle(application) def _selector(page: AppointmentsPage, row: int) -> QCheckBox: host = page.table.cellWidget(row, 0) assert host is not None selector = host.findChild(QCheckBox) assert selector is not None return selector def _assert_selection_matches(page: AppointmentsPage) -> None: table = page.table selected = {index.row() for index in table.selectionModel().selectedRows()} assert selected == {table.currentRow()} assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1 for row in range(table.rowCount()): assert _selector(page, row).isChecked() == (row in selected) assert table.cellWidget(row, 0).property("selected") == (row in selected) def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh( application: QApplication, window_factory ) -> None: shell, page, repository = window_factory() assert page.table.rowCount() == 3 _assert_selection_matches(page) checkbox = _selector(page, 1) QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton) _settle(application) assert page.table.currentRow() == 1 _assert_selection_matches(page) # Clicking the selected checkbox cannot leave the selected patient unchecked. QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton) _settle(application) _assert_selection_matches(page) assert page.table.currentRow() == 1 target = page.table.item(2, 2) QTest.mouseClick( page.table.viewport(), Qt.MouseButton.LeftButton, pos=page.table.visualItemRect(target).center(), ) _settle(application) assert page.table.currentRow() == 2 _assert_selection_matches(page) selected_id = page.table.current_data()["id"] shell.refresh_button.click() _settle(application) assert page.table.current_data()["id"] == selected_id _assert_selection_matches(page) # Changed server data rebuilds the cells, unlike unchanged polling. repository.rows[0]["channel_name"] = "更新后的测试渠道" shell.refresh_button.click() _settle(application) assert page.table.current_data()["id"] == selected_id _assert_selection_matches(page) def test_selector_selects_its_visible_patient_after_sorting( application: QApplication, window_factory ) -> None: _shell, page, _repository = window_factory() # A user can change sorting after cell widgets have already been installed. first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] header = page.table.horizontalHeader() header_position = QPoint( header.sectionViewportPosition(1) + header.sectionSize(1) // 2, header.height() // 2, ) for _ in range(2): QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position) _settle(application) if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id: break expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] assert expected_id != first_id QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton) _settle(application) assert page.table.current_data()["id"] == expected_id _assert_selection_matches(page) QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton) _settle(application) assert page.table.current_data()["id"] == expected_id _assert_selection_matches(page) def _rect(widget: QWidget, ancestor: QWidget) -> QRect: return QRect(widget.mapTo(ancestor, QPoint()), widget.size()) def _assert_filter_layout(page: AppointmentsPage) -> None: types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel) controls = [widget for widget in page.filter_panel.findChildren(QWidget) if isinstance(widget, types) and widget.isVisible()] panel = page.filter_panel.rect() for widget in controls: assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName() for first, second in combinations(controls, 2): if first.isAncestorOf(second) or second.isAncestorOf(first): continue overlap = _rect(first, page).intersected(_rect(second, page)) assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap) assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page)) assert page.table.viewport().height() > 80 @pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"]) @pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)]) def test_more_filters_fit_exact_window_and_preserve_all_columns( application: QApplication, window_factory, admin: bool, width: int, height: int ) -> None: shell, page, _repository = window_factory(admin=admin, width=width, height=height) page.filter_disclosure.set_expanded(True) _settle(application) for expanded in (False, True, False): if page.more_filters_button.isChecked() != expanded: QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton) _settle(application) assert shell.size() == QSize(width, height) assert page.advanced_filters.isVisible() == expanded assert page.dept_filter.isVisible() == expanded assert page.doctor_input.isVisible() == (expanded and admin) assert page.custom_date_button.isVisible() == expanded assert page.reset_filter_button.isVisible() == expanded _assert_filter_layout(page) assert page.table.columnCount() == 11 assert all(not page.table.isColumnHidden(column) for column in range(11)) assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [ "", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊", ] if width == 1024: assert page.table.horizontalScrollBar().maximum() > 0 page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum()) _settle(application) right = page.table.columnViewportPosition(10) + page.table.columnWidth(10) assert right <= page.table.viewport().width() def test_page_typography_remains_compact_and_chrome_restores( application: QApplication, window_factory ) -> None: shell, page, _repository = window_factory() original_qss = application.styleSheet() assert page.header.title_label.font().pixelSize() == 20 assert page.patient_input.font().pixelSize() == 14 assert page.table.font().pixelSize() == 14 assert page.table.item(0, 2).font().pixelSize() == 14 assert page.header.subtitle_label.font().pixelSize() == 13 assert page.more_filters_button.font().pixelSize() == 13 assert shell.sidebar.width() == 208 assert shell.topbar.height() == 76 assert shell.navigate("reception") _settle(application) assert shell.sidebar.width() == 208 assert shell.topbar.height() == 76 assert shell.navigate("patients") _settle(application) assert shell.sidebar.width() == 208 assert shell.topbar.height() == 76 assert shell.navigate("prescriptions") _settle(application) assert shell.sidebar.width() == 208 assert shell.topbar.height() == 76 assert shell.navigate("legacy_reference") _settle(application) assert shell.sidebar.width() == 190 assert shell.topbar.height() == 62 assert shell.workspace.pos() == QPoint(203, 13) assert shell.fold_button.isVisible() assert not shell.menu_sidebar_action.isVisible() for widget, stylesheet in shell._legacy_chrome_styles.items(): assert widget.styleSheet() == stylesheet assert shell.navigate("appointments") _settle(application) assert shell.sidebar.width() == 208 assert shell.topbar.height() == 76 assert page.header.title_label.font().pixelSize() == 20 assert application.styleSheet() == original_qss