900 lines
33 KiB
Python
900 lines
33 KiB
Python
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
|