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