from __future__ import annotations import os from pathlib import Path from types import SimpleNamespace from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal from PySide6.QtGui import QColor, QImage, QPainter from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, QFrame, QSizePolicy, QSpinBox, QToolButton, QWidget, ) from doctor_workstation.core import PermissionSet from doctor_workstation.ui.diagnosis_index_widgets import ( DIAGNOSIS_INDEX_QSS, DiagnosisItemDelegate, DiagnosisTableModel, ) from doctor_workstation.ui.pages import consultations as consultations_module from doctor_workstation.ui.pages.consultations import ConsultationsPage class _ListDiagnosisDialog(QWidget): saved = Signal() def __init__(self, _repository: Any, parent: QWidget | None = None) -> None: super().__init__(parent) def open_for(self, *_args: Any, **_kwargs: Any) -> None: return None class _CancellationRepository: def cancel_diagnosis_appointment(self, appointment_id: int) -> None: del appointment_id class _FullMenuRepository(_CancellationRepository): def generate_video_qrcode( self, doctor_id: int, patient_id: int, share_user_id: int, *, diagnosis_id: int, ) -> dict[str, str]: del diagnosis_id, doctor_id, patient_id, share_user_id return {"qrcode_url": "https://example.invalid/video.png"} def generate_diagnosis_qrcode( self, diagnosis_id: int, doctor_id: int, patient_id: int, share_user_id: int, ) -> dict[str, str]: del diagnosis_id, doctor_id, patient_id, share_user_id return {"qrcode_url": "https://example.invalid/confirm.png"} def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]: del diagnosis_id return [] def create_diagnosis_order( self, patient_id: int, order_type: int, amount: float, *, remark: str = "", ) -> dict[str, Any]: del patient_id, order_type, amount, remark return {"order_no": "ORDER-715"} def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: del order_no return {"qrcode_url": "https://example.invalid/order.png"} @pytest.fixture(scope="module") def application() -> QApplication: return QApplication.instance() or QApplication([]) @pytest.fixture(autouse=True) def isolate_list_from_detail_dialog(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(consultations_module, "DiagnosisDialog", _ListDiagnosisDialog) @pytest.fixture def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: 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) 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() monkeypatch.setattr(consultations_module, "run_async", run_immediately) def _row(identifier: int, **changes: Any) -> dict[str, Any]: row: dict[str, Any] = { "id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000, "patient_name": f"患者{identifier}", "gender": 1, "age": 38, "has_appointment": 1, "appointment_id": identifier + 2000, "appointment_status": 1, "appointments": [ { "id": identifier + 2000, "status": 1, "doctor_name": "陈医生", "time_text": "今天 09:00-09:30", } ], "DiagnosisViewRecord": [{"is_confirmed": 1}], "assistant_id": 8, "assistant_name": "赵医助", "assign_read_at": None, "has_prescription": 1, "current_has_prescription": 0, "current_prescription_id": 0, "followup_time_text": "2026-08-17 09:00", "followup_doctor_name": "陈医生", "unserved_days": 2, "last_blood_record_at": "2026-08-09 20:10", } row.update(changes) return row def _page() -> ConsultationsPage: return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"])) def _caret_matches(button: object, direction: str) -> bool: """The disclosure caret is now a shared glyph, not Fusion's arrow type. ``setArrowType`` drew a solid triangle - the one filled mark in an otherwise all-stroke icon set - so the state is carried by the icon instead, and the direction has to be checked by comparing what was actually painted. """ from doctor_workstation.ui import icons painted = button.icon().pixmap(14, 14).toImage() expected = icons.pixmap(direction, "muted", 14).toImage() return painted == expected def test_visual_hierarchy_and_filter_contract( application: QApplication, ) -> None: page = _page() content_layout = page.page_scroll.widget().layout() margins = content_layout.contentsMargins() # The approved blue shell has no outer gutter; the page owns this spacing. assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (27, 24, 26, 8) assert content_layout.spacing() == 10 status_card = page.findChild(QFrame, "DiagnosisStatusCard") assert status_card is not None assert page.page_header.maximumHeight() >= page.page_header.minimumSizeHint().height() assert status_card.height() == 54 assert page.findChild(QFrame, "DiagnosisFilterCard") is not None assert page.findChild(QFrame, "DiagnosisListCard") is not None assert page.filters_card.isHidden() assert page.page_header.height() <= 44 assert page.keyword_edit.maximumWidth() == 340 assert list(page.status_buttons) == ["1", "", "4", "2", "3"] assert page.status_buttons["1"].isChecked() assert not page.advanced_filters.isVisible() assert page.more_filter_button.text() == "更多筛选" assert _caret_matches(page.more_filter_button, "down") assert [page._date_button_labels[key] for key in page.date_buttons] == [ "昨天挂号", "前天挂号", "当天挂号", "明天挂号", "后天挂号", "全部", ] assert page.date_buttons[page._appointment_date].isChecked() assert page.pending_assign_month.width() == 128 assert page.pending_assign_keyword.width() == 220 assert page.pending_assign_keyword.placeholderText() == "搜身份证/诊单ID/患者号/备注…" assert page.pending_assign_button.parentWidget() is page.pending_assign_wrap assert page.pending_assign_filters.parentWidget() is page.pending_assign_wrap assert page.batch_assign_button.text() == "批量指派医助" assert "PageHeader" not in {type(widget).__name__ for widget in page.findChildren(QFrame)} page.close() application.processEvents() def test_pending_assign_and_secondary_chip_semantics( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: page = _page() monkeypatch.setattr(page, "refresh", lambda silent=False: None) page._choose_pending_assign() assert page.pending_assign_button.isChecked() assert not page.date_buttons[page._appointment_date].isChecked() assert not page.pending_assign_filters.isHidden() page.pending_assign_keyword.setText("订单-88") filters = page._filters() assert filters == { "pending_assign": "1", "pending_assign_keyword": "订单-88", } page.pending_assign_keyword.clear() filters = page._filters() assert filters["pending_assign_order_month"] == page.pending_assign_month.text().strip() assert filters["pending_assign_keyword"] == "" page._choose_appointment_filter("0") assert page.appointment_filter_buttons["0"].isChecked() assert page._combo_value(page.has_appointment_combo) == "0" page._toggle_advanced_filters(True) assert not page.advanced_filters.isHidden() assert page.more_filter_button.text() == "收起" assert _caret_matches(page.more_filter_button, "up") assert page.unserved_sort_combo.isHidden() date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange") assert len(date_ranges) == 2 assert all(field.width() == 260 for field in date_ranges) page.close() application.processEvents() def test_toolbar_cancel_tracks_single_appointment_without_changing_checked_rows( application: QApplication, ) -> None: page = _page() single = _row(880) multiple = _row(881, appointments=[ {"id": 8801, "status": 1}, {"id": 8802, "status": 3}, ]) page.table_host.set_rows([single, multiple]) page.table.selectRow(0) page._selection_changed() assert page.cancel_toolbar_button.isEnabled() assert page.case_toolbar_button.isEnabled() assert not page.call_toolbar_button.isEnabled() page.table.selectRow(1) page._selection_changed() assert not page.cancel_toolbar_button.isEnabled() assert page.table_host.selected_records() == [] page.table_host.set_rows([]) page._selection_changed() assert not page.case_toolbar_button.isEnabled() assert not page.prescription_toolbar_button.isEnabled() page.close() application.processEvents() @pytest.mark.parametrize("size", [(816, 564), (1328, 884)]) def test_filter_rows_and_toolbar_stay_inside_their_panels_when_wrapping( application: QApplication, monkeypatch: pytest.MonkeyPatch, size: tuple[int, int], ) -> None: page = _page() monkeypatch.setattr(page, "refresh", lambda silent=False: None) page.resize(*size) page.show() page.filter_disclosure.set_expanded(True) for expanded in (False, True): page._toggle_advanced_filters(expanded) if expanded: page._choose_pending_assign() for _ in range(4): application.processEvents() controls = [*page.date_buttons.values(), page.custom_date_edit, page.confirmed_combo, page.department_combo, page.keyword_edit, page.more_filter_button] if expanded: controls.extend([page.pending_assign_month, page.pending_assign_keyword, page.channel_combo, page.latest_assign_end_date]) rectangles = [QRect(control.mapTo(page.filters_card, QPoint()), control.size()) for control in controls if control.isVisible()] assert all(page.filters_card.rect().contains(rect) for rect in rectangles) for index, rect in enumerate(rectangles): assert all(not rect.intersects(other) for other in rectangles[index + 1:]) for button in (page.add_button, page.cancel_toolbar_button, page.complete_toolbar_button, page.refresh_button): if button.isVisible(): assert page.list_toolbar.rect().contains( QRect(button.mapTo(page.list_toolbar, QPoint()), button.size()) ) page.close() application.processEvents() def test_dedicated_model_fixed_columns_selection_and_sort( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: page = _page() assert isinstance(page.table.model(), QAbstractTableModel) assert isinstance(page.table.model(), DiagnosisTableModel) assert page.table_host.LEFT_WIDTHS == (48, 70, 82, 102, 244, 90, 84, 90, 88, 122) # Reference-aligned video/actions remain frozen in a compact 250px pane. assert page.table_host.FIXED_WIDTHS == (92, 158) assert page.table_host.fixed.width() == 250 assert page.table.isColumnHidden(10) assert page.table_host.fixed.isColumnHidden(9) assert not page.table_host.fixed.isColumnHidden(10) assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel assert page.table_host.fixed.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel assert page.table_host.fixed.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff assert page.table_host.minimumHeight() == 0 assert page.table_host.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Expanding rows = [_row(501), _row(502, has_appointment=0, appointments=[])] page.table_host.set_rows(rows) model = page.table_host.model assert model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole) assert page.table_host.selected_records() == [rows[0]] assert page.selected_label.text() == "已选 1 条" requested: list[str] = [] page.table_host.sort_unserved_requested.connect(requested.append) assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数" assert page.table_host.model._sort_direction == "" # This model/action test keeps its fixture rows; a real server sort starts # a new query and intentionally resets the loaded list. monkeypatch.setattr(page, "refresh", lambda silent=False: None) page.table_host.main.horizontalHeader().sectionClicked.emit(9) assert requested == ["desc"] assert page.table_host.model._sort_direction == "desc" page.table_host.action_requested.disconnect(page._row_action) actions: list[tuple[str, Any]] = [] page.table_host.action_requested.connect( lambda action, record: actions.append((action, record)) ) action_cell = page.table_host.fixed.indexWidget(model.index(0, 11)) second_fixed_cell = page.table_host.fixed.indexWidget(model.index(1, 11)) second_fixed_cell.hovered_row.emit(1) assert model.hover_row == 1 second_fixed_cell.hovered_row.emit(-1) assert model.hover_row == -1 # 行内只保留“看诊单 / 开方”这两个闭环主操作,其余操作一律降级进“更多”, # 保证每一行的操作列宽度一致、数据列不再被挤成省略号。 direct_links = [ button.text() for button in action_cell.findChildren(QToolButton) if button.menu() is None ] assert direct_links == ["查看", "诊单"] more = next(button for button in action_cell.findChildren(QToolButton) if button.menu()) menu_texts = [action.text() for action in more.menu().actions() if not action.isSeparator()] assert menu_texts[:4] == ["开方", "AI 分析", "预约", "补全身份证"] assert "指派" in menu_texts assert "取消挂号" in menu_texts assert {"视频二维码", "二维码", "挂号日志", "创建订单"}.isdisjoint(menu_texts) next(action for action in more.menu().actions() if action.text() == "指派").trigger() assert actions == [("assign", rows[0])] page.close() application.processEvents() def test_empty_loading_and_compact_footer_keep_the_table_shell( application: QApplication, ) -> None: page = _page() page.table_host.set_rows([]) page._loading = True page.resize(1024, 640) page.show() application.processEvents() page._loading = False assert page.table_host.empty_label.isVisible() assert page.table.horizontalHeader().isVisible() assert page.table_host.height() >= 39 + 60 page.loading_overlay.start() application.processEvents() assert page.loading_overlay.isVisible() assert page.loading_overlay.geometry() == page.table_host.rect() page.loading_overlay.stop() page.pager.update_state(3, 97) assert page.pager.height() == 24 pager_margins = page.pager.layout().contentsMargins() assert pager_margins.top() == 0 assert pager_margins.bottom() == 0 assert not page.pager.findChildren(QComboBox) assert not page.pager.findChildren(QSpinBox) assert not page.pager.findChildren(QToolButton) page.close() application.processEvents() @pytest.mark.parametrize( ("record", "stripe", "channel"), [ (_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"), (_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"), ], ) def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background( application: QApplication, record: dict[str, Any], stripe: str, channel: str, ) -> None: model = DiagnosisTableModel([record]) model.set_hover_row(0) image = QImage(48, 52, QImage.Format.Format_ARGB32_Premultiplied) image.fill(QColor("#FFFFFF")) painter = QPainter(image) DiagnosisItemDelegate._paint_row_background( painter, QRect(0, 0, 48, 52), record, 0, 0, model, ) painter.end() assert image.pixelColor(0, 20).name() == stripe assert image.pixelColor(1, 20).name() == stripe assert image.pixelColor(2, 20).name() == stripe assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened" assert image.pixelColor(6, 20).name() == "#f7f7f7" assert image.pixelColor(40, 20).name() == "#f7f7f7" def test_page_hides_fixed_shadow_and_preserves_admin_token_contract( application: QApplication, ) -> None: page = _page() page.resize(1024, 640) page.show() application.processEvents() shadow = page.table_host.fixed_shadow assert shadow.isHidden() assert shadow.width() == 12 assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1 assert "font-family:" not in DIAGNOSIS_INDEX_QSS assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in ( DIAGNOSIS_INDEX_QSS ) page.close() application.processEvents() def test_multi_appointment_never_exposes_ambiguous_row_cancel( application: QApplication, ) -> None: page = _page() single = _row(710) multiple = _row( 711, appointments=[ {"id": 2711, "status": 1, "doctor_name": "陈医生", "time_text": "09:00"}, { "id": 4_294_967_302, "status": 4, "doctor_name": "李医生", "time_text": "10:00", }, ], ) page.table_host.set_rows([single, multiple]) def menu_texts(row: int) -> set[str]: cell = page.table_host.fixed.indexWidget(page.table_host.model.index(row, 11)) more = next(button for button in cell.findChildren(QToolButton) if button.menu()) return {action.text() for action in more.menu().actions() if not action.isSeparator()} assert "取消挂号" in menu_texts(0) assert "取消挂号" not in menu_texts(1) appointment_cell = page.table_host.main.indexWidget(page.table_host.model.index(1, 4)) appointment_buttons = appointment_cell.findChildren(QToolButton) assert [button.text() for button in appointment_buttons] == ["取消", "取消"] assert [button.accessibleName() for button in appointment_buttons] == [ "取消挂号 2711", "取消挂号 4294967302", ] page.table_host.appointment_cancel_requested.disconnect(page._cancel_appointment_item) requested: list[tuple[Any, int]] = [] page.table_host.appointment_cancel_requested.connect( lambda record, appointment_id: requested.append((record, appointment_id)) ) appointment_buttons[1].click() assert requested == [(multiple, 4_294_967_302)] page.close() application.processEvents() def test_full_more_menu_requires_each_real_repository_capability( application: QApplication, ) -> None: page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(["*"])) record = _row( 715, appointment_doctor_id=9, appointments=[ { "id": 2715, "status": 1, "doctor_id": 9, "doctor_name": "陈医生", "time_text": "09:00", } ], ) page.table_host.set_rows([record]) cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) more = next(button for button in cell.findChildren(QToolButton) if button.menu()) # 前三项是从行内降级下来的次要操作,其后才是本就属于“更多”的能力项。 assert [action.text() for action in more.menu().actions() if not action.isSeparator()] == [ "开方", "AI 分析", "预约", "补全身份证", "指派", "取消指派", "视频二维码", "二维码", "取消挂号", "挂号日志", "创建订单", "删除", ] actions = more.menu().actions() menu_actions = [action for action in actions if not action.isSeparator()] assert all(not action.icon().isNull() for action in menu_actions) assert all(action.property("iconSource") == "qpaint" for action in menu_actions) assert actions[-2].isSeparator() assert actions[-1].text() == "删除" assert actions[-1].property("danger") is True more.menu().ensurePolished() more.menu().adjustSize() more.menu().show() application.processEvents() danger_rect = more.menu().actionGeometry(actions[-1]) danger_image = more.menu().grab().toImage() red_text_pixels = 0 for y in range(max(0, danger_rect.top()), min(danger_image.height(), danger_rect.bottom() + 1)): for x in range( max(0, danger_rect.left() + 40), min(danger_image.width(), danger_rect.right() + 1), ): color = danger_image.pixelColor(x, y) if color.red() > color.green() + 60 and color.red() > color.blue() + 60: red_text_pixels += 1 assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色" more.menu().hide() assert page.table_host.action_policy == { "view": True, "edit": True, "prescription": True, "ai_consult": True, "appointment": True, "assign": True, "delete": True, "video_call": False, "appointment_cancel": True, "video_qr": True, "confirm_qr": True, "appointment_logs": True, "create_order": True, } page.close() application.processEvents() def test_patient_workspace_cancel_endpoint_is_not_a_diagnosis_list_capability( application: QApplication, ) -> None: class PatientWorkspaceOnlyRepository: def cancel_patient_appointment(self, appointment_id: int) -> None: del appointment_id page = ConsultationsPage( PatientWorkspaceOnlyRepository(), permissions=PermissionSet(["tcm.diagnosis/guahao"]), ) page.table_host.set_rows([_row(716)]) assert not page.table_host.action_policy["appointment_cancel"] assert page.table_host.main.indexWidget(page.table_host.model.index(0, 4)) is None action_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) more_buttons = [button for button in action_cell.findChildren(QToolButton) if button.menu()] assert all(button.isHidden() for button in more_buttons) assert all( "取消挂号" not in {action.text() for action in button.menu().actions()} for button in more_buttons ) page.close() application.processEvents() @pytest.mark.parametrize("missing_method", ["create_diagnosis_order", "generate_order_qrcode"]) def test_order_menu_requires_the_complete_create_and_payment_qr_capability( application: QApplication, missing_method: str, ) -> None: repository = _FullMenuRepository() setattr(repository, missing_method, None) page = ConsultationsPage( repository, permissions=PermissionSet(["tcm.diagnosis/order"]), ) page.table_host.set_rows([_row(717, appointment_doctor_id=9)]) cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) more_buttons = [button for button in cell.findChildren(QToolButton) if button.menu()] assert not page.table_host.action_policy["create_order"] assert all( "创建订单" not in {action.text() for action in button.menu().actions()} for button in more_buttons ) page.close() application.processEvents() @pytest.mark.parametrize( ("permission", "missing_text"), [ ("tcm.diagnosis/videoQr", "视频二维码"), ("tcm.diagnosis/guahao", "二维码"), ("tcm.diagnosis/guahaoLogList", "挂号日志"), ("tcm.diagnosis/order", "创建订单"), ], ) def test_menu_capability_without_its_exact_permission_is_hidden( application: QApplication, permission: str, missing_text: str, ) -> None: granted = { "tcm.diagnosis/assign", "tcm.diagnosis/delete", "tcm.diagnosis/videoQr", "tcm.diagnosis/guahao", "tcm.diagnosis/guahaoLogList", "tcm.diagnosis/order", } granted.remove(permission) page = ConsultationsPage(_FullMenuRepository(), permissions=PermissionSet(granted)) page.table_host.set_rows([_row(718, appointment_doctor_id=9)]) cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) more = next(button for button in cell.findChildren(QToolButton) if button.menu()) texts = {action.text() for action in more.menu().actions() if not action.isSeparator()} assert missing_text not in texts page.close() application.processEvents() def test_permission_crop_removes_unavailable_row_actions(application: QApplication) -> None: page = ConsultationsPage( SimpleNamespace(), permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]), ) page.table_host.set_rows([_row(720)]) cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 11)) visible = {button.text() for button in cell.findChildren(QToolButton) if not button.isHidden()} assert visible == {"查看"} assert not page.table_host.action_policy["video_qr"] assert not page.table_host.action_policy["confirm_qr"] assert not page.table_host.action_policy["appointment_logs"] assert not page.table_host.action_policy["create_order"] page.close() application.processEvents() def test_video_entry_is_hidden_without_the_native_repository_lifecycle( application: QApplication, ) -> None: page = _page() page.table_host.set_rows([_row(725)]) video_cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10)) assert video_cell.findChildren(QToolButton) == [] assert page.video_button.isHidden() page.close() application.processEvents() def test_error_state_is_persistent_until_rows_replace_it(application: QApplication) -> None: page = _page() page.show() application.processEvents() page.table_host.set_rows([]) page.table_host.show_error("列表加载失败,请重试") assert page.table_host.empty_label.isVisible() assert page.table_host.empty_label.property("stateKind") == "error" assert "加载失败" in page.table_host.empty_label.text() page.table_host.set_rows([_row(730)]) assert not page.table_host.empty_label.isVisible() assert page.table_host.empty_label.property("stateKind") == "empty" page.close() application.processEvents() @pytest.mark.parametrize( ("size", "minimum_visible_rows"), [((1366, 768), 3), ((1710, 920), 4)], ) def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table( application: QApplication, size: tuple[int, int], minimum_visible_rows: int, ) -> None: page = _page() rows = [ _row( 600 + index, patient_name=f"患者{index:02d}", latest_appointment_channel_text="健康顾问转介", ) for index in range(40) ] page.table_host.set_rows(rows) page.resize(*size) page.show() for _ in range(4): application.processEvents() viewport = page.table.viewport() visible_rows = sum( 1 for row in range(page.table_host.model.rowCount()) if ( (rect := page.table.visualRect(page.table_host.model.index(row, 0))).isValid() and rect.top() >= 0 and rect.bottom() < viewport.height() ) ) pager_top = page.pager.mapTo(page.page_scroll.viewport(), QPoint()).y() assert page.page_scroll.horizontalScrollBar().maximum() == 0 assert page.page_scroll.verticalScrollBar().maximum() == 0 assert pager_top >= 0 assert pager_top + page.pager.height() <= page.page_scroll.viewport().height() assert visible_rows >= minimum_visible_rows assert page.table.verticalScrollBar().maximum() > 0 assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right() assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right() page.close() application.processEvents() def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable( application: QApplication, monkeypatch: pytest.MonkeyPatch, immediate_async: None, ) -> None: page = _page() rows = [ _row( 800 + index, latest_appointment_channel_text="健康顾问转介", ) for index in range(40) ] page.table_host.set_rows(rows[:15]) page.resize(1366, 768) page.show() for _ in range(4): application.processEvents() host_height = page.table_host.height() calls: list[int] = [] def list_consultations(**query: Any) -> dict[str, Any]: calls.append(query["page_no"]) start = (query["page_no"] - 1) * query["page_size"] return {"lists": rows[start:start + query["page_size"]], "count": len(rows)} monkeypatch.setattr(page.repository, "list_consultations", list_consultations, raising=False) page.refresh(silent=True) for _ in range(2): page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum()) QTest.qWait(50) application.processEvents() for _ in range(4): application.processEvents() main_scroll = page.table.verticalScrollBar() fixed_scroll = page.table_host.fixed.verticalScrollBar() assert calls == [1, 2, 3] assert page.table.rowCount() == 40 assert page.table_host.height() == host_height assert main_scroll.maximum() == fixed_scroll.maximum() main_scroll.setValue(main_scroll.maximum() // 2) application.processEvents() assert fixed_scroll.value() == main_scroll.value() fixed_scroll.setValue(fixed_scroll.maximum() // 3) application.processEvents() assert main_scroll.value() == fixed_scroll.value() center_index = page.table.indexAt(page.table.viewport().rect().center()) assert center_index.isValid() main_top = page.table.visualRect(page.table_host.model.index(center_index.row(), 0)).top() fixed_top = page.table_host.fixed.visualRect( page.table_host.model.index(center_index.row(), 10) ).top() assert main_top == fixed_top page.close() application.processEvents() def test_required_reference_artifacts_exist() -> None: root = Path(__file__).resolve().parents[1] expected = { root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640), root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900), root / "artifacts" / "diagnosis_visual" / "diagnosis_1366x768.png": (1366, 768), root / "artifacts" / "diagnosis_visual" / "diagnosis_1710x920.png": (1710, 920), root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_empty_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_error_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_hover_warning_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_focus_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_permissions_cropped_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_horizontal_scroll_1024x640.png": ( 1024, 640, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_pending_assign_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_advanced_filters_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_full_menu_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_order_qrcode_1280x800.png": ( 1280, 800, ), root / "artifacts" / "diagnosis_visual" / "diagnosis_double_appointment_cancel_1280x800.png": ( 1280, 800, ), } for path, dimensions in expected.items(): assert path.is_file(), f"run scripts/render_diagnosis_visual.py to create {path.name}" image = QImage(str(path)) assert not image.isNull() assert (image.width(), image.height()) == dimensions