更新
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
|
||||
|
||||
class _DiagnosisDialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page: int | None = None
|
||||
self.revision = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
second = bool(query.get("keyword") or query.get("patient_name"))
|
||||
offset = 1000 if second else 0
|
||||
total = 4 if second else 34
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": offset + index,
|
||||
"diagnosis_id": offset + index,
|
||||
"source_patient_id": index + 2000,
|
||||
"patient_id": index + 2000,
|
||||
"patient_name": f"患者{offset + index} · {self.revision}",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_id": offset + index,
|
||||
"appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(),
|
||||
"appointment_time": "09:00-09:30",
|
||||
"patient_phone": "13800001234",
|
||||
"doctor_name": "测试医生",
|
||||
"doctor_id": 30,
|
||||
"diagnosis_confirmed": True,
|
||||
"appointments": [],
|
||||
}
|
||||
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
|
||||
]
|
||||
result = {"lists": rows, "count": total}
|
||||
if page == 1:
|
||||
result["extend"] = {
|
||||
"status_count": {"1": total, "3": 7},
|
||||
"date_counts": {"today": total, "tomorrow": 9},
|
||||
}
|
||||
return result
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["appointments", "consultations"])
|
||||
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
module = appointments if request.param == "appointments" else consultations
|
||||
monkeypatch.setattr(module, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
repository = _Repository()
|
||||
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
|
||||
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
|
||||
page.resize(1280, 800)
|
||||
page.show()
|
||||
page.poll_timer.stop()
|
||||
_settle(application)
|
||||
yield page, repository, module
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _scroll_bottom(page: Any, application: QApplication) -> None:
|
||||
scrollbar = page.table.verticalScrollBar()
|
||||
assert scrollbar.maximum() > 0
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(6)
|
||||
if hasattr(page, "table_host"):
|
||||
model = page.table_host.model
|
||||
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
_scroll_bottom(page, application)
|
||||
|
||||
assert page.table.rowCount() == 30
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.current_data()["id"] == 7
|
||||
assert page.table.verticalScrollBar().value() > 0
|
||||
if hasattr(page, "table_host"):
|
||||
assert [row["id"] for row in page.table_host.selected_records()] == [7]
|
||||
else:
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 34
|
||||
assert len({row["id"] for row in page.pager.rows}) == 34
|
||||
assert not page.pager.has_more
|
||||
assert "已全部加载" in page.pager.summary_label.text()
|
||||
_scroll_bottom(page, application)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
_scroll_bottom(page, application)
|
||||
page.table.selectRow(19)
|
||||
old_scroll = page.table.verticalScrollBar().value()
|
||||
repository.calls.clear()
|
||||
repository.revision = 2
|
||||
page.refresh(silent=True)
|
||||
_settle(application)
|
||||
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.table.current_data()["id"] == 20
|
||||
assert page.table.current_data()["patient_name"].endswith(" · 2")
|
||||
assert page.table.verticalScrollBar().value() == old_scroll
|
||||
if isinstance(page, appointments.AppointmentsPage):
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
|
||||
def test_filter_change_supersedes_pending_append(
|
||||
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
page, _repository, module = queue_page
|
||||
jobs: list[tuple[Any, dict[str, Any]]] = []
|
||||
|
||||
def deferred(function: Any, **callbacks: Any) -> None:
|
||||
jobs.append((function, callbacks))
|
||||
|
||||
monkeypatch.setattr(module, "run_async", deferred)
|
||||
# Reconfigure the shared controller to use the deferred runner, then finish
|
||||
# that refresh before simulating a slow next page.
|
||||
page.refresh(silent=True)
|
||||
function, callbacks = jobs.pop()
|
||||
callbacks["on_success"](function())
|
||||
page.pager.load_more()
|
||||
append_function, append_callbacks = jobs.pop()
|
||||
stale_result = deepcopy(append_function())
|
||||
|
||||
search = page.patient_input if module is appointments else page.keyword_edit
|
||||
search.setText("第二组")
|
||||
page.refresh(silent=True)
|
||||
assert len(jobs) == 1
|
||||
function, callbacks = jobs.pop()
|
||||
assert function()["lists"][0]["id"] == 1001
|
||||
callbacks["on_success"](function())
|
||||
append_callbacks["on_success"](stale_result)
|
||||
_settle(application)
|
||||
|
||||
assert page.table.rowCount() == 4
|
||||
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
|
||||
assert page.pager.page == 1
|
||||
assert not page.pager.loading
|
||||
assert page.table.verticalScrollBar().value() == 0
|
||||
|
||||
|
||||
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
repository.fail_page = 2
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 15
|
||||
assert page.pager.page == 1
|
||||
assert page.pager.retry_button.isVisible()
|
||||
calls = len(repository.calls)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls
|
||||
|
||||
repository.fail_page = None
|
||||
page.pager.retry_button.click()
|
||||
_settle(application)
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.pager.page == 2
|
||||
assert page.pager.retry_button.isHidden()
|
||||
|
||||
|
||||
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
|
||||
page, _repository, _module = queue_page
|
||||
for height in (768, 960):
|
||||
page.resize(1280, height)
|
||||
_settle(application)
|
||||
assert isinstance(page.pager, InfiniteList)
|
||||
assert page.pager.height() == 24
|
||||
assert not page.pager.findChildren(QComboBox)
|
||||
assert not page.pager.findChildren(QSpinBox)
|
||||
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
|
||||
assert content.layout().contentsMargins().bottom() == 8
|
||||
@@ -0,0 +1,326 @@
|
||||
"""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
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Native Qt coverage for presentation-only filter folding on both queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Dialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(query)
|
||||
rows = [{
|
||||
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
|
||||
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
|
||||
"patient_phone": "13800001234", "gender": 2, "age": 38,
|
||||
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
|
||||
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
|
||||
"appointment_id": index + 1000, "appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
|
||||
"diagnosis_confirmed": True, "has_prescription": False,
|
||||
"appointments": [{"id": index + 1000, "status": 1,
|
||||
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
|
||||
} for index in range(1, 16)]
|
||||
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
QTest.qWait(10)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(appointments, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
navigation = [
|
||||
NavigationItem("appointments", "挂号列表", "号", appointments.AppointmentsPage,
|
||||
("doctor.appointment/lists",)),
|
||||
NavigationItem("consultations", "问诊列表", "询", consultations.ConsultationsPage,
|
||||
("tcm.diagnosis/lists",)),
|
||||
]
|
||||
monkeypatch.setattr(shell_module, "_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
|
||||
repository = _Repository()
|
||||
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
|
||||
"demo_mode": True}, permissions={"*"})
|
||||
yield window, repository
|
||||
window.close()
|
||||
window.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
|
||||
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
|
||||
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
|
||||
application: QApplication, list_window: Any, kind: str, width: int, height: int,
|
||||
) -> None:
|
||||
window, repository = list_window
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
page = window.pages[kind]
|
||||
page.poll_timer.stop()
|
||||
panel = page.filter_panel if kind == "appointments" else page.filters_card
|
||||
header = page.header if kind == "appointments" else page.page_header
|
||||
search = page.patient_input if kind == "appointments" else page.patient_name_edit
|
||||
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
|
||||
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
|
||||
disclosure = page.filter_disclosure
|
||||
filters = page._query_filters if kind == "appointments" else page._filters
|
||||
assert not disclosure.expanded
|
||||
assert panel.isHidden() and not search.isVisible()
|
||||
assert header.height() <= 44
|
||||
assert not header.subtitle_label.isVisible()
|
||||
assert disclosure.button.isVisible() and action.isVisible()
|
||||
assert 30 <= disclosure.button.height() <= 34
|
||||
assert window.refresh_button.isVisible()
|
||||
assert any(button.isVisible() and button.text() == "刷新"
|
||||
for button in page.findChildren(QPushButton))
|
||||
collapsed_height = page.table.viewport().height()
|
||||
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
|
||||
if capture_dir:
|
||||
Path(capture_dir).mkdir(parents=True, exist_ok=True)
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
|
||||
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert panel.isVisible() and search.isVisible()
|
||||
assert collapsed_height >= page.table.viewport().height() + 100
|
||||
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
|
||||
assert search.parentWidget().rect().contains(search.geometry())
|
||||
assert not page.advanced_filters.isVisible()
|
||||
if capture_dir:
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
|
||||
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
|
||||
json.dumps({"window": [width, height], "header_height": header.height(),
|
||||
"toggle_height": disclosure.button.height(),
|
||||
"collapsed_viewport_height": collapsed_height,
|
||||
"expanded_viewport_height": page.table.viewport().height(),
|
||||
"viewport_gain": collapsed_height - page.table.viewport().height()},
|
||||
indent=2), encoding="utf-8",
|
||||
)
|
||||
|
||||
search.setText("演示患者")
|
||||
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
if kind == "appointments" else page.search_button)
|
||||
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) > calls_before
|
||||
assert repository.calls[-1]["patient_name"] == "演示患者"
|
||||
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible()
|
||||
query = dict(filters())
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert filters() == query
|
||||
assert search.text() == "演示患者"
|
||||
assert panel.isHidden()
|
||||
assert page.table.viewport().height() == collapsed_height
|
||||
|
||||
window.resize(width - 50, height - 20)
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
assert filters() == query
|
||||
other = "consultations" if kind == "appointments" else "appointments"
|
||||
assert window.navigate(other)
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible() and nested.isChecked()
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert disclosure.expanded and panel.isVisible()
|
||||
assert filters() == query
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Page disclosures reclaim list space without changing queries or tab state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||
self.calls.append((kind, deepcopy(query)))
|
||||
return {
|
||||
"lists": [{
|
||||
"id": 101 + index, "diagnosis_id": 501 + index,
|
||||
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
|
||||
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
|
||||
"appointment_status": 1, "queue_status": "waiting",
|
||||
"queue_status_text": "等待中", "doctor_name": "测试医生",
|
||||
} for index in range(4)],
|
||||
"count": 4,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
|
||||
"summary": {"waiting": 4, "today": 4, "orders": 4},
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("patients", query)
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("orders", query)
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("progress", query)
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application, monkeypatch):
|
||||
def immediate(function, *, 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, **_kwargs):
|
||||
pytest.fail("Disclosure tests must use local fixture data")
|
||||
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
opened = []
|
||||
|
||||
def create(width=1328, height=884):
|
||||
repository = Repository()
|
||||
page = PatientsPage(repository, PermissionSet(["*"]))
|
||||
opened.append(page)
|
||||
page.resize(width, height)
|
||||
page.show()
|
||||
settle(application)
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def regions(page, index):
|
||||
return (
|
||||
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
|
||||
page.patient_workspace.summary_strip),
|
||||
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
|
||||
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
|
||||
)[index]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
|
||||
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
table = workspace.queue_table if index == 2 else workspace.table
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
|
||||
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.height() <= 48
|
||||
assert not page.header.subtitle_label.isVisible()
|
||||
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
|
||||
collapsed_height = table.viewport().height()
|
||||
before = deepcopy(repository.calls)
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
|
||||
assert all(widget.isVisible() for widget in regions(page, index))
|
||||
assert collapsed_height >= table.viewport().height() + 100
|
||||
assert repository.calls == before
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert table.viewport().height() == collapsed_height
|
||||
assert table.isVisible() and workspace.pager.isVisible()
|
||||
assert repository.calls == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
|
||||
def test_search_values_survive_collapse_refresh_and_tab_switch(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
workspace.keyword_edit.setText(" 林青 ")
|
||||
if index == 0:
|
||||
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
|
||||
else:
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.rx_audit.setCurrentIndex(2)
|
||||
workspace.start_date.setDate(QDate(2026, 9, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 9, 7))
|
||||
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
query = deepcopy(repository.calls[-1])
|
||||
before = deepcopy(repository.calls)
|
||||
page.filter_disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert repository.calls == before
|
||||
assert workspace.keyword_edit.text() == " 林青 "
|
||||
assert workspace.start_date.date() == QDate(2026, 9, 1)
|
||||
assert workspace.end_date.date() == QDate(2026, 9, 7)
|
||||
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert repository.calls[-1] == query
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert repository.calls[-1] == query
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
|
||||
assert (workspace.custom_date_button.isChecked() if index == 0 else
|
||||
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert page.filter_disclosure.expanded
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
|
||||
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
|
||||
application, page_factory, width, height
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
page.resize(width, height)
|
||||
for index in range(3):
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
for widget in (page.filter_disclosure.button, page.refresh_button):
|
||||
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
|
||||
assert workspace.scroll.verticalScrollBar().maximum() == 0
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
if index == 2:
|
||||
assert workspace.splitter.minimumHeight() == 222
|
||||
assert workspace.queue_card.isVisible()
|
||||
assert workspace.queue_card.height() == workspace.splitter.height()
|
||||
page.hide()
|
||||
page.show()
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, 2))
|
||||
|
||||
|
||||
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
|
||||
application, page_factory, monkeypatch
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(
|
||||
patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)),
|
||||
)
|
||||
page.refresh()
|
||||
page.refresh()
|
||||
assert len(pending) == 1
|
||||
function, callbacks = pending[0]
|
||||
callbacks["on_success"](function())
|
||||
settle(application)
|
||||
assert page.patient_workspace.table.rowCount() == 4
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert not page.patient_workspace.search_toolbar.isVisible()
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application):
|
||||
for _ in range(8):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1280, 800)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "展开筛选"
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.queue_filter_summary.isVisible()
|
||||
assert not page.queue_filter_panel.isVisible()
|
||||
assert not page.queue_date_button.isVisible()
|
||||
assert not page.search_edit.isVisible()
|
||||
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert page.detail_tabs.isVisible()
|
||||
assert page.patient_name_label.isVisible()
|
||||
assert page.clinical_info_group.isVisible()
|
||||
clinical_geometry = page.detail_stack.geometry()
|
||||
collapsed_height = page.queue_stack.height()
|
||||
|
||||
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "收起筛选"
|
||||
assert page.queue_date_button.isVisible()
|
||||
assert page.search_edit.isVisible()
|
||||
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert collapsed_height - page.queue_stack.height() >= 64
|
||||
assert page.detail_stack.geometry() == clinical_geometry
|
||||
|
||||
|
||||
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
|
||||
requests = []
|
||||
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.queue_date_button._pick_date(QDate(2026, 8, 3))
|
||||
page._queue_filter_buttons[None].click()
|
||||
page.search_edit.setText(" 折叠测试患者 ")
|
||||
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
|
||||
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
|
||||
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
|
||||
|
||||
page._selected_appointment_id = 51
|
||||
page._queue_records = [{"id": index} for index in range(1, 31)]
|
||||
page._queue_page = 2
|
||||
page._queue_total = 40
|
||||
page.note_edit.setPlainText("尚未保存的接诊备注")
|
||||
page.detail_tabs.setCurrentIndex(4)
|
||||
query_snapshot = dict(page._queue_query)
|
||||
request_count = len(requests)
|
||||
loading = page._queue_loading
|
||||
|
||||
for expanded in (False, True, False):
|
||||
page.filter_disclosure.set_expanded(expanded)
|
||||
page.resize(816 if expanded else 1280, 800)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded is expanded
|
||||
assert page.queue_filter_panel.isVisible() is expanded
|
||||
assert len(requests) == request_count
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page._queue_page == 2
|
||||
assert len(page._queue_records) == 30
|
||||
assert page._queue_loading is loading
|
||||
assert page._queue_query == query_snapshot
|
||||
assert page.search_edit.text() == " 折叠测试患者 "
|
||||
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
|
||||
assert page.detail_tabs.currentIndex() == 4
|
||||
|
||||
page.refresh(workspace_refresh=True)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == "2026-08-03"
|
||||
assert requests[-1][0]["page_size"] == 30
|
||||
assert page._selected_appointment_id == 51
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [816, 1280])
|
||||
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.resize(width, 800)
|
||||
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
|
||||
settle(application)
|
||||
panel = page.queue_panel
|
||||
controls = [
|
||||
page.refresh_button,
|
||||
page.filter_disclosure.button,
|
||||
page.queue_date_button,
|
||||
page.search_edit,
|
||||
*page._queue_filter_buttons.values(),
|
||||
]
|
||||
for widget in controls:
|
||||
position = widget.mapTo(panel, QPoint())
|
||||
assert position.x() >= 0
|
||||
assert position.x() + widget.width() <= panel.width()
|
||||
assert widget.width() >= widget.minimumSizeHint().width()
|
||||
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
|
||||
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DiagnosisTableHost,
|
||||
_blue_appointment_layout,
|
||||
_blue_appointment_text,
|
||||
_blue_font,
|
||||
)
|
||||
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(12):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _row(identifier: int = 501) -> dict:
|
||||
return {
|
||||
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||
"appointments": [
|
||||
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||
],
|
||||
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||
"unserved_days": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||
row = _row()
|
||||
before = copy.deepcopy(row)
|
||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||
)
|
||||
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||
"—", "2026-08-06 时间 —",
|
||||
)
|
||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||
for missing_id in (0, "", None):
|
||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||
assert row == before
|
||||
legacy = DiagnosisTableHost()
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
for host in (legacy, blue):
|
||||
host.set_rows([row])
|
||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||
assert blue.model.index(0, 4).data().splitlines() == [
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||
]
|
||||
assert blue.model.index(0, 9).data() == "—"
|
||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||
blue.set_rows([{**row, "appointments": []}])
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||
legacy.close()
|
||||
blue.close()
|
||||
|
||||
|
||||
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
legacy = DiagnosisTableHost()
|
||||
row = _row()
|
||||
single = _row(502)
|
||||
single["appointments"] = single["appointments"][:1]
|
||||
long = copy.deepcopy(single)
|
||||
long["id"] = long["diagnosis_id"] = 503
|
||||
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||
long["has_prescription"] = 1
|
||||
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||
long["followup_doctor_name"] = "复诊医生姓名"
|
||||
long["followup_rx_voided"] = True
|
||||
blue.set_rows([row, single, long])
|
||||
legacy.set_rows([row])
|
||||
assert blue.model.columnCount() == 12
|
||||
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||
assert blue.main.horizontalHeader().height() == 41
|
||||
assert legacy.main.horizontalHeader().height() == 39
|
||||
assert blue.main.rowHeight(0) == 108
|
||||
assert blue.main.rowHeight(1) == 72
|
||||
assert blue.main.rowHeight(2) > 110
|
||||
assert legacy.main.rowHeight(0) == 96
|
||||
for index in range(3):
|
||||
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||
assert _blue_font(14).family() == body_family()
|
||||
assert _blue_font(14, medium=True).family() == heading_family()
|
||||
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||
assert _blue_font(13).pixelSize() == 13
|
||||
blue.close()
|
||||
legacy.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||
application: QApplication, tech_blue: bool,
|
||||
) -> None:
|
||||
window = QWidget()
|
||||
layout = QVBoxLayout(window)
|
||||
layout.setContentsMargins(7, 7, 7, 7)
|
||||
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||
layout.addWidget(host, 1)
|
||||
footer = QLabel("始终可达的分页")
|
||||
footer.setFixedHeight(42)
|
||||
layout.addWidget(footer)
|
||||
host.set_rows([_row(i) for i in range(40)])
|
||||
window.resize(1280, 640)
|
||||
window.show()
|
||||
_settle(application)
|
||||
minimum = window.minimumSizeHint().height()
|
||||
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||
window.resize(width, height)
|
||||
_settle(application)
|
||||
# The previous implementation wrote the available height into the
|
||||
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||
assert window.size().height() == height
|
||||
assert window.minimumSizeHint().height() == minimum
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
frozen_x = host.fixed.x()
|
||||
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
assert host.fixed.x() == frozen_x
|
||||
assert host.fixed.geometry().right() <= host.rect().right()
|
||||
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||
window.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True)
|
||||
host.resize(1300, 420)
|
||||
host.set_rows([_row(501), _row(502)])
|
||||
host.show()
|
||||
_settle(application)
|
||||
host.main.selectRow(1)
|
||||
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert [row["id"] for row in host.selected_records()] == [502]
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert host.selected_records() == []
|
||||
assert host.main.currentRow() == 1
|
||||
header = host.main.horizontalHeader()
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
assert host.main.currentRow() == 1
|
||||
host.main.selectRow(0)
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert host.selected_records() == []
|
||||
host.close()
|
||||
|
||||
|
||||
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||
})
|
||||
row = _row()
|
||||
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||
row["appointments"][1]["status"] = 4
|
||||
host.set_rows([row])
|
||||
host.resize(1300, 500)
|
||||
host.show()
|
||||
_settle(application)
|
||||
received = []
|
||||
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||
buttons = overlay.findChildren(QToolButton)
|
||||
assert len(buttons) == 2
|
||||
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||
for position, button in enumerate(buttons):
|
||||
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||
assert button.geometry().bottom() < overlay.height()
|
||||
button.click()
|
||||
assert received == [(501, 2501), (501, 2401)]
|
||||
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||
row["video_call_hint"] = {"state": "live"}
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and video.isEnabled()
|
||||
row["patient_id"] = 0
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and not video.isEnabled()
|
||||
host.action_policy["appointment_cancel"] = False
|
||||
host.set_rows([row])
|
||||
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||
script = tmp_path / "shell_navigation.py"
|
||||
script.write_text('''
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
app = QApplication([])
|
||||
apply_theme(app)
|
||||
def settle():
|
||||
for _ in range(12):
|
||||
QThreadPool.globalInstance().waitForDone(1000)
|
||||
app.processEvents()
|
||||
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
settle()
|
||||
for route in ("consultations", "appointments", "consultations"):
|
||||
shell.navigate(route)
|
||||
settle()
|
||||
host = shell.pages["consultations"].table_host
|
||||
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||
shell.resize(width, height)
|
||||
settle()
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert shell.height() == height
|
||||
shell.close()
|
||||
settle()
|
||||
print("native navigation geometry stable")
|
||||
''', encoding="utf-8")
|
||||
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "native navigation geometry stable" in result.stdout
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(10):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||
application: QApplication, width: int
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(width, 640)
|
||||
host.show()
|
||||
editor = PrescriptionEditorDialog(
|
||||
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||
parent=host,
|
||||
)
|
||||
editor.show()
|
||||
settle(application)
|
||||
try:
|
||||
display = editor.date_edit._display
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
for button in (editor.gender_male, editor.gender_female):
|
||||
assert button.width() >= button.sizeHint().width()
|
||||
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||
assert len({control.height() for control in controls}) == 1
|
||||
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||
editor.prescription_type.setCurrentText("饮片")
|
||||
settle(application)
|
||||
assert not editor._main_decoction_field.isHidden()
|
||||
assert editor._main_bag_field.isHidden()
|
||||
if width == 860:
|
||||
for resized_width in (600, 860):
|
||||
editor.resize(resized_width, 640)
|
||||
settle(application)
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||
finally:
|
||||
editor.close()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||
tabs = DiagnosisTabWidget()
|
||||
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||
tabs.addTab(QWidget(), title)
|
||||
tabs.resize(614, 480)
|
||||
tabs.show()
|
||||
settle(application)
|
||||
try:
|
||||
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||
assert tabs.tab_scrollbar.isVisible()
|
||||
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||
assert tabs.currentIndex() == tabs.count() - 1
|
||||
finally:
|
||||
tabs.close()
|
||||
|
||||
|
||||
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||
dialog = QDialog()
|
||||
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||
layout = QVBoxLayout(dialog)
|
||||
card = _SlotCard("09:00-09:30", "可约")
|
||||
card.setProperty("appointmentSlot", True)
|
||||
card.setProperty("availability", "available")
|
||||
card.setCheckable(True)
|
||||
layout.addWidget(card)
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||
card.setChecked(checked)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||
card.setChecked(True)
|
||||
card.setEnabled(False)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def immediate(function, **kwargs):
|
||||
kwargs["on_success"](function())
|
||||
if kwargs.get("on_finished"):
|
||||
kwargs["on_finished"]()
|
||||
|
||||
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||
assert abs(centers[0] - centers[1]) <= 1
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
for module in (ai_consult_picker, diagnosis, prescription):
|
||||
monkeypatch.setattr(module, "run_async", run)
|
||||
|
||||
|
||||
class ListRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page = 0
|
||||
|
||||
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||
self.calls.append(filters)
|
||||
page, size = filters["page_no"], filters["page_size"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
start = (page - 1) * size + 1
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": number,
|
||||
"diagnosis_id": number,
|
||||
"patient_id": 1000 + number,
|
||||
"patient_name": f"患者{number}",
|
||||
"prescription_id": 77,
|
||||
"prescription_name": f"处方{number}",
|
||||
"herbs": [{"name": "白术", "dosage": 10}],
|
||||
"order_no": f"ORDER{number}",
|
||||
}
|
||||
for number in range(start, min(start + size, 46))
|
||||
],
|
||||
"count": 45,
|
||||
}
|
||||
|
||||
list_prescription_templates = _list
|
||||
list_prescription_orders = _list
|
||||
list_ai_patient_options = _list
|
||||
|
||||
|
||||
def scroll_to_bottom(table: Any) -> None:
|
||||
scrollbar = table.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
QTest.qWait(70)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||
def test_scroll_appends_and_retains_selected_business_identity(
|
||||
application: QApplication, immediate_async: None, kind: str
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
if kind == "template":
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
controller = dialog.infinite_list
|
||||
elif kind == "order":
|
||||
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||
controller = dialog.infinite_list
|
||||
else:
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
controller = dialog.pager
|
||||
dialog.resize(980, 520)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
page_size = controller.page_size
|
||||
assert dialog.table.rowCount() == page_size
|
||||
assert not any(
|
||||
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||
)
|
||||
dialog.table.selectRow(2)
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
assert dialog.table.currentRow() == 2
|
||||
assert all(
|
||||
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
for row in range(dialog.table.rowCount())
|
||||
for column in range(dialog.table.columnCount())
|
||||
)
|
||||
repository.fail_page = 3
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert controller.retry_button.isVisible()
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
repository.fail_page = 0
|
||||
controller.retry_button.click()
|
||||
assert dialog.table.rowCount() == 45
|
||||
assert not controller.has_more
|
||||
assert dialog.table.currentRow() == 2
|
||||
if kind == "template":
|
||||
dialog.accept()
|
||||
assert dialog.selected_template()["id"] == 3
|
||||
elif kind == "ai":
|
||||
dialog.accept()
|
||||
assert dialog.selected_target().diagnosis_id == 3
|
||||
assert dialog.selected_target().patient_id == 1003
|
||||
else:
|
||||
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||
)
|
||||
repository = ListRepository()
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
dialog.name_edit.setText("白术")
|
||||
dialog.formula_combo.setCurrentIndex(1)
|
||||
dialog.search()
|
||||
assert len(pending) == 2
|
||||
latest_function, latest_callbacks = pending[1]
|
||||
latest_callbacks["on_success"](latest_function())
|
||||
old_function, old_callbacks = pending[0]
|
||||
old_callbacks["on_success"](old_function())
|
||||
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||
assert dialog.table.rowCount() == 15
|
||||
assert not dialog.banner.isVisible()
|
||||
assert repository.calls[0] == {
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"prescription_name": "白术",
|
||||
"formula_type": "主方",
|
||||
"prescribing_creator_id": 42,
|
||||
}
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
class Repository(ListRepository):
|
||||
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||
result = self._list(**filters)
|
||||
if filters["page_no"] == 1:
|
||||
for row in result["lists"]:
|
||||
row["id"] = -row["id"]
|
||||
row["diagnosis_id"] = row["id"]
|
||||
return result
|
||||
|
||||
repository = Repository()
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
QTest.qWait(100)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == 20
|
||||
assert dialog.table.item(0, 3).text() == "21"
|
||||
assert dialog.table.isVisible()
|
||||
assert not dialog.empty_state.isVisible()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
dialog = diagnosis.DiagnosisDialog(
|
||||
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||
)
|
||||
dialog.open_for(
|
||||
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||
)
|
||||
application.processEvents()
|
||||
try:
|
||||
table = dialog._table_registry["orders"][0]
|
||||
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||
assert table.rowCount() == 10
|
||||
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||
table.selectRow(2)
|
||||
scroll_to_bottom(table)
|
||||
assert table.rowCount() == 20
|
||||
assert table.currentRow() == 2
|
||||
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
assert repository.calls[-1] == {
|
||||
"page_no": 2,
|
||||
"page_size": 10,
|
||||
"patient_id": 301,
|
||||
"context_diagnosis_id": 501,
|
||||
"scene": "diagnosis_edit",
|
||||
}
|
||||
dialog.open_for(
|
||||
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||
)
|
||||
assert table.rowCount() == 10
|
||||
assert repository.calls[-1]["page_no"] == 1
|
||||
assert repository.calls[-1]["patient_id"] == 302
|
||||
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Search disclosure preserves queries, permissions, focus and usable list space."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
value = function()
|
||||
if on_success:
|
||||
on_success(value)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
|
||||
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
panel = QWidget(host)
|
||||
form = QVBoxLayout(panel)
|
||||
field = QLineEdit("保留查询", panel)
|
||||
forbidden = QPushButton("无权限操作", panel)
|
||||
forbidden.hide()
|
||||
form.addWidget(field)
|
||||
form.addWidget(forbidden)
|
||||
disclosure = FilterDisclosure(host, [panel])
|
||||
layout.addWidget(disclosure.button)
|
||||
layout.addWidget(panel)
|
||||
host.show()
|
||||
settle(application)
|
||||
assert not panel.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert panel.isVisible() and not forbidden.isVisible()
|
||||
field.setFocus()
|
||||
disclosure.set_expanded(False)
|
||||
assert QApplication.focusWidget() is disclosure.button
|
||||
disclosure.set_expanded(True)
|
||||
assert field.text() == "保留查询" and forbidden.isHidden()
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
|
||||
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
|
||||
module = prescriptions if kind == "issued" else prescription_library
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
|
||||
page = page_type(repository, {"*"}, session.user)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
settle(application)
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded and page.filter_card.isHidden()
|
||||
assert page.header.height() == 44
|
||||
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
|
||||
assert disclosure.button.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
|
||||
compact_height = page.table.height()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert disclosure.expanded and page.filter_card.isVisible()
|
||||
assert compact_height - page.table.height() >= 100
|
||||
field = page.sn_filter if kind == "issued" else page.name_filter
|
||||
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
|
||||
field.setText("不存在的查询")
|
||||
# Folding must not issue a request or clear an unfinished query.
|
||||
generation = page._generation
|
||||
disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert page._generation == generation
|
||||
assert field.text() == "不存在的查询"
|
||||
disclosure.set_expanded(True)
|
||||
QTest.keyClick(field, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
assert page._generation > generation
|
||||
assert disclosure.expanded
|
||||
disclosure.set_expanded(False)
|
||||
page.refresh()
|
||||
page.resize(size[0] + 10, size[1])
|
||||
settle(application)
|
||||
assert not disclosure.expanded and not page.filter_card.isVisible()
|
||||
disclosure.set_expanded(True)
|
||||
page.reset_button.click()
|
||||
settle(application)
|
||||
assert field.text() == "" and page.table.rowCount() == 2
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Contract for the shared icon system.
|
||||
|
||||
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
|
||||
painters that disagreed on stroke weight, design grid and palette. These tests
|
||||
lock in the properties that keep the set reading as one family, so a new glyph
|
||||
cannot quietly reintroduce a one-off weight or a clipped mark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.theme import COLORS
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _rendered(kind: str, size: int) -> QImage:
|
||||
return icons.pixmap(kind, "strong", size).toImage()
|
||||
|
||||
|
||||
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
|
||||
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
|
||||
|
||||
left, top = image.width(), image.height()
|
||||
right = bottom = -1
|
||||
count = 0
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
if image.pixelColor(x, y).alpha() > 24:
|
||||
count += 1
|
||||
left, top = min(left, x), min(top, y)
|
||||
right, bottom = max(right, x), max(bottom, y)
|
||||
return left, top, right, bottom, count
|
||||
|
||||
|
||||
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""No glyph may touch the edge of its pixmap.
|
||||
|
||||
Clipping is what the old painters did whenever a call site asked for a size
|
||||
other than the one the geometry was authored against - the shell's ``ai``
|
||||
star lost its companion dot, and the prescription icons were drawn with
|
||||
16 px geometry inside 14 px and 15 px boxes.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
for size in (14, 16, 18, 20, 24):
|
||||
image = _rendered(kind, size)
|
||||
device = image.width() # honours the device pixel ratio
|
||||
left, top, right, bottom, count = _ink_bounds(image)
|
||||
|
||||
assert count > 0, f"{kind}@{size} painted nothing"
|
||||
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
|
||||
assert right < device - 1 and bottom < device - 1, (
|
||||
f"{kind}@{size} is clipped at the bottom/right"
|
||||
)
|
||||
|
||||
|
||||
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Every mark lives in the same safe area, so none reads over- or undersized.
|
||||
|
||||
The set this replaces mixed glyphs that spanned the full 18 px box with a
|
||||
``close`` cross that spanned only 7 px, which is why the title bar controls
|
||||
never looked like siblings.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
image = _rendered(kind, 24)
|
||||
device = image.width()
|
||||
left, top, right, bottom, _count = _ink_bounds(image)
|
||||
extent = max(right - left, bottom - top) / device
|
||||
|
||||
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
|
||||
|
||||
|
||||
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
|
||||
assert icons.stroke_px(24) == pytest.approx(2.0)
|
||||
assert icons.stroke_px(18) == pytest.approx(1.5)
|
||||
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
|
||||
# Clamped so a small icon stays visible and a large one does not turn slab.
|
||||
assert icons.stroke_px(8) == pytest.approx(1.25)
|
||||
assert icons.stroke_px(64) == pytest.approx(2.25)
|
||||
|
||||
|
||||
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A list page builds one icon per action button per row on every refresh.
|
||||
|
||||
Without the cache that is a fresh ``QPainter`` run per button; the pages in
|
||||
this product ask for the same handful of (kind, colour, size) triples over
|
||||
and over, so the cache turns a per-row cost into a per-process one.
|
||||
"""
|
||||
|
||||
icons.clear_cache()
|
||||
for _ in range(50):
|
||||
icons.icon("eye", "accent", 15)
|
||||
icons.icon("trash", "danger", 15)
|
||||
|
||||
# Two glyphs, each painted once in its requested colour and once disabled.
|
||||
assert icons._cached_pixmap.cache_info().misses == 4
|
||||
# The remaining 98 calls are answered from the icon cache without painting.
|
||||
assert icons._cached_icon.cache_info().hits == 98
|
||||
|
||||
|
||||
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
|
||||
assert icons.resolve_color("accent") == COLORS["indigo"]
|
||||
assert icons.resolve_color("danger") == COLORS["danger"]
|
||||
assert icons.resolve_color("muted") == COLORS["muted"]
|
||||
# A literal colour still passes through for the few bespoke tints that remain.
|
||||
assert icons.resolve_color("#8268E8") == "#8268E8"
|
||||
|
||||
|
||||
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Navigation rows invert on selection, so the icon has to invert with them."""
|
||||
|
||||
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
|
||||
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
|
||||
|
||||
assert off != on
|
||||
assert _dominant_ink(on) == QColor("#FFFFFF")
|
||||
|
||||
|
||||
def _dominant_ink(image: QImage) -> QColor:
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
colour = image.pixelColor(x, y)
|
||||
if colour.alpha() > 240:
|
||||
colour.setAlpha(255)
|
||||
return colour
|
||||
raise AssertionError("no opaque pixel found")
|
||||
|
||||
|
||||
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
|
||||
"""Six sidebar entries need six silhouettes.
|
||||
|
||||
The reception detail tabs previously shared three near-identical
|
||||
"document with lines" marks, which made them unreadable at 16 px.
|
||||
"""
|
||||
|
||||
renders = {
|
||||
kind: _rendered(kind, 24).constBits().tobytes()
|
||||
for kind in (
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
"report",
|
||||
"meds",
|
||||
"daily",
|
||||
"followup",
|
||||
)
|
||||
}
|
||||
|
||||
assert len(set(renders.values())) == len(renders)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Incremental requests retain business rows and survive query changes/errors."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.widgets import get_value, page_items
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class Requests:
|
||||
def __init__(self):
|
||||
self.pending = []
|
||||
|
||||
def __call__(self, function, **callbacks):
|
||||
self.pending.append((function, callbacks))
|
||||
|
||||
def finish(self, index=0, error=None):
|
||||
fn, cb = self.pending.pop(index)
|
||||
if error:
|
||||
cb["on_error"](error)
|
||||
else:
|
||||
cb["on_success"](fn())
|
||||
cb["on_finished"]()
|
||||
|
||||
|
||||
def setup(app, size=2):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
table = QTableWidget(0, 2)
|
||||
footer = InfiniteList(size)
|
||||
footer.bind(table)
|
||||
layout.addWidget(table)
|
||||
layout.addWidget(footer)
|
||||
host.resize(420, 220)
|
||||
seen = []
|
||||
errors = []
|
||||
jobs = Requests()
|
||||
|
||||
def apply(result):
|
||||
rows = page_items(result)
|
||||
seen.append(result)
|
||||
table.setRowCount(len(rows))
|
||||
for i, row in enumerate(rows):
|
||||
for col in range(2):
|
||||
item = QTableWidgetItem(str(row["id"]))
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
if col == 1:
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Unchecked)
|
||||
table.setItem(i, col, item)
|
||||
|
||||
return host, table, footer, seen, errors, jobs, apply
|
||||
|
||||
|
||||
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
|
||||
host, table, f, seen, errors, jobs, apply = setup(app)
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
|
||||
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
|
||||
3: {"items": [{"id": 4}], "total": 4},
|
||||
}
|
||||
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
table.selectRow(1)
|
||||
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
|
||||
f.load_more()
|
||||
f.load_more()
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert [r["id"] for r in f.rows] == [1, 2, 3]
|
||||
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
|
||||
assert get_value(seen[-1], "extend.scope") == "mine"
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert not f.has_more
|
||||
assert table.rowCount() == 4 and not errors
|
||||
host.close()
|
||||
|
||||
|
||||
def test_changed_query_discards_old_success_and_error(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 1}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 2}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="b",
|
||||
)
|
||||
jobs.finish(1)
|
||||
jobs.finish(0, error=RuntimeError("stale"))
|
||||
assert f.rows == [{"id": 2}] and not errors and not f.loading
|
||||
f.invalidate()
|
||||
assert not f.has_more
|
||||
host.close()
|
||||
|
||||
|
||||
def test_append_failure_retry_and_empty_end_are_bounded(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish(error=RuntimeError("offline"))
|
||||
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
|
||||
f.load_more()
|
||||
assert not jobs.pending
|
||||
f.retry()
|
||||
jobs.finish()
|
||||
assert f.page == 2 and not f.has_more
|
||||
assert calls == [1, 2] and len(errors) == 1
|
||||
host.close()
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_until_complete(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.loading
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.page == 2
|
||||
assert calls == [1, 2, 1, 2]
|
||||
host.close()
|
||||
|
||||
|
||||
def test_scroll_autofill_then_load_at_bottom(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
|
||||
|
||||
def fetch(p):
|
||||
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
|
||||
|
||||
host.show()
|
||||
app.processEvents()
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
app.processEvents()
|
||||
QTest.qWait(60)
|
||||
assert not jobs.pending
|
||||
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
|
||||
QTest.qWait(60)
|
||||
assert len(jobs.pending) == 1
|
||||
old = t.verticalScrollBar().value()
|
||||
jobs.finish()
|
||||
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
|
||||
host.close()
|
||||
|
||||
|
||||
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
|
||||
host, table, footer, seen, errors, jobs, apply = setup(app)
|
||||
completed = []
|
||||
|
||||
def fetch(page):
|
||||
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
|
||||
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
footer.reload(
|
||||
fetch,
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
on_finished=lambda: completed.append("latest"),
|
||||
)
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert completed == ["latest"] and table.rowCount() == 2
|
||||
footer.load_more()
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert table.rowCount() == 4 and footer.page == 2
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish() # Prefix page 2 now pending.
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
|
||||
host.close()
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import (
|
||||
appointments,
|
||||
consultations,
|
||||
patients,
|
||||
prescription_library,
|
||||
prescriptions,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import PageHeader
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
|
||||
on_finished=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()
|
||||
|
||||
modules = (appointments, consultations, patients, prescription_library, prescriptions)
|
||||
for module in modules:
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(kind: str, width: int = 1040):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
classes = {
|
||||
"appointments": appointments.AppointmentsPage,
|
||||
"patients": patients.PatientsPage,
|
||||
"consultations": consultations.ConsultationsPage,
|
||||
"prescriptions": prescriptions.PrescriptionsPage,
|
||||
"prescription_library": prescription_library.PrescriptionLibraryPage,
|
||||
}
|
||||
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
|
||||
opened.append(page)
|
||||
page.resize(width, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
return page
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
|
||||
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
header = page.findChild(PageHeader)
|
||||
assert header is not None
|
||||
for label in header.findChildren(QLabel):
|
||||
if label.isVisible() and label.text() and label.property("role"):
|
||||
assert label.height() >= label.fontMetrics().height(), (
|
||||
kind, label.text(), label.height(), label.fontMetrics().height()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
|
||||
@pytest.mark.parametrize("width", [1040, 1200])
|
||||
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
|
||||
page = page_factory(kind, width)
|
||||
table = page.patient_workspace.table if kind == "patients" else page.table
|
||||
assert table.rowCount() > 0
|
||||
assert table.horizontalHeader().visualIndex(9) == 9
|
||||
actions = table.cellWidget(0, 9)
|
||||
assert actions is not None
|
||||
assert table.horizontalScrollBar().value() == 0
|
||||
# Both approved layouts put actions after the nine data columns. Preserve
|
||||
# full-size controls and the existing reachability assertion after scrolling.
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
QApplication.processEvents()
|
||||
assert actions.geometry().left() >= 0
|
||||
assert actions.geometry().right() < table.viewport().width()
|
||||
assert actions.height() >= actions.minimumSizeHint().height()
|
||||
for child in actions.findChildren(QPushButton):
|
||||
assert actions.rect().contains(child.mapTo(actions, QPoint()))
|
||||
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
|
||||
|
||||
|
||||
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
|
||||
page = page_factory("appointments")
|
||||
table = page.table
|
||||
assert table.rowCount() > 0
|
||||
host = table.cellWidget(0, 4)
|
||||
assert host.width() >= host.minimumSizeHint().width()
|
||||
assert host.height() >= host.minimumSizeHint().height()
|
||||
for label in host.findChildren(QLabel):
|
||||
assert label.height() >= label.fontMetrics().height()
|
||||
assert host.rect().contains(label.geometry())
|
||||
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
|
||||
for label in host.findChildren(QLabel):
|
||||
assert not label.geometry().intersects(cancel.geometry())
|
||||
# Underlying text is still available to sorting while a delegate paints only
|
||||
# the selection surface beneath the actual controls.
|
||||
assert table.item(0, 4).text()
|
||||
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
search = page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
assert search.height() == page.patient_input.height()
|
||||
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
|
||||
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
if kind == "patients":
|
||||
controls = page.patient_workspace
|
||||
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
|
||||
statuses = tuple(controls.status_buttons.values())
|
||||
assert len({widget.height() for widget in statuses}) == 1
|
||||
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
|
||||
for widget in statuses:
|
||||
assert controls.status_host.rect().contains(widget.geometry())
|
||||
assert widget.height() >= widget.fontMetrics().height()
|
||||
elif kind == "prescriptions":
|
||||
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
|
||||
elif kind == "prescription_library":
|
||||
widgets = (page.name_filter, page.query_button, page.reset_button)
|
||||
else:
|
||||
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
|
||||
assert len({widget.height() for widget in widgets}) == 1
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Contract for the shared motion system.
|
||||
|
||||
The product previously had no `QPropertyAnimation` at all, so these tests exist
|
||||
to keep the two things that make added motion a liability from creeping back:
|
||||
an animation that outlives or destroys the object it is animating, and a
|
||||
graphics effect left attached after a fade, which would quietly move a whole
|
||||
subtree onto Qt's offscreen composite path for the rest of the session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
# The module disables itself under the offscreen platform so widget grabs in the
|
||||
# other suites capture settled frames; these tests are about the animation, so
|
||||
# they opt back in.
|
||||
os.environ["DOCTOR_MOTION"] = "on"
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from PySide6.QtGui import QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui import motion
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
|
||||
host = QWidget()
|
||||
host.resize(320, 120)
|
||||
layout = QVBoxLayout(host)
|
||||
stack = QStackedWidget()
|
||||
first, second = QLabel("A"), QLabel("B")
|
||||
stack.addWidget(first)
|
||||
stack.addWidget(second)
|
||||
layout.addWidget(stack)
|
||||
host.show()
|
||||
application.processEvents()
|
||||
return host, stack, second
|
||||
|
||||
|
||||
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
animations = incoming._doctor_motion
|
||||
rise, fade = animations["enter"], animations["fade"]
|
||||
assert rise.duration() == fade.duration() == motion.BASE
|
||||
|
||||
samples = []
|
||||
for at in (0, motion.BASE // 2, motion.BASE - 1):
|
||||
rise.setCurrentTime(at)
|
||||
fade.setCurrentTime(at)
|
||||
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
|
||||
|
||||
offsets = [offset for offset, _ in samples]
|
||||
opacities = [opacity for _, opacity in samples]
|
||||
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
|
||||
assert opacities == sorted(opacities), "opacity must rise monotonically"
|
||||
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
|
||||
host.close()
|
||||
|
||||
|
||||
def test_fade_detaches_its_graphics_effect_when_it_finishes(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
motion.switch_stack(stack, 1)
|
||||
assert incoming.graphicsEffect() is not None
|
||||
|
||||
fade = incoming._doctor_motion["fade"]
|
||||
fade.setCurrentTime(fade.duration())
|
||||
# The detach is deferred by one event-loop turn on purpose, so that the
|
||||
# effect is not destroyed from inside the signal it is emitting.
|
||||
application.processEvents()
|
||||
application.processEvents()
|
||||
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||
assert motion.reduced_motion()
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
assert stack.currentIndex() == 1
|
||||
assert incoming.pos() == origin
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def _scroll_area(application: QApplication) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
|
||||
area.resize(300, 200)
|
||||
area.show()
|
||||
application.processEvents()
|
||||
return area
|
||||
|
||||
|
||||
def _wheel(delta: int) -> QWheelEvent:
|
||||
return QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, delta),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
animation = scroller._animation
|
||||
assert animation.endValue() == motion.SCROLL_STEP
|
||||
|
||||
values = []
|
||||
for at in (0, 60, 120, animation.duration() - 1):
|
||||
animation.setCurrentTime(at)
|
||||
values.append(bar.value())
|
||||
|
||||
assert values[0] == 0
|
||||
assert values == sorted(values)
|
||||
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
|
||||
animation.stop()
|
||||
area.close()
|
||||
|
||||
|
||||
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
|
||||
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
bar.setValue(bar.minimum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(120))
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
|
||||
scroller._animation.stop()
|
||||
bar.setValue(bar.maximum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
area.close()
|
||||
|
||||
|
||||
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
|
||||
ctrl_wheel = QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, -120),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.ControlModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
|
||||
area.close()
|
||||
|
||||
|
||||
def test_install_is_idempotent(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
first = area._doctor_smooth_scroll
|
||||
motion.install_smooth_scroll(area)
|
||||
|
||||
assert area._doctor_smooth_scroll is first
|
||||
area.close()
|
||||
|
||||
|
||||
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A QScrollArea should not have to opt in page by page."""
|
||||
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
apply_theme(application)
|
||||
area = QScrollArea()
|
||||
area.setWidget(QLabel("content"))
|
||||
area.show()
|
||||
area.ensurePolished()
|
||||
application.sendEvent(area, QEvent(QEvent.Type.Polish))
|
||||
application.processEvents()
|
||||
|
||||
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||
area.close()
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Order contracts at risk when filters, summary and table receive the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 901 + index,
|
||||
"order_no": f"PO2026081000{index + 1}",
|
||||
"patient_name": name,
|
||||
"patient_phone_masked": f"186****482{index}",
|
||||
"recipient_phone": f"1860000482{index}",
|
||||
"prescription_id": 801 + index,
|
||||
"diagnosis_id": 501 + index,
|
||||
"amount": 368 + index,
|
||||
"effective_amount": 368 + index,
|
||||
"prescription_audit_status": 1,
|
||||
"payment_slip_audit_status": 1,
|
||||
"fulfillment_status": 2,
|
||||
"assistant_name": "周医助",
|
||||
"doctor_name": "陈医生(演示)",
|
||||
"creator_name": "周医助",
|
||||
# Source-only values must not fill display fields that are absent.
|
||||
"create_time": "2026-09-05 10:15:00",
|
||||
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
self.total = 47
|
||||
self.summary = {
|
||||
"orders": 47,
|
||||
"amount": 12368.5,
|
||||
"pending": 8,
|
||||
"completed": 9,
|
||||
"rejected": 2,
|
||||
"rejection_rate": 4.3,
|
||||
}
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": self.total,
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门订单范围"},
|
||||
"summary": deepcopy(self.summary),
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
|
||||
"count": 1,
|
||||
"extend": {"scope": {"label": "测试患者范围"}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [],
|
||||
"count": 0,
|
||||
"extend": {"scope": {"label": "测试面诊范围"}},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
|
||||
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(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("Order visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=680, page=False):
|
||||
repository = _Repository()
|
||||
widget = (
|
||||
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
|
||||
if page
|
||||
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
|
||||
)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if not page:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
default_query = {
|
||||
"keyword": "",
|
||||
"prescription_audit_status": None,
|
||||
"payment_slip_audit_status": None,
|
||||
"fulfillment_status": None,
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
assert repository.queries[0] == default_query
|
||||
assert all(query == {**default_query, "page_no": index + 1}
|
||||
for index, query in enumerate(repository.queries))
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
|
||||
assert workspace.end_date.date() == QDate.currentDate()
|
||||
assert all(
|
||||
combo.currentData() is None
|
||||
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
|
||||
)
|
||||
|
||||
before_filters = len(repository.queries)
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
|
||||
_click(workspace.use_dates, application)
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
|
||||
workspace.start_date.setDate(start)
|
||||
workspace.end_date.setDate(end)
|
||||
_click(workspace.search_button, application)
|
||||
assert repository.queries[before_filters] == {
|
||||
**default_query,
|
||||
"keyword": "林医生",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-03",
|
||||
"end_date": "2026-08-08",
|
||||
}
|
||||
|
||||
workspace.pager.load_more()
|
||||
assert repository.queries[-1]["page_no"] == 2
|
||||
assert repository.queries[-1]["page_size"] == 15
|
||||
workspace.keyword_edit.setText("新检索")
|
||||
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
|
||||
_settle(application)
|
||||
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
|
||||
workspace.start_date.setDate(end.addDays(1))
|
||||
before = len(repository.queries)
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == end.addDays(1)
|
||||
assert workspace.end_date.date() == end
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
row = repository.rows[0]
|
||||
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
|
||||
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
|
||||
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
table = workspace.table
|
||||
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
|
||||
"订单",
|
||||
"患者",
|
||||
"处方 / 诊单",
|
||||
"有效金额",
|
||||
"处方审核",
|
||||
"支付审核",
|
||||
"履约",
|
||||
"支付单",
|
||||
"归属助理",
|
||||
"开方人",
|
||||
"创建人",
|
||||
"创建时间",
|
||||
]
|
||||
index = next(
|
||||
index
|
||||
for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
|
||||
)
|
||||
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
|
||||
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
|
||||
assert table.item(index, 2).text() == "#801 / #501"
|
||||
assert table.item(index, 9).text() == row["doctor_name"]
|
||||
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
|
||||
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
|
||||
assert table.item(index, 9).toolTip() == row["doctor_name"]
|
||||
assert table.item(index, 7).text() == "—"
|
||||
assert table.item(index, 11).text() == "—"
|
||||
for column in range(12):
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
|
||||
assert row["recipient_phone"] not in table.item(index, column).text()
|
||||
|
||||
|
||||
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
|
||||
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
|
||||
table.sortItems(0, Qt.SortOrder.AscendingOrder)
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
|
||||
for index in (0, 2, 1):
|
||||
table.selectRow(index)
|
||||
_settle(application)
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
workspace.diagnosis_button.click()
|
||||
workspace.detail_button.click()
|
||||
next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
|
||||
).trigger()
|
||||
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
|
||||
for _kind, row in emitted[-3:]:
|
||||
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
|
||||
expected["id"],
|
||||
expected["prescription_id"],
|
||||
expected["diagnosis_id"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "menu", "detail"),
|
||||
[
|
||||
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
|
||||
(("tcm.prescriptionOrder/detail",), [], True),
|
||||
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
|
||||
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
|
||||
((), [], False),
|
||||
],
|
||||
)
|
||||
def test_selected_order_menu_requires_canonical_permissions(
|
||||
application: QApplication, workspace_factory, permissions, menu, detail
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
assert [action.text() for action in workspace.action_menu.actions()] == menu
|
||||
assert workspace.action_button.isVisible() is bool(menu)
|
||||
assert workspace.detail_button.isVisible() is detail
|
||||
assert workspace.diagnosis_button.isEnabled()
|
||||
emitted = []
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
|
||||
workspace._request_detail() # The double-click path also checks permission.
|
||||
assert bool(emitted) is detail
|
||||
workspace.permissions = PermissionSet([])
|
||||
workspace._selection_changed()
|
||||
_settle(application)
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert not workspace.action_button.isVisible()
|
||||
assert not workspace.detail_button.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
|
||||
[
|
||||
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
|
||||
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
|
||||
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
|
||||
(1, 1, 6, "已通过", "已通过", "已签收", ""),
|
||||
],
|
||||
)
|
||||
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
|
||||
application: QApplication,
|
||||
workspace_factory,
|
||||
rx,
|
||||
pay,
|
||||
fulfillment,
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
exclusion,
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.rows = [
|
||||
{
|
||||
**repository.rows[0],
|
||||
"prescription_audit_status": rx,
|
||||
"payment_slip_audit_status": pay,
|
||||
"fulfillment_status": fulfillment,
|
||||
"amount_included": not bool(exclusion),
|
||||
"amount_exclusion_text": exclusion,
|
||||
"effective_amount": 12368.5,
|
||||
}
|
||||
]
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
|
||||
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
]
|
||||
if fulfillment == 6:
|
||||
refund = next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "退款"
|
||||
)
|
||||
assert refund.property("danger") is True
|
||||
|
||||
|
||||
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "47",
|
||||
"amount": "¥12,368.50",
|
||||
"pending": "8",
|
||||
"completed": "9",
|
||||
"rejected": "2",
|
||||
"rejection_rate": "4.3%",
|
||||
}
|
||||
assert workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert workspace.pager.total == 47
|
||||
assert workspace.pager.page_size == 15
|
||||
assert workspace.pager.height() == 24
|
||||
# Preserve the existing ratio compatibility, including its known ambiguity.
|
||||
repository.summary = {
|
||||
"order_count": 20,
|
||||
"effective_amount": 700.25,
|
||||
"pending_audit": 3,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"rejection_rate": 0.05,
|
||||
}
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "20",
|
||||
"amount": "¥700.25",
|
||||
"pending": "3",
|
||||
"completed": "4",
|
||||
"rejected": "1",
|
||||
"rejection_rate": "5.0%",
|
||||
}
|
||||
before = len(repository.queries)
|
||||
for metric in workspace.metrics.values():
|
||||
_click(metric, application)
|
||||
assert len(repository.queries) == before # The six metrics are read-only.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(
|
||||
patients_module,
|
||||
"run_async",
|
||||
lambda function, **callbacks: queued.append((function, callbacks)),
|
||||
)
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.start_date.setDate(QDate(2026, 7, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 8, 1))
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("尚未提交")
|
||||
workspace.rx_audit.setCurrentIndex(0)
|
||||
workspace.use_dates.setChecked(False)
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "最新请求",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-01",
|
||||
"end_date": "2026-08-01",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 0)
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.metrics["orders"].text() == "47"
|
||||
assert workspace.banner.isVisible()
|
||||
assert "订单查询失败" in _banner_text(workspace)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"]({"lists": [], "count": 0})
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert "当前范围内暂无订单" in " ".join(
|
||||
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
|
||||
)
|
||||
assert not workspace.diagnosis_button.isEnabled()
|
||||
assert not workspace.detail_button.isEnabled()
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert workspace.pager.total == 0
|
||||
assert workspace.metrics["orders"].text() == "0"
|
||||
assert workspace.metrics["amount"].text() == "¥0.00"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
|
||||
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
|
||||
application: QApplication, workspace_factory, width: int, height: int
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(width=width, height=height)
|
||||
table = workspace.table
|
||||
assert workspace.size().width() == width
|
||||
assert workspace.size().height() == height
|
||||
assert table.columnCount() == 12
|
||||
assert table.horizontalHeader().height() == 46
|
||||
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
|
||||
assert table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.rx_audit,
|
||||
workspace.pay_audit,
|
||||
workspace.fulfillment,
|
||||
workspace.search_button,
|
||||
workspace.reset_button,
|
||||
workspace.use_dates,
|
||||
workspace.start_date,
|
||||
workspace.end_date,
|
||||
):
|
||||
assert widget.isVisibleTo(workspace)
|
||||
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
|
||||
for metric in workspace.metrics.values():
|
||||
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
|
||||
if width == 1270:
|
||||
assert workspace.filter_card.height() == 132
|
||||
assert workspace.summary_strip.height() == 96
|
||||
if width == 760:
|
||||
assert workspace.filter_card.height() == 184
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
metric.mapTo(workspace.summary_strip, QPoint()).y()
|
||||
for metric in workspace.metrics.values()
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
for host in (
|
||||
workspace.filter_card,
|
||||
workspace.summary_strip,
|
||||
workspace.action_bar,
|
||||
workspace.pager,
|
||||
):
|
||||
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||
_settle(application)
|
||||
assert (
|
||||
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
|
||||
), host.objectName()
|
||||
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
|
||||
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
|
||||
assert workspace.pager.height() == 24
|
||||
for widget in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
table.scrollToBottom()
|
||||
_settle(application)
|
||||
assert table.columnViewportPosition(11) >= 0
|
||||
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
|
||||
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
|
||||
|
||||
|
||||
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
page, _repository = workspace_factory(width=1328, height=884, page=True)
|
||||
patient = page.patient_workspace
|
||||
progress = page.progress_workspace
|
||||
patient_style = patient.styleSheet()
|
||||
assert patient.table.objectName() == "PatientTable"
|
||||
assert patient.table.columnCount() == 10
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
assert patient.table.font().pixelSize() == 14
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
progress_style = progress.styleSheet()
|
||||
progress_table_font = progress.schedule_table.font()
|
||||
progress_metric_font = progress.overview["total"][0].font()
|
||||
assert progress.timer.interval() == 15_000
|
||||
assert progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.scope_label.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress_table_font.pixelSize() == 14
|
||||
assert progress_metric_font.pixelSize() == 18
|
||||
assert progress.schedule_table.rowCount() == 7
|
||||
assert progress.schedule_table.columnCount() == 7
|
||||
|
||||
page.tabs.setCurrentIndex(1)
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
assert page.order_workspace.scope_label.isVisible()
|
||||
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert patient.styleSheet() == patient_style
|
||||
assert progress.styleSheet() == progress_style
|
||||
assert progress.schedule_table.font() == progress_table_font
|
||||
assert progress.overview["total"][0].font() == progress_metric_font
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
|
||||
page.tabs.setCurrentIndex(0)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress.timer.isActive()
|
||||
page.hide()
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
page.show()
|
||||
_settle(application)
|
||||
assert progress.timer.isActive()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Progress data/display contracts and native reachability after the blue redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.result = {
|
||||
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
|
||||
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
|
||||
"appointment_time": "09:00-09:30"}],
|
||||
"count": 37,
|
||||
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
|
||||
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(deepcopy(query))
|
||||
return deepcopy(self.result)
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_orders(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _texts(table) -> list[list[str]]:
|
||||
return [[table.item(row, column).text() for column in range(table.columnCount())]
|
||||
for row in range(table.rowCount())]
|
||||
|
||||
|
||||
def _metrics(workspace) -> dict[str, tuple[str, str]]:
|
||||
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function, *, 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, **_kwargs):
|
||||
pytest.fail("Progress regression tests must use local fixtures or Demo only")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
|
||||
repository=None, refresh=True):
|
||||
repository = repository if repository is not None else _Repository()
|
||||
permission_set = PermissionSet(list(permissions))
|
||||
if shell:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
# A real shell with only the tested navigation route, not unrelated workspaces.
|
||||
widget = ShellWindow(repository, {
|
||||
"user": session.user, "permissions": permission_set,
|
||||
"menu": [{"perms": "firstvisit.myPatient/lists"}],
|
||||
}, permissions=permission_set)
|
||||
elif page:
|
||||
widget = PatientsPage(repository, permissions=permission_set)
|
||||
else:
|
||||
widget = PatientProgressWorkspace(repository)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if shell:
|
||||
widget.navigate("patients")
|
||||
widget.pages["patients"].tabs.setCurrentIndex(2)
|
||||
elif page:
|
||||
widget.tabs.setCurrentIndex(2)
|
||||
elif refresh:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extend", "total", "expected_mode", "expected"),
|
||||
[
|
||||
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
|
||||
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
|
||||
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
|
||||
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
|
||||
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
|
||||
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
|
||||
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
|
||||
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
|
||||
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
|
||||
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
],
|
||||
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
|
||||
)
|
||||
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
|
||||
workspace_factory, extend, total, expected_mode, expected
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(count=total, extend=extend)
|
||||
workspace.refresh()
|
||||
assert _metrics(workspace) == expected
|
||||
assert workspace.mode_label.text() == expected_mode
|
||||
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
|
||||
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
|
||||
assert workspace.queue_count.text() == f"共 {total} 人 · 每 15 秒刷新"
|
||||
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
|
||||
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
|
||||
workspace_factory, monkeypatch, week_schedule
|
||||
) -> None:
|
||||
# Sunday fixes the distinction between rolling seven days and a calendar week.
|
||||
class Sunday:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate(2026, 9, 6)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Sunday)
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = week_schedule
|
||||
workspace.refresh()
|
||||
rows = _texts(workspace.schedule_table)
|
||||
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
|
||||
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
|
||||
|
||||
|
||||
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = [
|
||||
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
|
||||
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
|
||||
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
|
||||
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
|
||||
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
|
||||
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
|
||||
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
|
||||
]
|
||||
workspace.refresh()
|
||||
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
|
||||
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
|
||||
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
|
||||
assert rows["09-07 周一"][6] == "—"
|
||||
|
||||
|
||||
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
workspace, _ = workspace_factory(repository=repository)
|
||||
assert _texts(workspace.queue_table) == [["—", "林晓岚", "陈医生(演示)", "09:00", "0 位", "—"]]
|
||||
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
|
||||
assert workspace.scope == "演示医生本人患者"
|
||||
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
|
||||
assert not workspace.pager.has_more
|
||||
table = workspace.schedule_table
|
||||
table.scrollToItem(table.item(0, 6))
|
||||
_settle(application)
|
||||
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
|
||||
image = table.viewport().grab(rect).toImage()
|
||||
assert not image.isNull()
|
||||
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
|
||||
assert all(image.pixelColor(x, y).lightness() > 160
|
||||
for x in range(image.width()) for y in range(image.height()))
|
||||
dash_table = workspace.queue_table
|
||||
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
|
||||
assert any(dash_image.pixelColor(x, y).lightness() < 100
|
||||
for x in range(dash_image.width()) for y in range(dash_image.height()))
|
||||
|
||||
|
||||
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
examples = [
|
||||
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
|
||||
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
|
||||
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
|
||||
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", "—"),
|
||||
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
|
||||
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
|
||||
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", "—"),
|
||||
]
|
||||
base = repository.result["lists"][0]
|
||||
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
|
||||
for index, (fields, _, _) in enumerate(examples)]
|
||||
workspace.refresh()
|
||||
table = workspace.queue_table
|
||||
for row_index in range(table.rowCount()):
|
||||
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
fields, expected_wait, expected_status = examples[row["id"] - 100]
|
||||
assert table.item(row_index, 0).text() == str(row["queue_no"])
|
||||
assert table.item(row_index, 4).text() == expected_wait
|
||||
assert table.item(row_index, 5).text() == expected_status
|
||||
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
|
||||
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
|
||||
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
|
||||
assert table.isSortingEnabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "expected"),
|
||||
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
|
||||
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
|
||||
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
|
||||
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
|
||||
)
|
||||
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
|
||||
application, workspace_factory, monkeypatch, permissions, expected
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
|
||||
workspace = page.progress_workspace
|
||||
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
|
||||
diagnosis_id=501 + index, patient_id=301 + index,
|
||||
patient_name=name, queue_no=index + 1)
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))]
|
||||
workspace.refresh()
|
||||
calls, toasts = [], []
|
||||
dialog = SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
|
||||
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
table = workspace.queue_table
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(0)
|
||||
table.scrollToItem(table.item(0, 1))
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
|
||||
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
|
||||
point = table.visualItemRect(table.item(0, 1)).center()
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
_settle(application)
|
||||
if expected == "denied":
|
||||
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
|
||||
else:
|
||||
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
|
||||
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
|
||||
assert not toasts
|
||||
|
||||
|
||||
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
|
||||
workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
|
||||
workspace = page.progress_workspace
|
||||
calls, toasts = [], []
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(resource)))
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
for row, expected in [({"id": 810, "patient_id": 910}, 810),
|
||||
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
|
||||
({"patient_id": 912}, None)]:
|
||||
repository.result["lists"] = [row]
|
||||
workspace.refresh()
|
||||
workspace.queue_table.selectRow(0)
|
||||
before = len(calls)
|
||||
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
|
||||
assert calls[before:] == ([] if expected is None else [expected])
|
||||
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
|
||||
|
||||
|
||||
def test_query_captures_today_status_and_page_before_worker_runs(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
workspace.refresh(silent=True)
|
||||
queued.pop()[1]["on_success"](deepcopy(repository.result))
|
||||
workspace.pager.load_more()
|
||||
workspace.pager.load_more()
|
||||
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
|
||||
assert not workspace.banner.isVisible()
|
||||
# Later GUI changes must not reach the pending worker's immutable query.
|
||||
workspace._page = 9
|
||||
queued[0][0]()
|
||||
assert repository.queries[-1:] == [
|
||||
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
|
||||
]
|
||||
assert "page" not in repository.queries[-1]
|
||||
assert workspace.pager.page_size == 15
|
||||
|
||||
|
||||
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
|
||||
workspace.refresh()
|
||||
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
|
||||
tomorrow = patients_module.QDate.currentDate().addDays(1)
|
||||
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
|
||||
workspace.refresh()
|
||||
newer = deepcopy(repository.result)
|
||||
newer["lists"][0].update(id=202, patient_name="最新结果")
|
||||
newer["count"] = 22
|
||||
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
|
||||
pending[1]["on_success"](newer)
|
||||
_settle(application)
|
||||
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
pending[0]["on_success"]({"lists": [], "count": 0})
|
||||
pending[0]["on_error"](RuntimeError("obsolete failure"))
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
workspace.refresh(silent=True)
|
||||
assert not workspace.banner.isVisible()
|
||||
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
|
||||
_settle(application)
|
||||
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
|
||||
assert "连接失败" in workspace.banner.label.text()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
|
||||
|
||||
def test_empty_list_keeps_total_week_and_compact_status_visible(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(lists=[], count=37)
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.queue_stack.currentIndex() == 1
|
||||
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
|
||||
assert workspace.pager.height() == 24
|
||||
assert workspace.schedule_table.rowCount() == 7
|
||||
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
|
||||
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
|
||||
assert "今日暂无候诊患者" in labels
|
||||
assert "当前权限范围内没有有效挂号。" in labels
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
|
||||
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
|
||||
application, workspace_factory, width, height
|
||||
) -> None:
|
||||
shell, _ = workspace_factory(shell=True, width=width, height=height)
|
||||
page = shell.pages["patients"]
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
workspace = page.progress_workspace
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
|
||||
schedule, queue = workspace.schedule_table, workspace.queue_table
|
||||
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
|
||||
if width == 1536:
|
||||
assert schedule.verticalScrollBar().value() == 0
|
||||
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
|
||||
for table in (schedule, queue):
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
|
||||
for column in range(table.columnCount()):
|
||||
item = table.item(table.rowCount() - 1, column)
|
||||
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
_settle(application)
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
|
||||
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
|
||||
assert workspace.pager.height() == 24
|
||||
for child in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
|
||||
if width == 1024:
|
||||
assert schedule.horizontalScrollBar().maximum() > 0
|
||||
assert queue.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
|
||||
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, _ = workspace_factory()
|
||||
splitter = workspace.splitter
|
||||
before = splitter.sizes()
|
||||
handle = splitter.handle(1)
|
||||
point = handle.rect().center()
|
||||
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseMove(handle, point + QPoint(0, -60))
|
||||
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] < before[0]
|
||||
assert not splitter.childrenCollapsible()
|
||||
for sizes in ([0, 1000], [1000, 0]):
|
||||
splitter.setSizes(sizes)
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
|
||||
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
|
||||
|
||||
|
||||
def test_typography_and_styles_are_local_to_the_progress_workspace(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
app_style = application.styleSheet()
|
||||
unrelated = QLabel("外部标签")
|
||||
unrelated.ensurePolished()
|
||||
original_font = unrelated.font()
|
||||
workspace, _ = workspace_factory()
|
||||
assert workspace.schedule_table.font().pixelSize() == 14
|
||||
assert workspace.queue_table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert workspace.mode_label.font().pixelSize() == 13
|
||||
assert workspace.queue_count.font().pixelSize() == 13
|
||||
for value, hint in workspace.overview.values():
|
||||
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
|
||||
for label in workspace.findChildren(QLabel):
|
||||
if label.property("role") == "sectionTitle":
|
||||
assert label.font().pixelSize() == 14
|
||||
assert application.styleSheet() == app_style
|
||||
assert "ProgressWorkspace" not in app_style
|
||||
assert unrelated.font() == original_font and not unrelated.styleSheet()
|
||||
unrelated.deleteLater()
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Patient workspaces keep resource identity while appending server pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import (
|
||||
PatientListWorkspace,
|
||||
PatientOrdersWorkspace,
|
||||
PatientProgressWorkspace,
|
||||
PatientsPage,
|
||||
)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.failed_pages: set[int] = set()
|
||||
self.version = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page in self.failed_pages:
|
||||
raise RuntimeError("连接失败,请重试。")
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": index + 100,
|
||||
"diagnosis_id": index + 1000,
|
||||
"patient_id": index + 2000,
|
||||
"appointment_id": index + 3000,
|
||||
"order_id": index + 4000,
|
||||
"patient_name": f"患者 {index:02d} · {query.get('keyword', '')}{self.version}",
|
||||
"appointment_status": 1,
|
||||
"prescription_audit_status": 0,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 2,
|
||||
"queue_no": index + 1,
|
||||
"queue_status": "waiting",
|
||||
"queue_status_text": "等待中",
|
||||
}
|
||||
for index in range(start, min(start + query["page_size"], 37))
|
||||
]
|
||||
return {
|
||||
"lists": rows,
|
||||
"count": 37,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"},
|
||||
"summary": {"orders": 37, "amount": 3700, "today": 37, "waiting": 37},
|
||||
"schedule_mode": "ownership",
|
||||
},
|
||||
}
|
||||
|
||||
list_patients = _list
|
||||
patient_orders = _list
|
||||
patient_progress = _list
|
||||
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
if callbacks.get("on_finished"):
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["patients", "orders", "progress"])
|
||||
def workspace(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
repository = Repository()
|
||||
permissions = PermissionSet(["*"])
|
||||
constructors = {
|
||||
"patients": lambda: PatientListWorkspace(repository, permissions),
|
||||
"orders": lambda: PatientOrdersWorkspace(repository, permissions),
|
||||
"progress": lambda: PatientProgressWorkspace(repository),
|
||||
}
|
||||
widget = constructors[request.param]()
|
||||
widget.resize(1280, 900)
|
||||
widget.show()
|
||||
application.processEvents()
|
||||
yield request.param, widget, repository
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def table_for(workspace: Any) -> Any:
|
||||
return getattr(workspace, "table", None) or workspace.queue_table
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
application.processEvents()
|
||||
QTest.qWait(60)
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def scroll_down(workspace: Any, application: QApplication) -> None:
|
||||
table = table_for(workspace)
|
||||
table.verticalScrollBar().setValue(table.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_scrolling_appends_without_duplicates_or_selection_loss(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.height() == 24
|
||||
assert not hasattr(widget.pager, "page_changed")
|
||||
assert not any(button.text().isdigit() for button in widget.pager.findChildren(QPushButton))
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(5)
|
||||
chosen = table.current_data()["id"]
|
||||
if kind == "patients":
|
||||
table.cellWidget(5, 0).findChild(QCheckBox).setChecked(True)
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen
|
||||
if kind == "patients":
|
||||
selected_row = next(index for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == chosen)
|
||||
assert table.cellWidget(selected_row, 0).findChild(QCheckBox).isChecked()
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 37
|
||||
assert len({table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
for index in range(table.rowCount())}) == 37
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 3]
|
||||
assert all(query["page_size"] == 15 and "page" not in query for query in repository.calls)
|
||||
assert widget.pager.page == widget._page == 3
|
||||
assert widget.pager.total == 37
|
||||
assert not widget.pager.has_more
|
||||
assert widget.scope == "本人患者"
|
||||
scroll_down(widget, application)
|
||||
assert len(repository.calls) == 3
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_scroll_and_distinct_action_ids(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
table.selectRow(20)
|
||||
chosen = dict(table.current_data())
|
||||
scroll = table.verticalScrollBar().value()
|
||||
repository.version = 1
|
||||
widget.refresh(silent=True)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 1, 2]
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen["id"]
|
||||
assert table.current_data()["patient_name"].endswith("1")
|
||||
assert table.verticalScrollBar().value() == scroll
|
||||
selected = []
|
||||
if kind == "patients":
|
||||
widget.diagnosis_requested.connect(lambda row, _edit: selected.append(row))
|
||||
widget._open_selected_diagnosis()
|
||||
elif kind == "orders":
|
||||
widget.detail_requested.connect(selected.append)
|
||||
widget._request_detail()
|
||||
else:
|
||||
widget.diagnosis_requested.connect(selected.append)
|
||||
widget._open_selected()
|
||||
assert selected[0]["id"] == chosen["id"]
|
||||
assert selected[0]["diagnosis_id"] == chosen["diagnosis_id"]
|
||||
assert selected[0]["diagnosis_id"] != selected[0]["patient_id"]
|
||||
|
||||
|
||||
def test_failed_append_keeps_rows_and_retry_continues_same_page(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(2)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
settle(application)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2]
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table.rowCount() == 30
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 2]
|
||||
assert not widget.pager.retry_button.isVisible()
|
||||
|
||||
|
||||
def test_refresh_preserves_horizontal_scroll_at_narrow_width(workspace, application):
|
||||
_kind, widget, _repository = workspace
|
||||
widget.resize(760, 600)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
horizontal = table.horizontalScrollBar()
|
||||
assert horizontal.maximum() > 0
|
||||
horizontal.setValue(horizontal.maximum())
|
||||
offset = horizontal.value()
|
||||
widget.refresh(silent=True)
|
||||
assert horizontal.value() == offset
|
||||
|
||||
|
||||
def test_first_page_failure_keeps_compact_retry_accessible(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(1)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
widget.scroll.ensureWidgetVisible(widget.pager)
|
||||
settle(application)
|
||||
assert table_for(widget).rowCount() == 0
|
||||
assert widget.pager.isVisible()
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
assert widget.pager.retry_button.height() <= widget.pager.height()
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table_for(widget).rowCount() == 15
|
||||
|
||||
|
||||
def test_filter_change_restarts_and_ignores_old_append(workspace, application, monkeypatch):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)))
|
||||
# Capture the new runner for the next scroll request without changing query.
|
||||
widget.refresh(silent=True)
|
||||
pending.pop()[1]["on_success"](repository._list(page_no=1, page_size=15))
|
||||
scroll_down(widget, application)
|
||||
assert len(pending) == 1
|
||||
if kind == "progress":
|
||||
class Tomorrow:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate.currentDate().addDays(1)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Tomorrow)
|
||||
else:
|
||||
widget.keyword_edit.setText("新条件")
|
||||
widget.refresh()
|
||||
assert len(pending) == 2
|
||||
stale_function, stale_callbacks = pending[0]
|
||||
current_function, current_callbacks = pending[1]
|
||||
current_callbacks["on_success"](current_function())
|
||||
stale_callbacks["on_success"](stale_function())
|
||||
stale_callbacks["on_error"](RuntimeError("旧请求错误"))
|
||||
assert table_for(widget).rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert not widget.banner.isVisible()
|
||||
latest = repository.calls[-2]
|
||||
assert latest["page_no"] == 1
|
||||
if kind == "progress":
|
||||
assert latest["start_date"] == QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
else:
|
||||
assert latest["keyword"] == "新条件"
|
||||
|
||||
|
||||
def test_patient_route_uses_eight_pixel_bottom_margin(application, monkeypatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
page = PatientsPage(Repository(), PermissionSet(["*"]))
|
||||
assert page.layout().contentsMargins().bottom() == 8
|
||||
assert page.order_workspace.action_bar.height() == 54
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Interaction contracts at risk when the patient list moves to the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 601 + index,
|
||||
"diagnosis_id": 601 + index,
|
||||
"source_patient_id": 301 + index,
|
||||
"appointment_id": 401 + index if index != 2 else 0,
|
||||
"patient_name": name,
|
||||
"gender": 2,
|
||||
"age": 36,
|
||||
"assistant_name": "测试医助",
|
||||
"assistant_id": 81 if index != 2 else 0,
|
||||
"appointment_doctor_name": "测试医生" if index != 2 else "",
|
||||
"appointment_status": (1, 4, 0)[index],
|
||||
"appointment_status_text": "未预约" if index == 2 else "待接诊",
|
||||
"appointment_time_text": "2026-09-05 09:00" if index != 2 else "—",
|
||||
"has_id_card": index != 1,
|
||||
"revisit_count": index,
|
||||
"confirmation_text": "待确认",
|
||||
"diagnosis_date_text": "初诊",
|
||||
"phone_masked": f"138****120{index}",
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": len(self.rows),
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门可见患者"},
|
||||
"summary": {"today": 2, "tomorrow": 1, "day_after": 0},
|
||||
"dates": {"today": QDate.currentDate().toString("yyyy-MM-dd")},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(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("Patient visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=700):
|
||||
repository = _Repository()
|
||||
workspace = PatientListWorkspace(repository, PermissionSet(list(permissions)))
|
||||
opened.append(workspace)
|
||||
workspace.resize(width, height)
|
||||
workspace.show()
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
return workspace, repository
|
||||
|
||||
yield create
|
||||
for workspace in opened:
|
||||
for timer in workspace.findChildren(QTimer):
|
||||
timer.stop()
|
||||
workspace.close()
|
||||
workspace.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _date_selection(workspace: PatientListWorkspace) -> list[str]:
|
||||
return [key for key, button in workspace.quick_buttons.items() if button.isChecked()] + (
|
||||
["custom"] if workspace.custom_date_button.isChecked() else []
|
||||
)
|
||||
|
||||
|
||||
def test_filters_preserve_independent_dimensions_and_reset_to_unlimited_dates(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert repository.queries == [{
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled()
|
||||
assert not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == workspace.end_date.date() == QDate.currentDate()
|
||||
assert workspace.scope_label.text() == "测试部门可见患者"
|
||||
assert workspace.summary_buttons["today"].text() == f"今日预约 · {QDate.currentDate().toString('yyyy-MM-dd')}\n2 人"
|
||||
assert workspace.summary_buttons["tomorrow"].text() == "明日预约\n1 人"
|
||||
assert workspace.summary_buttons["day_after"].text() == "后天预约\n0 人"
|
||||
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.pager.load_more()
|
||||
_click(workspace.status_buttons["pending_interview"], application)
|
||||
assert repository.queries[-1]["page_no"] == 1
|
||||
for mode, offset in (("today", 0), ("tomorrow", 1), ("day_after", 2)):
|
||||
_click(workspace.summary_buttons[mode], application)
|
||||
expected = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "林医生", "status": "pending_interview", "start_date": expected,
|
||||
"end_date": expected, "page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert _date_selection(workspace) == [mode]
|
||||
assert workspace.status_buttons["pending_interview"].isChecked()
|
||||
assert workspace.summary_buttons["today"].text().endswith("2 人")
|
||||
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
# Clicking the selected custom mode keeps a visible selection.
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(-4))
|
||||
workspace.end_date.setDate(QDate.currentDate().addDays(-2))
|
||||
workspace.end_date.editingFinished.emit()
|
||||
assert repository.queries[-1]["start_date"] == QDate.currentDate().addDays(-4).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1]["end_date"] == QDate.currentDate().addDays(-2).toString("yyyy-MM-dd")
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(3))
|
||||
before = len(repository.queries)
|
||||
workspace.start_date.editingFinished.emit()
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert [key for key, button in workspace.status_buttons.items() if button.isChecked()] == [""]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("permissions", "expected", "editable"), [
|
||||
(("*",), ["诊单", "AI 分析"], True),
|
||||
(("tcm.diagnosis/readonlyDetail",), ["查看"], False),
|
||||
((), [], None),
|
||||
])
|
||||
def test_row_actions_obey_edit_readonly_and_no_permission(
|
||||
application: QApplication, workspace_factory, permissions, expected, editable
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
opened = []
|
||||
workspace.diagnosis_requested.connect(lambda row, can_edit: opened.append((row["diagnosis_id"], can_edit)))
|
||||
for index in range(workspace.table.rowCount()):
|
||||
row = workspace.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = workspace.table.cellWidget(index, 9)
|
||||
assert actions.visible_labels == expected
|
||||
if permissions == ("*",):
|
||||
overflow = ["预约", "重新指派" if row["assistant_id"] else "指派医助"]
|
||||
if not row["has_id_card"]:
|
||||
overflow.append("补全身份证")
|
||||
overflow.append("关联订单")
|
||||
if row["appointment_id"] and row["appointment_status"] in (1, 4):
|
||||
overflow.append("取消")
|
||||
assert actions.overflow_labels == overflow
|
||||
assert all(entry.property("danger") is True for entry in actions.menu.actions() if entry.text() == "取消")
|
||||
else:
|
||||
assert actions.more_button is None
|
||||
assert actions.overflow_labels == []
|
||||
if editable is not None:
|
||||
_click(actions.buttons[0], application)
|
||||
assert opened[-1] == (row["diagnosis_id"], editable)
|
||||
if editable is None:
|
||||
workspace.table.selectRow(0)
|
||||
workspace._open_selected_diagnosis()
|
||||
assert opened == []
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
|
||||
|
||||
def _selector(workspace: PatientListWorkspace, row: int) -> QCheckBox:
|
||||
return workspace.table.cellWidget(row, 0).findChild(QCheckBox)
|
||||
|
||||
|
||||
def _assert_single_selection(workspace: PatientListWorkspace) -> None:
|
||||
checked = [row for row in range(workspace.table.rowCount()) if _selector(workspace, row).isChecked()]
|
||||
assert len(checked) <= 1
|
||||
if checked:
|
||||
assert checked == [workspace.table.currentRow()]
|
||||
|
||||
|
||||
def test_sorted_selectors_and_row_actions_keep_distinct_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"]
|
||||
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"] != first_id
|
||||
for index in (0, 1):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
_click(_selector(workspace, index), application)
|
||||
assert table.current_data()["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert _selector(workspace, index).isChecked()
|
||||
_assert_single_selection(workspace)
|
||||
|
||||
# Leave a different row current: every callback must use its own sorted row.
|
||||
table.selectRow(2)
|
||||
_settle(application)
|
||||
_assert_single_selection(workspace)
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row, editable: emitted.append(("diagnosis", row, editable)))
|
||||
workspace.ai_consult_requested.connect(lambda row: emitted.append(("ai", row, None)))
|
||||
workspace.appointment_requested.connect(lambda row: emitted.append(("appointment", row, None)))
|
||||
for index in range(table.rowCount()):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = table.cellWidget(index, 9)
|
||||
actions.buttons[0].click()
|
||||
actions.buttons[1].click()
|
||||
next(action for action in actions.menu.actions() if action.text() == "预约").trigger()
|
||||
for kind, row, editable in emitted[-3:]:
|
||||
assert row["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert row["source_patient_id"] == expected["source_patient_id"]
|
||||
assert row["appointment_id"] == expected["appointment_id"]
|
||||
if kind == "diagnosis":
|
||||
assert editable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_resets_changed_query_and_ignores_stale_success_and_error(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
# Workers must keep the UI-thread snapshot even after text changes again.
|
||||
workspace.keyword_edit.setText("尚未查询")
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1]["keyword"] == "最新请求"
|
||||
newer["lists"][0]["patient_name"] = "最新结果"
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 1)
|
||||
assert current_item.text().startswith("最新结果")
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("患者查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.banner.isVisible()
|
||||
assert "患者查询失败" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"](queued[-1][0]())
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Library display units and query/identity contracts after the approved redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import prescription_library as library
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class QueryRepository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.rows = [
|
||||
{"id": 41, "prescription_name": "乙方", "formula_type": "aux", "is_public": False,
|
||||
"creator_id": 7, "creator_name": "本页医生", "efficacy": "益气养阴",
|
||||
"herbs": [{"name": "白芍", "dosage": "15g"}], "create_time": "2026-07-15 10:30"},
|
||||
{"id": 42, "prescription_name": "甲方", "formula_type": "main", "is_public": True,
|
||||
"creator_id": 8, "creator_name": "其他医生", "efficacy": "清热祛湿",
|
||||
"herbs": [{"name": "茯苓", "dosage": 10}], "create_time": "2026-07-18 16:20"},
|
||||
]
|
||||
|
||||
def list_prescription_templates(self, **query):
|
||||
self.calls.append(deepcopy(query))
|
||||
# Server total deliberately differs from the current-page/effect-filter count.
|
||||
return {"lists": deepcopy(self.rows), "count": 44}
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def record_id(record):
|
||||
return record["id"] if isinstance(record, dict) else record.id
|
||||
|
||||
|
||||
def row_record(table, index):
|
||||
return table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
|
||||
def row_buttons(table, index):
|
||||
return {button.accessibleName(): button for button in table.cellWidget(index, 9).findChildren(QPushButton)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_execution(monkeypatch):
|
||||
def reject(*_args, **_kwargs):
|
||||
pytest.fail("Library regression tests must not contact external services")
|
||||
|
||||
def immediate(function, *, 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()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject)
|
||||
monkeypatch.setattr(socket, "create_connection", reject)
|
||||
monkeypatch.setattr(library, "run_async", immediate)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application):
|
||||
opened = []
|
||||
|
||||
def create(repository=None, *, permissions=None, user=None):
|
||||
repository = repository or QueryRepository()
|
||||
page = library.PrescriptionLibraryPage(
|
||||
repository, {"*"} if permissions is None else permissions,
|
||||
user or SimpleNamespace(id=7, root=0, role_ids=[]),
|
||||
)
|
||||
opened.append(page)
|
||||
page.resize(1328, 884)
|
||||
page.show()
|
||||
settle(application)
|
||||
assert page.table.rowCount() == 2
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dose, expected", [(10, "10g"), ("10", "10g"), ("10g", "10g"), ("15G", "15G"), (0, "0g")])
|
||||
def test_display_unit_is_added_once_without_mutating_dose(dose, expected):
|
||||
row = {"herbs": [{"medicine_id": 11, "name": "白芍", "dosage": dose}]}
|
||||
before = deepcopy(row)
|
||||
assert library._herbs_detail(None, row) == f"白芍 {expected}"
|
||||
assert row == before
|
||||
assert library._herbs_detail(None, {"herbs": [{"medicine_name": "茯苓", "amount": dose}]}) == f"茯苓 {expected}"
|
||||
|
||||
|
||||
def test_demo_raw_doses_and_default_unselected_state_are_preserved(page_factory, monkeypatch):
|
||||
monkeypatch.setattr(library, "datetime", SimpleNamespace(now=lambda: datetime(2026, 9, 5)))
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
originals = {identifier: repository.get_prescription_template(identifier) for identifier in (701, 702)}
|
||||
snapshots = {identifier: deepcopy((row.herbs, row.raw)) for identifier, row in originals.items()}
|
||||
assert library._herbs_detail(None, originals[701]) == "柴胡 10g、白芍 15g、茯苓 15g"
|
||||
assert library._herbs_detail(None, originals[702]) == "酸枣仁 20g、夜交藤 30g"
|
||||
assert {identifier: (row.herbs, row.raw) for identifier, row in originals.items()} == snapshots
|
||||
page, _ = page_factory(repository, permissions=session.permissions, user=session.user)
|
||||
assert page.table.columnCount() == 10
|
||||
assert [page.table.horizontalHeader().logicalIndex(index) for index in range(10)] == list(range(10))
|
||||
assert page.table.currentRow() == -1
|
||||
assert all(not button.isEnabled() for button in (page.view_button, page.ai_button, page.edit_button, page.delete_button))
|
||||
assert [page.metric_cards[key].value_label.text() for key in ("total", "private", "public", "month")] == ["2", "1", "1", "0"]
|
||||
for index in range(2):
|
||||
assert all(button.isEnabled() for button in row_buttons(page.table, index).values())
|
||||
assert len(row_buttons(page.table, index)) == 4
|
||||
assert page.table.item(index, 5).text() == "—"
|
||||
identifier = record_id(row_record(page.table, index))
|
||||
assert page.table.item(index, 4).text() == library._herbs_detail(None, originals[identifier])
|
||||
|
||||
|
||||
def test_query_return_reset_favorite_tab_and_page_send_original_dto(page_factory, application):
|
||||
page, repository = page_factory()
|
||||
repository.calls.clear()
|
||||
page.name_filter.setText(" 测试名称 ")
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
page.formula_filter.setCurrentIndex(page.formula_filter.findData("主方"))
|
||||
page.visibility_filter.setCurrentIndex(page.visibility_filter.findData(1))
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
assert repository.calls == [] # Combo selection still requires query or Return.
|
||||
QTest.keyClick(page.name_filter, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
filtered = {"prescription_name": "测试名称", "formula_type": "主方", "is_public": 1, "page_no": 1, "page_size": 15}
|
||||
assert repository.calls == [filtered]
|
||||
assert page.table.rowCount() == 1
|
||||
assert record_id(row_record(page.table, 0)) == 42
|
||||
assert page.metric_cards["total"].value_label.text() == "44"
|
||||
assert page.metric_cards["private"].value_label.text() == "0"
|
||||
assert page.metric_cards["public"].value_label.text() == "1"
|
||||
page.pager.load_more()
|
||||
assert repository.calls[-1] == dict(filtered, page_no=2)
|
||||
assert page.pager.page == 2
|
||||
page.favorite_tab.click()
|
||||
assert repository.calls[-2:] == [filtered, dict(filtered, page_no=2)] # Same query refreshes its loaded prefix.
|
||||
assert page.favorite_tab.isChecked() and not page.all_tab.isChecked()
|
||||
page.reset_button.click()
|
||||
assert repository.calls[-1] == {"prescription_name": "", "formula_type": "", "is_public": "", "page_no": 1, "page_size": 15}
|
||||
assert page.effect_filter.currentData() == ""
|
||||
assert page.favorite_tab.isChecked()
|
||||
assert page.table.rowCount() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column, order", [(0, Qt.SortOrder.DescendingOrder), (2, Qt.SortOrder.DescendingOrder)])
|
||||
def test_sort_then_refresh_keeps_selection_tags_and_each_action_bound_to_visible_id(
|
||||
page_factory, application, monkeypatch, column, order
|
||||
):
|
||||
page, repository = page_factory()
|
||||
original_rows = deepcopy(repository.rows)
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(record_id(table.current_data())))
|
||||
table.sortItems(column, order)
|
||||
settle(application)
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
|
||||
def verify_visible_actions():
|
||||
for index in range(2):
|
||||
record = row_record(table, index)
|
||||
assert table.item(index, 2).text() == ("主方" if record["formula_type"] == "main" else "辅方")
|
||||
assert table.item(index, 6).text() == ("所有人可见" if record["is_public"] else "仅自己可见")
|
||||
buttons = row_buttons(table, index)
|
||||
assert buttons["编辑处方模板"].isEnabled() == (record["creator_id"] == 7)
|
||||
table.selectRow(1 - index)
|
||||
button = buttons["查看处方模板"]
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == record["id"]
|
||||
assert record_id(table.current_data()) == record["id"]
|
||||
|
||||
verify_visible_actions()
|
||||
table.selectRow(next(index for index in range(2) if record_id(row_record(table, index)) == 41))
|
||||
page.refresh()
|
||||
settle(application)
|
||||
assert record_id(table.current_data()) == 41
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
verify_visible_actions()
|
||||
assert visited == [42, 41, 42, 41]
|
||||
assert repository.rows == original_rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permissions, user, expected_actions, can_edit_foreign",
|
||||
[({"wcf.prescription/read"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[3]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, True)],
|
||||
)
|
||||
def test_row_action_permissions_and_foreign_public_management_are_preserved(
|
||||
page_factory, permissions, user, expected_actions, can_edit_foreign
|
||||
):
|
||||
page, _ = page_factory(permissions=permissions, user=user)
|
||||
index = next(index for index in range(2) if record_id(row_record(page.table, index)) == 42)
|
||||
buttons = row_buttons(page.table, index)
|
||||
assert set(buttons) == expected_actions
|
||||
assert buttons["查看处方模板"].isEnabled()
|
||||
assert buttons["AI 解释"].isEnabled()
|
||||
if "编辑处方模板" in buttons:
|
||||
assert buttons["编辑处方模板"].isEnabled() == can_edit_foreign
|
||||
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
|
||||
else:
|
||||
assert page.edit_button.isHidden() and page.delete_button.isHidden()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Real prescription pages append server pages without losing row actions."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def inline(function, *, on_success, on_error, on_finished):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
finally:
|
||||
on_finished()
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get(self, **q):
|
||||
self.calls.append(dict(q))
|
||||
start = (q["page_no"] - 1) * q["page_size"]
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": i,
|
||||
"prescription_name": f"Template {i}",
|
||||
"sn": f"RX{i}",
|
||||
"patient_name": f"Patient {i}",
|
||||
"creator_id": 1,
|
||||
"is_public": False,
|
||||
"efficacy": "清热祛湿" if i > 15 else "益气养阴",
|
||||
"herbs": [],
|
||||
"create_time": "2026-09-01 10:00",
|
||||
}
|
||||
for i in range(start + 1, min(start + q["page_size"], 37) + 1)
|
||||
],
|
||||
"count": 37,
|
||||
"extend": {"doctors": [{"id": 1, "name": "Doctor"}]},
|
||||
}
|
||||
|
||||
list_prescriptions = get
|
||||
list_prescription_templates = get
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module,cls",
|
||||
[
|
||||
(prescriptions, prescriptions.PrescriptionsPage),
|
||||
(prescription_library, prescription_library.PrescriptionLibraryPage),
|
||||
],
|
||||
)
|
||||
def test_scroll_append_refresh_and_filter_reset(app, monkeypatch, module, cls):
|
||||
monkeypatch.setattr(module, "run_async", inline)
|
||||
repo = Repository()
|
||||
page = cls(repo, {"*"}, SimpleNamespace(id=1, root=1, role_ids=[]))
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.refresh()
|
||||
# Let deferred filter/table geometry settle before scrolling its real viewport.
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(4)
|
||||
before_id = page.table.current_data()["id"]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
for _ in range(50):
|
||||
if page.table.rowCount() == 30:
|
||||
break
|
||||
QTest.qWait(20)
|
||||
assert page.table.rowCount() == 30 and page.table.current_data()["id"] == before_id
|
||||
assert (
|
||||
len({page.table.item(r, 0).data(Qt.ItemDataRole.UserRole)["id"] for r in range(30)}) == 30
|
||||
)
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
assert [q["page_no"] for q in repo.calls[-2:]] == [1, 2]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
QTest.qWait(90)
|
||||
assert page.table.rowCount() == 37 and not page.pager.has_more
|
||||
edit = page.name_filter if module is prescription_library else page.patient_filter
|
||||
edit.setText("changed")
|
||||
page._search()
|
||||
assert page.table.rowCount() == 15 and repo.calls[-1]["page_no"] == 1
|
||||
assert page.pager.height() == 24 and page.layout().contentsMargins().bottom() == 8
|
||||
page.close()
|
||||
|
||||
|
||||
def test_local_effect_filter_continues_until_matching_rows_are_visible(app, monkeypatch):
|
||||
monkeypatch.setattr(prescription_library, "run_async", inline)
|
||||
page = prescription_library.PrescriptionLibraryPage(
|
||||
Repository(), {"*"}, SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
)
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
page._search()
|
||||
QTest.qWait(180)
|
||||
assert page.table.rowCount() >= 15
|
||||
assert page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] > 15
|
||||
page.close()
|
||||
|
||||
|
||||
def test_audit_status_sort_keeps_actions_on_the_visible_prescription(app, monkeypatch):
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
monkeypatch.setattr(prescriptions, "run_async", inline)
|
||||
|
||||
class AuditRepository(Repository):
|
||||
def get(self, **query):
|
||||
result = super().get(**query)
|
||||
for row in result["lists"]:
|
||||
identifier = row["id"]
|
||||
row.update(
|
||||
audit_status=identifier % 3,
|
||||
audit_remark="rejected reason" if identifier % 3 == 2 else "",
|
||||
business_prescription_audit_rejected=identifier % 2 == 0,
|
||||
business_prescription_audit_remark="business reason"
|
||||
if identifier % 2 == 0
|
||||
else "",
|
||||
)
|
||||
return result
|
||||
|
||||
list_prescriptions = get
|
||||
|
||||
page = prescriptions.PrescriptionsPage(AuditRepository(), {"*"}, SimpleNamespace(id=1))
|
||||
page.refresh()
|
||||
opened = []
|
||||
page._view_selected = lambda: opened.append(page.table.current_data()["id"])
|
||||
page.table.sortItems(6, Qt.SortOrder.DescendingOrder)
|
||||
page.pager.load_more()
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
for row in range(page.table.rowCount()):
|
||||
expected = page.table.item(row, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
buttons = page.table.cellWidget(row, 2).findChildren(QPushButton)
|
||||
next(button for button in buttons if button.accessibleName() == "查看处方").click()
|
||||
assert opened[-1] == expected
|
||||
page.close()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Native interactions and reachability unique to the issued-prescription blue UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _assert_visible_within(widget, viewport) -> None:
|
||||
assert widget.isVisible()
|
||||
rect = _rect_in(widget, viewport)
|
||||
assert viewport.rect().contains(rect), (widget.objectName(), rect, viewport.rect())
|
||||
|
||||
|
||||
def _record_id(table, row: int) -> int:
|
||||
return int(table.item(row, 0).data(Qt.ItemDataRole.UserRole).id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
network_attempts = []
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
network_attempts.append("socket")
|
||||
pytest.fail("Prescription blue interaction tests must use local Demo data only")
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(width=1536, height=960):
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
permissions = PermissionSet(["*"])
|
||||
shell = ShellWindow(repository, {
|
||||
"user": session.user,
|
||||
"permissions": permissions,
|
||||
"demo_mode": True,
|
||||
"menu": [{"perms": "tcm.prescription/lists"}],
|
||||
}, permissions=permissions)
|
||||
opened.append(shell)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
assert shell.navigate("prescriptions")
|
||||
_settle(application)
|
||||
page = shell.pages["prescriptions"]
|
||||
assert {_record_id(page.table, row) for row in range(page.table.rowCount())} == {801, 802}
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
return shell, page
|
||||
|
||||
yield create
|
||||
for shell in opened:
|
||||
for timer in shell.findChildren(QTimer):
|
||||
timer.stop()
|
||||
shell.close()
|
||||
shell.deleteLater()
|
||||
_settle(application)
|
||||
assert network_attempts == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "filter_height"),
|
||||
[(1536, 960, 144), (1366, 768, 144), (1024, 768, 200), (1024, 640, 200)],
|
||||
ids=["full-desktop", "compact-desktop", "small-window", "minimum-window"],
|
||||
)
|
||||
def test_shell_preserves_all_columns_filters_and_reachable_pager(
|
||||
shell_factory, application, width, height, filter_height
|
||||
) -> None:
|
||||
shell, page = shell_factory(width, height)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
table = page.table
|
||||
assert [column.key for column in table.columns] == [
|
||||
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
||||
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
||||
]
|
||||
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
||||
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
||||
]
|
||||
assert table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.filter_card.height() == filter_height
|
||||
for name in ("quick_date", "start_time", "end_time", "audit_filter", "source_filter",
|
||||
"sn_filter", "patient_filter", "doctor_filter", "query_button", "reset_button"):
|
||||
_assert_visible_within(getattr(page, name), page.filter_card)
|
||||
|
||||
outer_scroll = page.scroll.verticalScrollBar()
|
||||
if width >= 1366:
|
||||
assert outer_scroll.maximum() == 0
|
||||
_assert_visible_within(page.filter_card, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
else:
|
||||
# The compact footer allows 1024x768 to fit without outer scrolling.
|
||||
if height <= 640:
|
||||
assert outer_scroll.maximum() > 0
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
|
||||
if width == 1536:
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
for row in range(table.rowCount()):
|
||||
for column in range(11):
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(row, column))), (row, column)
|
||||
_assert_visible_within(page.pager, shell)
|
||||
|
||||
# Every logical column remains reachable in the native table at every width.
|
||||
outer_scroll.setValue(outer_scroll.maximum())
|
||||
for column in range(11):
|
||||
table.scrollToItem(table.item(0, column), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
_settle(application)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(0, column))), column
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
action_host = table.cellWidget(0, 2)
|
||||
assert action_host is not None
|
||||
_assert_visible_within(action_host, table.viewport())
|
||||
_assert_visible_within(action_host, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager.summary_label, shell)
|
||||
assert page.pager.height() == 24
|
||||
assert not hasattr(page.pager, "next")
|
||||
for button in page.pager.findChildren(QPushButton):
|
||||
if button.isVisible():
|
||||
_assert_visible_within(button, page.scroll.viewport())
|
||||
for button in action_host.findChildren(QPushButton):
|
||||
_assert_visible_within(button, table.viewport())
|
||||
|
||||
|
||||
def test_native_checkbox_center_clicks_and_space_keep_row_binding(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
row = next(index for index in range(table.rowCount()) if _record_id(table, index) == 802)
|
||||
selector = table.item(row, 0)
|
||||
table.setCurrentItem(selector)
|
||||
table.setFocus()
|
||||
_settle(application)
|
||||
center = table.visualItemRect(selector).center()
|
||||
assert table.viewport().rect().contains(center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
QTest.keyClick(table, Qt.Key.Key_Space)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
assert int(table.current_data().id) == 802
|
||||
assert selector.data(Qt.ItemDataRole.UserRole).id == 802
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder])
|
||||
def test_sorted_native_row_view_button_targets_visible_record(
|
||||
shell_factory, application, monkeypatch, order
|
||||
) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(int(table.current_data().id)))
|
||||
table.sortItems(1, order)
|
||||
_settle(application)
|
||||
expected_order = [801, 802] if order == Qt.SortOrder.AscendingOrder else [802, 801]
|
||||
assert [_record_id(table, index) for index in range(2)] == expected_order
|
||||
for row in range(2):
|
||||
# Start on the other record, so a stale action that only uses selection fails.
|
||||
table.selectRow(1 - row)
|
||||
host = table.cellWidget(row, 2)
|
||||
button = next(button for button in host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "查看处方")
|
||||
_assert_visible_within(button, table.viewport())
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == expected_order[row]
|
||||
assert int(table.current_data().id) == expected_order[row]
|
||||
assert visited == expected_order
|
||||
|
||||
|
||||
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
|
||||
kind, color, size = "pencil", "#1769E8", 19
|
||||
shared = icons.icon(kind, color, size)
|
||||
original_key = shared.cacheKey()
|
||||
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
|
||||
original_disabled = shared.pixmap(size, size, QIcon.Mode.Disabled).toImage()
|
||||
|
||||
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
|
||||
|
||||
assert icons.icon(kind, color, size) is shared
|
||||
assert shared.cacheKey() == original_key
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
|
||||
|
||||
|
||||
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory(1024, 768)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
button = page.doctor_filter.button
|
||||
full_text = "联合会诊专家门诊陈医生、疑难病联合门诊林医生 等3人"
|
||||
button.setText(full_text)
|
||||
_settle(application)
|
||||
assert button.fontMetrics().horizontalAdvance(full_text) > button.width() - 48
|
||||
assert button.text() == full_text
|
||||
assert button.toolTip() == full_text
|
||||
assert not button.grab().isNull() # Execute the actual compact paint path.
|
||||
assert button.text() == full_text
|
||||
assert page.doctor_filter.values() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1340, 900)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
widget.patient_name_label.setText("林晓岚")
|
||||
widget.patient_meta_label.setText("女 · 46岁 · 138****1203 | 就诊号:101")
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
|
||||
page.video_button.setEnabled(True)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
|
||||
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
page.video_button.setEnabled(False)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
|
||||
|
||||
|
||||
def test_typography_preserves_regular_body_and_distinct_values(page):
|
||||
body = page.case_labels["present"]
|
||||
assert body.font().pixelSize() == 14
|
||||
assert body.font().weight() == 400
|
||||
assert page.patient_name_label.font().pixelSize() == 22
|
||||
assert page.patient_name_label.font().weight() == 600
|
||||
assert page.vital_labels["height"].font().pixelSize() == 18
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1340, 1080, 816])
|
||||
def test_identity_and_actions_stay_inside_hero_at_both_layouts(page, application, width):
|
||||
page.resize(width, 900)
|
||||
settle(application)
|
||||
hero = page.findChild(QFrame, "ReceptionHero")
|
||||
for button in (page.notify_button, page.history_button, page.video_button, page.more_button):
|
||||
assert hero.rect().contains(button.geometry())
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
if width == 1340:
|
||||
assert abs(page.video_button.geometry().center().y() - page.patient_avatar_label.geometry().center().y()) < 20
|
||||
else:
|
||||
assert page.video_button.geometry().top() >= page.patient_meta_label.geometry().bottom()
|
||||
|
||||
|
||||
def test_body_growth_stays_readable_and_does_not_cover_actions(page, application):
|
||||
body = page.case_labels["present"]
|
||||
body.setText("患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n" * 12)
|
||||
settle(application)
|
||||
assert body.height() >= body.heightForWidth(body.width())
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > 0
|
||||
assert body.textFormat() == Qt.TextFormat.PlainText
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["6.8 mmol/L", "1234.56 mg/g", "≤0.25 mg/L", "—"])
|
||||
def test_lab_value_keeps_full_plain_text_and_wraps_long_units(page, application, value):
|
||||
label = page.lab_labels["fasting"]
|
||||
label.setText(value)
|
||||
settle(application)
|
||||
assert label.text() == value
|
||||
assert label.textFormat() == Qt.TextFormat.PlainText
|
||||
for width in (55, 120):
|
||||
layout, size = label._value_layout(width)
|
||||
assert sum(layout.lineAt(i).textLength() for i in range(layout.lineCount())) == len(value)
|
||||
assert size.height() == label.heightForWidth(width)
|
||||
for i in range(layout.lineCount()):
|
||||
line = layout.lineAt(i)
|
||||
assert line.naturalTextWidth() <= width + 1
|
||||
assert line.y() + line.height() <= size.height()
|
||||
|
||||
|
||||
def test_queue_summary_and_navigation_tabs_remain_available(page):
|
||||
summary = page.findChild(QLabel, "ReceptionQueueSummary")
|
||||
assert summary.isVisible()
|
||||
assert [page.detail_tabs.tabText(i) for i in range(page.detail_tabs.count())] == [
|
||||
"问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"
|
||||
]
|
||||
|
||||
|
||||
def test_risks_remain_in_full_report_while_preview_matches_approved_image(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
page._render_ai_risk_chips([
|
||||
{"label": "血糖波动风险", "level": "medium"},
|
||||
{"label": "睡眠质量下降", "level": "low"},
|
||||
])
|
||||
settle(application)
|
||||
assert not page.ai_risk_chip_host.isVisible()
|
||||
dialog = reception_module._ReceptionAiAnalysisDialog({"qwen": {
|
||||
"diagnosis_advice": "需要结合病史复核。",
|
||||
"risk_assessment": [{"label": "血糖波动风险", "level": "medium"}],
|
||||
"treatment_advice": "完整治疗建议仍可阅读。",
|
||||
}})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
risk = next(label for label in dialog.findChildren(QLabel) if label.text() == "血糖波动风险")
|
||||
assert risk.isVisible()
|
||||
assert risk.width() >= risk.fontMetrics().horizontalAdvance(risk.text())
|
||||
assert any(label.text() == "完整治疗建议仍可阅读。" for label in dialog.findChildren(QLabel))
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_compact_ai_keeps_full_treatment_data_without_pushing_controls_out_of_view(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
page.ai_summary_label.setText("需要结合完整病史与复诊记录核对。" * 25)
|
||||
page.ai_treatment_label.setText("请结合患者实际情况复核完整分析。" * 25)
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
settle(application)
|
||||
assert page.ai_treatment_label.text() == "请结合患者实际情况复核完整分析。" * 25
|
||||
assert not page.ai_treatment_label.isVisible()
|
||||
assert not page.ai_question_edit.isVisible()
|
||||
assert not page.ai_analysis_history_button.isVisible()
|
||||
actions = [action.text() for action in page.more_button.menu().actions()]
|
||||
assert "向 AI 提问" in actions
|
||||
assert "历史 AI 报告" in actions
|
||||
assert "重新分析 AI 报告" in actions
|
||||
assert page.ai_analysis_card.height() < 320
|
||||
|
||||
|
||||
def test_patient_switch_clears_previous_condition_tooltip(page):
|
||||
page.patient_meta_label.setToolTip("上一位患者的病情提示")
|
||||
page._reset_detail_content({"id": 202, "patient_name": "新患者"})
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
page.patient_meta_label.setToolTip("已失效的病情提示")
|
||||
page._render_detail_load_failure("详情暂不可用")
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
|
||||
|
||||
def test_moved_ai_controls_keep_actions_and_enabled_state(application, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_analysis_dialog", lambda self: calls.append("history"))
|
||||
monkeypatch.setattr(ReceptionPage, "_regenerate_patient_ai_reports", lambda self: calls.append("regenerate"))
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_assistant", lambda self, prompt: calls.append(("ask", prompt)))
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
try:
|
||||
widget.poll_timer.stop()
|
||||
assert not widget.ai_history_action.isEnabled()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
widget._ai_analysis_histories["qwen"] = [{"diagnosis_advice": "示例分析"}]
|
||||
widget._ai_analysis_patient_flow = True
|
||||
widget._ai_analysis_patient_id = 101
|
||||
widget._sync_ai_analysis_view()
|
||||
assert widget.ai_history_action.isEnabled()
|
||||
assert widget.ai_regenerate_action.isEnabled()
|
||||
widget.ai_history_action.trigger()
|
||||
widget.ai_regenerate_action.trigger()
|
||||
next(action for action in widget.more_button.menu().actions() if action.text() == "向 AI 提问").trigger()
|
||||
widget.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle").click()
|
||||
assert calls == ["history", "regenerate", ("ask", ""), ("ask", "")]
|
||||
widget._ai_analysis_regenerating = True
|
||||
widget._sync_ai_analysis_view()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
finally:
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QSize
|
||||
from PySide6.QtGui import QFont, QFontInfo, QIcon
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.reception_style import (
|
||||
TECH_BLUE,
|
||||
body_family,
|
||||
heading_family,
|
||||
number_family,
|
||||
)
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
class _Page(QWidget):
|
||||
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self.label = QLabel("原有页面正文", self)
|
||||
layout.addWidget(self.label)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
navigation = [
|
||||
NavigationItem(key, title, "", _Page, ("doctor.appointment/lists",))
|
||||
for key, title in (
|
||||
("appointments", "挂号列表"),
|
||||
("consultations", "问诊列表"),
|
||||
("reception", "接诊台"),
|
||||
("patients", "我的患者"),
|
||||
("prescriptions", "已开处方"),
|
||||
("prescription_library", "我的处方库"),
|
||||
("legacy_reference", "原版框架参照"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||
)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
shell = ShellWindow(
|
||||
object(),
|
||||
{"user": {"name": "陈医生"}, "demo_mode": True},
|
||||
permissions={"doctor.appointment/lists", "tcm.diagnosis/aiAssistant"},
|
||||
)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
application.processEvents()
|
||||
yield shell
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _pixel(widget: QWidget, x: int, y: int) -> str:
|
||||
image = widget.grab().toImage()
|
||||
ratio = image.devicePixelRatio()
|
||||
return image.pixelColor(round(x * ratio), round(y * ratio)).name().upper()
|
||||
|
||||
|
||||
def test_approved_chrome_restores_styles_fonts_icons_and_geometry(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_app_qss = application.styleSheet()
|
||||
original_app_font = application.font().toString()
|
||||
original_colors = dict(COLORS)
|
||||
original_page_fonts = {key: page.label.font().toString() for key, page in window.pages.items()}
|
||||
original_styles = dict(window._legacy_chrome_styles)
|
||||
original_avatar = window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
original_ai = window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
original_nav = (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library", "consultations", "appointments"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert _pixel(window, 6, 300) == "#F3F7FD"
|
||||
assert _pixel(window.sidebar, 5, 300) == "#F3F7FD"
|
||||
assert _pixel(window.workspace, 600, 10) == TECH_BLUE["surface"]
|
||||
assert window.nav_buttons[key].font().family() == body_family()
|
||||
assert window.nav_buttons[key].font().weight() == QFont.Weight.Normal
|
||||
assert window.brand_name.font().family() == heading_family()
|
||||
assert window.brand_name.font().weight() == QFont.Weight.DemiBold
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is True
|
||||
assert window.assistant_status.property("receptionTechBlue") is True
|
||||
assert original_avatar != window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
assert original_ai != window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
assert (
|
||||
original_nav
|
||||
!= window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert _pixel(window, 6, 300) == COLORS["canvas"].upper()
|
||||
assert _pixel(window, 18, 313) == COLORS["canvas"].upper()
|
||||
assert {widget: widget.styleSheet() for widget in original_styles} == original_styles
|
||||
assert window.user_menu_button.icon().pixmap(34, 34).toImage() == original_avatar
|
||||
assert window.ai_top_button.icon().pixmap(18, 18).toImage() == original_ai
|
||||
assert (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
== original_nav
|
||||
)
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is False
|
||||
assert window.assistant_status.property("receptionTechBlue") is False
|
||||
|
||||
assert application.styleSheet() == original_app_qss
|
||||
assert application.font().toString() == original_app_font
|
||||
assert original_colors == COLORS
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_selected_navigation_and_ai_tile_use_approved_blue(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
button = window.nav_buttons[key]
|
||||
button.clearFocus()
|
||||
application.processEvents()
|
||||
assert _pixel(button, 1, button.height() // 2) == TECH_BLUE["accent"]
|
||||
assert _pixel(button, button.width() - 12, button.height() // 2) == TECH_BLUE["selection"]
|
||||
assert _pixel(window.assistant_glyph, 10, 25) == TECH_BLUE["accent"]
|
||||
assert _pixel(window.assistant_status, 3, 9) == TECH_BLUE["accent"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_brand_mark_uses_blue_medical_cross_and_restores_original(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
mark = window.brand_mark
|
||||
original = mark.grab().toImage()
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is True
|
||||
assert mark.width() == mark.height() == 38
|
||||
assert _pixel(mark, 5, 18) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 19, 19) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 18, 9) == TECH_BLUE["surface"]
|
||||
assert original != mark.grab().toImage()
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is False
|
||||
assert mark.grab().toImage() == original
|
||||
|
||||
|
||||
def test_collapsed_rail_keeps_state_across_approved_and_original_pages(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.toggle_sidebar()
|
||||
assert window.sidebar.width() == 68
|
||||
window.navigate("reception")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["reception"].isChecked()
|
||||
assert not window.assistant_card.isVisible()
|
||||
assert window.nav_buttons["reception"].toolTip() == "接诊台"
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
window.navigate("appointments")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["appointments"].isChecked()
|
||||
assert window.nav_buttons["appointments"].toolTip() == "挂号列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("consultations")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["consultations"].isChecked()
|
||||
assert window.nav_buttons["consultations"].toolTip() == "问诊列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("patients")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["patients"].isChecked()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("prescriptions")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["prescriptions"].isChecked()
|
||||
assert window.nav_buttons["prescriptions"].toolTip() == "已开处方"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert window.nav_buttons["legacy_reference"].text() == "原版框架参照"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_shell_geometry_and_function_entries_restore(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_geometry = {
|
||||
widget: (widget.size(), widget.iconSize() if hasattr(widget, "iconSize") else None)
|
||||
for widget in (
|
||||
window.sidebar, window.topbar, window.search_host, window.brand_mark,
|
||||
window.assistant_card, window.assistant_button, window.assistant_status,
|
||||
window.upload_settings_button, window.user_menu_button,
|
||||
window.refresh_button, window.notification_button, window.settings_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(208, 0)
|
||||
assert window.topbar.height() == 76
|
||||
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
|
||||
assert window.assistant_card.size() == QSize(168, 171)
|
||||
assert window.assistant_button.height() == 46
|
||||
assert window.brand_name.font().pixelSize() == 18
|
||||
assert window.brand_subtitle.font().pixelSize() == 13
|
||||
for index, button in enumerate(window.nav_buttons.values()):
|
||||
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
|
||||
assert button.height() == 52
|
||||
assert button.font().pixelSize() == 15
|
||||
assert button.iconSize() == QSize(20, 20)
|
||||
assert not window.fold_button.isVisible()
|
||||
assert not window.ai_top_button.isVisible()
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
assert window.menu_ai_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(203, 13)
|
||||
assert window.fold_button.isVisible()
|
||||
assert window.ai_top_button.isVisible()
|
||||
assert not window.menu_sidebar_action.isVisible()
|
||||
assert not window.menu_ai_action.isVisible()
|
||||
assert not window.user_separator.isVisible()
|
||||
for widget, (size, icon_size) in original_geometry.items():
|
||||
assert widget.size() == size
|
||||
if icon_size is not None:
|
||||
assert widget.iconSize() == icon_size
|
||||
|
||||
|
||||
def test_reception_fonts_resolve_real_requested_weights(application: QApplication) -> None:
|
||||
for family, weights in (
|
||||
(body_family(), (QFont.Weight.Normal,)),
|
||||
(heading_family(), (QFont.Weight.Medium, QFont.Weight.DemiBold)),
|
||||
(number_family(), (QFont.Weight.Normal, QFont.Weight.DemiBold)),
|
||||
):
|
||||
for weight in weights:
|
||||
font = QFont(family)
|
||||
font.setPixelSize(15)
|
||||
font.setWeight(weight)
|
||||
resolved = QFontInfo(font)
|
||||
assert resolved.family() == family
|
||||
assert resolved.weight() == weight
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_minimum_window_keeps_search_and_all_chrome_actions_separate(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
window.resize(1024, 640)
|
||||
application.processEvents()
|
||||
assert window.width() == 1024
|
||||
assert 220 <= window.search_host.width() < 384
|
||||
controls = (
|
||||
window.search_host, window.refresh_button, window.notification_button,
|
||||
window.settings_button, window.user_menu_button, window.minimize_button,
|
||||
window.fullscreen_button, window.close_button,
|
||||
)
|
||||
for left, right in zip(controls, controls[1:], strict=False):
|
||||
assert left.x() + left.width() <= right.x()
|
||||
assert right.x() + right.width() <= window.topbar.width()
|
||||
window.resize(1536, 960)
|
||||
application.processEvents()
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
|
||||
|
||||
def test_refresh_animation_keeps_running_and_restores_palette_on_page_switch(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
button = window.refresh_button
|
||||
original = button.icon().pixmap(18, 18).toImage()
|
||||
button.start_spin()
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"):
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is True
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is False
|
||||
assert button._resting.pixmap(18, 18).toImage() == original
|
||||
button._timer.stop()
|
||||
|
||||
|
||||
def test_navigation_between_approved_pages_keeps_existing_chrome_assets(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("appointments")
|
||||
application.processEvents()
|
||||
styles = {widget: widget.styleSheet() for widget in window._legacy_chrome_styles}
|
||||
icons = {
|
||||
widget: widget.icon().cacheKey()
|
||||
for widget in (
|
||||
window.user_menu_button, window.refresh_button, window.ai_top_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
window.global_search.setText("患者查询")
|
||||
|
||||
for key in ("consultations", "reception", "patients", "prescriptions", "prescription_library", "appointments", "consultations"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.nav_buttons[key].isChecked()
|
||||
assert window.global_search.text() == "患者查询"
|
||||
assert window._reception_chrome_active
|
||||
assert {widget: widget.styleSheet() for widget in styles} == styles
|
||||
assert {widget: widget.icon().cacheKey() for widget in icons} == icons
|
||||
@@ -0,0 +1,145 @@
|
||||
"""List pages must load without a banner that reflows the page.
|
||||
|
||||
Every list refresh used to raise an info banner ("正在加载…") into the page
|
||||
layout and drop it again on completion. That is two relayouts per refresh - the
|
||||
filter bar and the whole table jump down and back - and the appointment and
|
||||
consultation pages poll every five seconds, so the jump repeated on its own with
|
||||
nobody touching the keyboard.
|
||||
|
||||
Errors still surface on the banner; only the routine load is silent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run the worker inline so the banner state can be inspected mid-load."""
|
||||
|
||||
def run_immediately(
|
||||
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: # noqa: BLE001 - mirrors the real worker
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
for module in (prescriptions_module, library_module, appointments_module):
|
||||
monkeypatch.setattr(module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _pages(application: QApplication) -> list[Any]:
|
||||
del application
|
||||
repository = DemoDoctorRepository()
|
||||
return [
|
||||
PrescriptionsPage(repository, ["*"]),
|
||||
PrescriptionLibraryPage(
|
||||
repository, ["*"], SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
),
|
||||
AppointmentsPage(repository, PermissionSet(["*"])),
|
||||
]
|
||||
|
||||
|
||||
def test_a_list_refresh_never_raises_a_loading_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
for page in _pages(application):
|
||||
banner = page.banner
|
||||
original = banner.show_message
|
||||
|
||||
def record(text: str, kind: str = "info", _page: Any = page) -> None:
|
||||
seen.append((type(_page).__name__, text))
|
||||
|
||||
banner.show_message = record # type: ignore[method-assign]
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
banner.show_message = original # type: ignore[method-assign]
|
||||
page.close()
|
||||
|
||||
assert seen == [], f"a list refresh still announced itself: {seen}"
|
||||
|
||||
|
||||
def test_a_refresh_does_not_move_anything_on_the_page(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""The real symptom was reflow, so assert on geometry rather than on a flag.
|
||||
|
||||
A hidden widget keeps whatever size it was last given, so checking the
|
||||
banner's own height proves nothing; what matters is that the table below it
|
||||
does not shift when a refresh runs.
|
||||
"""
|
||||
|
||||
for page in _pages(application):
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
table = page.table
|
||||
settled = (table.mapTo(page, table.rect().topLeft()), table.size())
|
||||
|
||||
for _ in range(3):
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
assert not page.banner.isVisible()
|
||||
assert (table.mapTo(page, table.rect().topLeft()), table.size()) == settled
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_a_failed_load_still_reports_on_the_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Silence is only for the happy path; failures must stay visible."""
|
||||
|
||||
class Failing(DemoDoctorRepository):
|
||||
def list_prescriptions(self, **_filters: Any) -> Any:
|
||||
raise RuntimeError("上游不可用")
|
||||
|
||||
page = PrescriptionsPage(Failing(), ["*"])
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
assert page.banner.isVisible()
|
||||
assert page.banner.label.text()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionDailyRecordsPanel, ReceptionPage
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet([]))
|
||||
widget.resize(1080, 760)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
|
||||
settle(application)
|
||||
for button in page.ai_prompt_buttons:
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
assert button.parentWidget().rect().contains(button.geometry())
|
||||
analysis = page.ai_analysis_card.geometry()
|
||||
assistant = page.ai_assistant_card.geometry()
|
||||
assert assistant.top() > analysis.bottom()
|
||||
|
||||
|
||||
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
|
||||
page.detail_tabs.setCurrentIndex(0)
|
||||
settle(application)
|
||||
consultation_height = page.clinical_pages.height()
|
||||
page.detail_tabs.setCurrentIndex(3)
|
||||
settle(application)
|
||||
assert page.clinical_pages.height() < consultation_height - 80
|
||||
|
||||
|
||||
def test_short_risk_labels_stay_on_one_line(page, application):
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
chip = page.ai_risk_chip_layout.itemAt(0).widget()
|
||||
assert chip.width() >= chip.fontMetrics().horizontalAdvance(chip.text()) + 18
|
||||
|
||||
|
||||
def test_seven_day_matrix_fits_readable_columns_without_unnecessary_horizontal_scroll(application):
|
||||
panel = ReceptionDailyRecordsPanel()
|
||||
panel.resize(900, 580)
|
||||
panel.set_data({}, [])
|
||||
panel.show()
|
||||
settle(application)
|
||||
try:
|
||||
assert panel.matrix.columnCount() == 8
|
||||
assert panel.matrix.horizontalScrollBar().maximum() == 0
|
||||
assert all(panel.matrix.columnWidth(i) >= 96 for i in range(1, 8))
|
||||
panel.range_buttons["30"].click()
|
||||
panel.set_data({}, [])
|
||||
settle(application)
|
||||
assert panel.matrix.columnCount() == 31
|
||||
assert panel.matrix.horizontalScrollBar().maximum() > 0
|
||||
assert panel.matrix.columnWidth(1) >= 96
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["secondary", "success", "warning", "danger", "link"])
|
||||
def test_disabled_semantic_buttons_do_not_look_actionable(application, variant):
|
||||
button = QPushButton("查看记录")
|
||||
button.setProperty("variant", variant)
|
||||
button.setEnabled(False)
|
||||
button.ensurePolished()
|
||||
assert button.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText).name() == COLORS["disabled_text"].lower()
|
||||
button.close()
|
||||
Reference in New Issue
Block a user