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

484 lines
25 KiB
Python

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