327 lines
13 KiB
Python
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
|