Files
zyt/app/tests/test_appointments_visual_blue.py
2026-09-07 12:30:42 +08:00

327 lines
13 KiB
Python

"""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