This commit is contained in:
Your Name
2026-09-07 10:07:47 +08:00
parent cf3fbdc5ef
commit d5164b7369
388 changed files with 19863 additions and 18720 deletions
+10 -5
View File
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
host, bubble = _fitted_bubble(reply)
assert bubble.height() > 0
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
# Wrapping labels can legitimately have a different preferred height at
# their preferred width. Compare against the actual reading-column width.
fitted_height = bubble.heightForWidth(bubble.width())
expected_height = fitted_height if fitted_height >= 0 else bubble.sizeHint().height()
assert abs(expected_height - bubble.height()) <= 2
host.close()
host.deleteLater()
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
assert "#C0392B" in marker
assert "#BE4B58" in marker
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
assert len(body) == 2 or "font-size: 13px" in qss
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
assert "color: #1a1c1f" in body.lower()
assert "font-size: 14px" in body
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
assert "font-size: 13px" in block
assert "font-size: 14px" in block
assert "font-size: 11px" not in block
+1 -1
View File
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
+18 -9
View File
@@ -10,6 +10,8 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QElapsedTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
page.refresh()
application.processEvents()
completed = QElapsedTimer()
completed.start()
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
QTest.qWait(10)
filters = page._query_filters()
assert filters["status"] == 1
assert filters["include_status_counts"] == 1
assert filters["start_date"] == filters["end_date"]
assert "diag_scope_relax" not in filters
assert page.table.rowCount() >= 1
page.close()
application.processEvents()
def test_demo_appointment_status_counts_respect_date_scope() -> None:
@@ -464,8 +473,7 @@ def test_appointment_multiline_cells_receive_enough_row_height(
assert appointment_text.count("\n") == 2
assert "2026-08-11 14:30" in appointment_text
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert 60 <= page.table.rowHeight(0) <= 66
assert page.table.rowHeight(0) >= min(required, 66)
assert page.table.rowHeight(0) >= required
assert page.table.item(0, 4).toolTip() == appointment_text
page.close()
@@ -854,9 +862,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
permissions=PermissionSet(["*"]),
current_user={"id": 1001, "role_id": 1},
)
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
# and 62 px top bar leaves a 1161x680 page viewport.
page.resize(1161, 680)
# Approved shared chrome: 208 px rail, 76 px topbar, no outer gutter.
page.resize(1158, 692)
page.show()
application.processEvents()
rows = [
@@ -886,10 +893,12 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
application.processEvents()
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
assert page.header.height() == 62
assert page.filter_panel.height() <= 84
assert all(60 <= height <= 66 for height in heights)
# Compact title and folded filters leave more room for the patient queue.
assert page.header.height() >= page.header.minimumSizeHint().height()
assert page.header.height() <= 44
assert page.filter_panel.isHidden()
assert all(60 <= height <= 84 for height in heights)
assert all(page.table.cellWidget(row, 4).height() >= page.table.cellWidget(row, 4).minimumSizeHint().height() for row in range(page.table.rowCount()))
assert page.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page)
assert page.content_layout.count() == 1
+5 -3
View File
@@ -365,9 +365,11 @@ def test_refresh_generation_ignores_late_results(
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
page.refresh(silent=True)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
# Identical in-flight requests are deduplicated; a new query supersedes one.
page.keyword_edit.setText("新患者")
page.refresh(silent=True)
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
+3 -3
View File
@@ -986,14 +986,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#f0f2ff"
assert enabled_color.name().lower() == "#eef1fa"
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#f0f2ff"
assert disabled_color.name().lower() == "#eef1fa"
dialog.close()
application.processEvents()
@@ -1013,7 +1013,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#f0f2ff"
assert color.name().lower() == "#eef1fa"
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
+132 -32
View File
@@ -10,11 +10,14 @@ 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,
)
@@ -161,27 +164,44 @@ 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()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
assert content_layout.spacing() == 8
# 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.height() == 62
assert status_card.height() == 50
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.height() == 90
assert page.keyword_edit.maximumWidth() == 380
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 page.more_filter_button.arrowType() == Qt.ArrowType.DownArrow
assert _caret_matches(page.more_filter_button, "down")
assert [page._date_button_labels[key] for key in page.date_buttons] == [
"昨天挂号",
"前天挂号",
@@ -228,7 +248,7 @@ def test_pending_assign_and_secondary_chip_semantics(
page._toggle_advanced_filters(True)
assert not page.advanced_filters.isHidden()
assert page.more_filter_button.text() == "收起"
assert page.more_filter_button.arrowType() == Qt.ArrowType.UpArrow
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
@@ -237,15 +257,81 @@ def test_pending_assign_and_secondary_chip_semantics(
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, 60, 100, 175, 88, 120, 100, 72, 110)
assert page.table_host.FIXED_WIDTHS == (120, 410)
assert page.table_host.fixed.width() == 532
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)
@@ -267,6 +353,9 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
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"
@@ -300,7 +389,7 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
application.processEvents()
def test_empty_loading_and_full_pager_keep_the_table_shell(
def test_empty_loading_and_compact_footer_keep_the_table_shell(
application: QApplication,
) -> None:
page = _page()
@@ -321,13 +410,13 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
page.loading_overlay.stop()
page.pager.update_state(3, 97)
assert 40 <= page.pager.height() <= 44
assert page.pager.height() == 24
pager_margins = page.pager.layout().contentsMargins()
assert pager_margins.top() >= 4
assert pager_margins.bottom() >= 4
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
assert page.pager.jumper.maximum() == 7
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()
@@ -335,11 +424,11 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
@pytest.mark.parametrize(
("record", "stripe", "channel"),
[
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#9a6813", "warning"),
(_row(702, has_appointment=0, appointments=[]), "#2f6edb", "info"),
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
],
)
def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
application: QApplication,
record: dict[str, Any],
stripe: str,
@@ -363,8 +452,9 @@ def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
assert image.pixelColor(0, 20).name() == stripe
assert image.pixelColor(1, 20).name() == stripe
assert image.pixelColor(2, 20).name() == stripe
gradient = image.pixelColor(6, 20)
assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row"
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(
@@ -378,9 +468,7 @@ def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
assert shadow.isHidden()
assert shadow.width() == 12
assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1
assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in (
DIAGNOSIS_INDEX_QSS
)
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 (
@@ -491,7 +579,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
min(danger_image.width(), danger_rect.right() + 1),
):
color = danger_image.pixelColor(x, y)
if color.red() > 190 and color.green() < 150 and color.blue() < 150:
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()
@@ -642,7 +730,7 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 4), ((1710, 920), 7)],
[((1366, 768), 3), ((1710, 920), 4)],
)
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
application: QApplication,
@@ -686,9 +774,10 @@ def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
application.processEvents()
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
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 = [
@@ -704,15 +793,26 @@ def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable
for _ in range(4):
application.processEvents()
host_height = page.table_host.height()
calls: list[int] = []
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
page._change_page_size(40)
page.table_host.set_rows(rows)
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)
+49 -37
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, QPoint
from PySide6.QtCore import QDate, QPoint, QRect
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet
@@ -175,6 +175,8 @@ def test_patient_refresh_generation_ignores_late_results(
monkeypatch.setattr(patients_module, "run_async", queue_async)
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
workspace.refresh()
# Identical in-flight queries coalesce; a changed query starts a new generation.
workspace.keyword_edit.setText("新结果")
workspace.refresh()
newer = {
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
@@ -621,62 +623,72 @@ def test_patient_list_reference_geometry_and_row_actions(
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
# and 62 px top bar leaves a 1170x680 page viewport.
page.resize(1170, 680)
page.filter_disclosure.set_expanded(True)
# Patient pages use the approved 208 px rail and 76 px top bar.
page.resize(1536 - 208, 960 - 76)
page.show()
application.processEvents()
page.patient_workspace.refresh()
application.processEvents()
workspace = page.patient_workspace
assert page.header.height() == 62
assert workspace.filter_card.height() <= 92
assert all(
button.minimumHeight() == 44 and button.maximumHeight() == 44
for button in workspace.summary_buttons.values()
)
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost"
assert (
workspace.keyword_edit.maximumWidth(),
workspace.status_host.maximumWidth(),
workspace.quick_host.maximumWidth(),
workspace.date_host.maximumWidth(),
) == (620, 440, 620, 420)
assert workspace.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514):
page.resize(width, 680)
application.processEvents()
for widget in (
workspace.keyword_edit,
workspace.status_host,
workspace.search_button,
workspace.reset_button,
workspace.quick_host,
workspace.date_host,
workspace.custom_date_button,
assert workspace.table.rowCount() == 4
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
assert workspace.table.horizontalScrollBar().maximum() == 0
assert workspace.pager.summary_label.text() == "共 4 条 · 已全部加载"
assert workspace.pager.height() == 24
assert workspace.pager.page_size == 15
assert workspace.pager.page == 1
assert not workspace.pager.has_more
for width, height in ((1536, 960), (1366, 768), (1024, 640)):
page.resize(width - 208, height - 76)
for _ in range(3):
application.processEvents()
assert page.header.height() >= page.header.minimumSizeHint().height()
for host, widgets in (
(workspace.search_toolbar, (workspace.keyword_edit, workspace.search_button, workspace.reset_button)),
(workspace.filter_card, (workspace.status_host, workspace.quick_host, workspace.date_host, workspace.custom_date_button)),
(workspace.status_host, tuple(workspace.status_buttons.values())),
(workspace.quick_host, tuple(workspace.quick_buttons.values())),
(workspace.date_host, (workspace.start_date, workspace.end_date)),
(workspace.summary_strip, tuple(workspace.summary_buttons.values())),
):
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
assert top_left.x() >= 0
assert top_left.x() + widget.width() <= workspace.filter_card.width()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
for widget in widgets:
assert widget.isVisibleTo(page)
assert host.rect().contains(QRect(widget.mapTo(host, QPoint()), widget.size())), widget.objectName()
for host in (workspace.search_toolbar, workspace.filter_card, workspace.summary_strip, workspace.pager):
if workspace.content.isAncestorOf(host):
workspace.scroll.ensureWidgetVisible(host, 0, 0)
application.processEvents()
assert workspace.scroll.viewport().rect().contains(QRect(host.mapTo(workspace.scroll.viewport(), QPoint()), host.size())), host.objectName()
else:
assert page.rect().contains(QRect(host.mapTo(page, QPoint()), host.size())), host.objectName()
assert workspace.pager.isVisibleTo(page)
workspace.table.scrollToBottom()
application.processEvents()
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
assert page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0
assert workspace.bottom_actions.isHidden()
assert workspace.table.viewport().height() // 36 >= 6
assert workspace.pager.isVisibleTo(page)
assert workspace.table.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
if workspace.table.rowCount():
assert workspace.table.rowHeight(0) == 36
assert workspace.table.cellWidget(0, 0) is not None
assert workspace.table.cellWidget(0, 9) is not None
assert workspace.table.horizontalHeader().visualIndex(9) == 9
assert all(not workspace.table.isColumnHidden(column) for column in range(10))
actions = workspace.table.cellWidget(0, 9)
assert workspace.table.rowHeight(0) >= max(40, actions.minimumSizeHint().height() + 1)
for button in [*actions.buttons, actions.more_button]:
if button is not None:
assert actions.rect().contains(button.geometry())
assert workspace.table.cellWidget(0, 0) is not None
assert workspace.table.cellWidget(0, 9) is not None
page.close()
application.processEvents()
+275 -265
View File
@@ -1,268 +1,278 @@
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 QPoint
from PySide6.QtGui import QImage
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QFrame,
QPushButton,
QWidget,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
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, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class DensityRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
repository = DensityRepository()
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
permissions = {"*"}
if kind == "issued":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, permissions, user
)
else:
page = PrescriptionLibraryPage(repository, permissions, user)
page.refresh()
return page
def _settle(application: QApplication) -> None:
for _ in range(5):
application.processEvents()
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
application: QApplication,
) -> None:
pager = BusinessPager(15)
pager.update_state(2, 44)
pager.show()
_settle(application)
assert prescriptions_module.BusinessPager is BusinessPager
assert 40 <= pager.height() <= 44
assert pager.minimumHeight() == pager.maximumHeight() == 42
assert pager.findChildren(QComboBox) == []
assert pager.page_size_label.text() == "15 条/页"
margins = pager.layout().contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
assert pager.page_label is not None and pager.page_label.text() == "2"
pager.close()
@pytest.mark.parametrize("kind", ["issued", "library"])
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 6), ((1710, 920), 9)],
)
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
application: QApplication,
kind: str,
size: tuple[int, int],
minimum_visible_rows: int,
) -> None:
page = _new_page(kind)
page.resize(*size)
page.show()
_settle(application)
header = page.findChild(QWidget, "PageHeader")
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
toolbar = page.findChild(QFrame, toolbar_name)
assert header is not None and 60 <= header.height() <= 64
assert toolbar is not None and 44 <= toolbar.height() <= 48
assert 40 <= page.pager.height() <= 44
assert page.pager.minimumHeight() == page.pager.maximumHeight()
assert page.table.minimumHeight() == 0
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert _fully_visible_rows(page) >= minimum_visible_rows
pager_position = page.pager.mapTo(page, QPoint())
assert pager_position.x() >= 0
assert pager_position.x() + page.pager.width() <= page.width()
assert pager_position.y() >= 0
assert pager_position.y() + page.pager.height() <= page.height()
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
page.pager.page_size_label.width()
)
assert page_size_right <= page.width()
pager_margins = page.pager.layout().contentsMargins()
toolbar_margins = toolbar.layout().contentsMargins()
assert pager_margins.left() == toolbar_margins.left() == 16
assert pager_margins.right() == toolbar_margins.right() == 16
if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and 84 <= filters.height() <= 92
actions_host = page.table.cellWidget(0, 2)
assert actions_host is not None
row_edit = next(
button
for button in actions_host.findChildren(QPushButton)
if button.accessibleName() == "编辑处方"
)
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
edit_bottom_right = row_edit.mapTo(
page.table.viewport(), row_edit.rect().bottomRight()
)
assert row_edit.text() == "编辑"
assert page.table.viewport().rect().contains(edit_top_left)
assert page.table.viewport().rect().contains(edit_bottom_right)
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 QPoint
from PySide6.QtGui import QImage
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QFrame,
QPushButton,
QWidget,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
from doctor_workstation.ui.widgets import BusinessPager
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
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, **kwargs)
except Exception as error:
if on_error is not None:
on_error(error)
else:
if on_success is not None:
on_success(result)
finally:
if on_finished is not None:
on_finished()
return object()
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
def _issued_row(index: int) -> dict[str, Any]:
return {
"id": 1000 + index,
"sn": f"CF-202608-{1000 + index}",
"prescription_type": "汤剂",
"is_system_auto": index % 2,
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
"gender": 2 if index % 2 else 1,
"age": 29 + index,
"audit_status": index % 3,
"void_status": 0,
"has_prescription_order": index % 2,
"creator_id": 7,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
def _library_row(index: int) -> dict[str, Any]:
return {
"id": 2000 + index,
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
"formula_type": "主方" if index % 3 else "辅方",
"herbs": [
{"name": "黄芪", "dosage": 15},
{"name": "党参", "dosage": 12},
],
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
"is_public": index % 2,
"disable_edit": 0,
"creator_id": 7,
"creator_name": "陈医生",
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
}
class DensityRepository:
def __init__(self) -> None:
self.issued_rows = [_issued_row(index) for index in range(15)]
self.library_rows = [_library_row(index) for index in range(15)]
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.issued_rows, "count": 44}
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {"lists": self.library_rows, "count": 41}
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
repository = DensityRepository()
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
permissions = {"*"}
if kind == "issued":
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
repository, permissions, user
)
else:
page = PrescriptionLibraryPage(repository, permissions, user)
page.refresh()
return page
def _settle(application: QApplication) -> None:
for _ in range(5):
application.processEvents()
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
viewport = page.table.viewport()
return sum(
1
for row in range(page.table.rowCount())
if (
(item := page.table.item(row, 0)) is not None
and (rect := page.table.visualItemRect(item)).isValid()
and rect.top() >= 0
and rect.bottom() < viewport.height()
)
)
def test_infinite_footer_is_shared_compact_and_has_no_page_controls(application):
from doctor_workstation.ui.infinite_list import InfiniteList
footer = InfiniteList(15)
footer.show()
_settle(application)
assert prescriptions_module.InfiniteList is InfiniteList
assert footer.height() == 24
assert footer.findChildren(QComboBox) == []
assert not hasattr(footer, "page_size_label")
assert not hasattr(footer, "next")
assert footer.layout().contentsMargins().bottom() == 0
footer.close()
@pytest.mark.parametrize("kind", ["issued", "library"])
@pytest.mark.parametrize(
("size", "minimum_visible_rows"),
[((1366, 768), 6), ((1710, 920), 9)],
)
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
application: QApplication,
kind: str,
size: tuple[int, int],
minimum_visible_rows: int,
) -> None:
page = _new_page(kind)
page.resize(*size)
page.show()
_settle(application)
header = page.findChild(QWidget, "PageHeader")
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
toolbar = page.findChild(QFrame, toolbar_name)
assert header is not None and header.height() >= header.minimumSizeHint().height()
assert toolbar is not None
if kind == "issued":
assert toolbar.height() == 64
assert page.pager.height() == 24
# Approved two-line rows retain complete 14 px text and warning content.
minimum_visible_rows = 3 if size[1] == 768 else 5
else:
# The approved library design adds the four metric cards and readable
# multiline herb/date rows. Keep meaningful row and pager reachability.
assert 60 <= toolbar.height() <= 70
assert page.pager.height() == 24
minimum_visible_rows = 2 if size[1] == 768 else 4
assert page.pager.minimumHeight() == page.pager.maximumHeight()
assert page.table.minimumHeight() == 0
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
assert _fully_visible_rows(page) >= minimum_visible_rows
pager_position = page.pager.mapTo(page, QPoint())
assert pager_position.x() >= 0
assert pager_position.x() + page.pager.width() <= page.width()
assert pager_position.y() >= 0
assert pager_position.y() + page.pager.height() <= page.height()
page_size_right = page.pager.summary_label.mapTo(page, QPoint()).x() + (
page.pager.summary_label.width()
)
assert page_size_right <= page.width()
pager_margins = page.pager.layout().contentsMargins()
toolbar_margins = toolbar.layout().contentsMargins()
expected_inset = 16 if kind == "issued" else 18
assert pager_margins.left() == 12
assert toolbar_margins.left() == expected_inset
assert pager_margins.right() == 12
assert toolbar_margins.right() == expected_inset
if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and filters.height() == 144
actions_host = page.table.cellWidget(0, 2)
assert actions_host is not None
row_edit = next(
button
for button in actions_host.findChildren(QPushButton)
if button.accessibleName() == "编辑处方"
)
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
edit_bottom_right = row_edit.mapTo(
page.table.viewport(), row_edit.rect().bottomRight()
)
assert row_edit.text() == "编辑"
assert page.table.viewport().rect().contains(edit_top_left)
assert page.table.viewport().rect().contains(edit_bottom_right)
else:
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
assert filters is not None
assert page.name_filter.minimumWidth() < 500
filter_right = filters.contentsRect().right()
for control in (
page.name_filter,
page.formula_filter,
page.visibility_filter,
page.effect_filter,
page.query_button,
page.reset_button,
):
right = control.mapTo(filters, QPoint()).x() + control.width()
assert right <= filter_right
page.close()
_settle(application)
def test_density_reference_artifacts_exist() -> None:
root = Path(__file__).resolve().parents[1]
expected = {
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
1366,
768,
),
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
1710,
920,
),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1366x768.png": (1366, 768),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1710x920.png": (1710, 920),
}
for path, dimensions in expected.items():
image = QImage(str(path))
assert not image.isNull(), path
assert (image.width(), image.height()) == dimensions
page.filter_disclosure.set_expanded(True)
_settle(application)
assert page.name_filter.minimumWidth() < 500
filter_right = filters.contentsRect().right()
for control in (
page.name_filter,
page.formula_filter,
page.visibility_filter,
page.effect_filter,
page.query_button,
page.reset_button,
):
right = control.mapTo(filters, QPoint()).x() + control.width()
assert right <= filter_right
page.close()
_settle(application)
def test_density_reference_artifacts_exist() -> None:
root = Path(__file__).resolve().parents[1]
expected = {
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
1366,
768,
),
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
1710,
920,
),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1366x768.png": (1366, 768),
root
/ "artifacts"
/ "prescription_list_density"
/ "prescription_library_1710x920.png": (1710, 920),
}
for path, dimensions in expected.items():
image = QImage(str(path))
assert not image.isNull(), path
assert (image.width(), image.height()) == dimensions
+1 -1
View File
@@ -140,7 +140,7 @@ def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
assert blank_height > normal_height
assert duplicate_height > normal_height
assert page.table.columnWidth(1) >= 250
assert page.table.columnWidth(1) == 192
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
red_pixels = 0
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -200,7 +200,7 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
"disable_edit": 1,
"creator_id": 9,
"creator_name": "张医生",
"create_time": "2026-08-10 12:00:00",
"create_time": QDate.currentDate().toString("yyyy-MM-dd") + " 12:00:00",
}
],
"count": 1,
@@ -237,8 +237,9 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
"创建时间",
"操作",
]
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 6) is not None
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制,只有操作列仍是真实控件。
assert page.table.item(0, 2).data(prescription_module._ROLE_TAG_KIND) == "accent"
assert page.table.item(0, 6).data(prescription_module._ROLE_LEAD_ICON) == "lock"
assert page.table.cellWidget(0, 9) is not None
assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
@@ -305,8 +306,10 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
assert page.table.columnCount() == 11
assert page.table.horizontalHeaderItem(2).text() == "操作"
assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 3) is not None
assert page.table.cellWidget(0, 6) is not None
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
# 各挂一个 QWidget;这里改为断言驱动绘制的角色数据仍然写入。
assert page.table.item(0, 3).data(prescription_module._ROLE_TAG_KIND) == "accent"
assert page.table.item(0, 6).data(prescription_module._ROLE_TAG_KIND)
row_edit = next(
button
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
@@ -59,7 +59,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
clicked: list[bool] = []
button.clicked.connect(lambda: clicked.append(True))
assert surface_color(button) == "#fff7f8"
assert surface_color(button) == "#ffffff"
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
QTest.mouseMove(button, button.rect().center())
@@ -81,7 +81,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
button.clearFocus()
app.processEvents()
assert clicked == []
assert surface_color(button) == "#fff7f8"
assert surface_color(button) == "#ffffff"
assert button.geometry() == geometry
assert neighbor.geometry() == neighbor_geometry
@@ -93,14 +93,14 @@ def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
button.setEnabled(False)
QTest.mouseMove(button, button.rect().center())
app.processEvents()
assert surface_color(button) == "#f7f8fb"
assert surface_color(button) == "#f7fafe"
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
assert clicked == []
button.setEnabled(True)
app.processEvents()
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
assert surface_color(button) != "#f7f8fb"
assert surface_color(button) != "#f7f7f7"
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
@@ -112,11 +112,11 @@ def test_completion_keyboard_focus_is_visible_without_resizing(controls):
button.setFocus(Qt.FocusReason.TabFocusReason)
app.processEvents()
assert button.hasFocus()
assert surface_color(button, border=True) == "#cf4656"
assert surface_color(button, border=True) == "#be4b58"
assert surface_color(button, border=True) != border_before
assert button.geometry() == geometry
assert neighbor.geometry() == neighbor_geometry
QTest.mouseMove(button, button.rect().center())
app.processEvents()
assert surface_color(button) == "#fff0f2"
assert surface_color(button, border=True) == "#cf4656"
assert surface_color(button, border=True) == "#be4b58"
+58 -11
View File
@@ -51,6 +51,52 @@ def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("width", [1114, 1320, 1494])
def test_visible_history_updates_grow_clinical_card_without_clipping(
application: QApplication,
queued_async: list[dict[str, Any]],
width: int,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(["*"]))
page.resize(width, 824)
page.show()
try:
application.processEvents()
page.detail_stack.setCurrentIndex(1)
for _ in range(8):
application.processEvents()
label = page.case_labels["present"]
clinical = page.clinical_info_group
initial_height = clinical.height()
initial_scroll_maximum = page.detail_scroll.verticalScrollBar().maximum()
history = (
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
)
label.setText(history)
application.processEvents()
assert label.text() == history
assert label.height() >= label.heightForWidth(label.width())
label.setText("\n".join([history] * 5))
application.processEvents()
assert label.height() >= label.heightForWidth(label.width())
assert clinical.height() > initial_height
assert page.detail_scroll.verticalScrollBar().maximum() > initial_scroll_maximum
assert label.maximumHeight() > label.height()
expanded_height = clinical.height()
label.setText("无特殊不适。")
application.processEvents()
assert label.height() >= label.heightForWidth(label.width())
assert clinical.height() < expanded_height
finally:
page.close()
page.deleteLater()
application.processEvents()
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
@@ -622,6 +668,7 @@ def test_queue_date_picker_filters_the_selected_day(
page = ReceptionPage(Repository(), PermissionSet([]))
page.show()
application.processEvents()
page.filter_disclosure.button.click()
page.queue_date_button.click()
calendar = page.queue_date_button.calendarWidget()
assert calendar.isVisible()
@@ -1036,14 +1083,14 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
assert title is not None and title.font().pixelSize() == 18
assert caption is not None and caption.font().pixelSize() == 12
assert caption is not None and caption.font().pixelSize() == 13
assert value is not None and value.font().pixelSize() == 14
assert title.font().weight() >= 700
assert caption.font().weight() >= 600
assert value.font().weight() >= 700
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#17264d"
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#617092"
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#253a83"
assert title.font().weight() == 600
assert caption.font().weight() == 400
assert value.font().weight() == 600
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert value.wordWrap()
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
@@ -1135,7 +1182,7 @@ def test_queue_load_more_accumulates_to_total_boundary(
assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
assert page.queue_summary.text() == "共 22 位患者"
assert not page._queue_has_more()
page.close()
application.processEvents()
@@ -1466,11 +1513,11 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
buttons = [
button
for button in (
page.complete_button,
page.notify_button,
page.history_button,
page.video_button,
page.more_button,
page.complete_button,
)
if button.isVisibleTo(page)
]
@@ -1688,8 +1735,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
assert page.ai_analysis_card.maximumHeight() > 520
assert page.ai_analysis_card.findChildren(QScrollArea) == []
assert left.height() == right.height()
assert 0 <= right.left() - left.right() - 1 <= 2
assert abs(left.width() * 5 - right.width() * 4) <= 10
assert right.left() - left.right() - 1 == 10
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48
+5 -3
View File
@@ -400,9 +400,11 @@ def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application)
page.poll_timer.stop()
application.processEvents()
assert page.refresh_button.isVisible()
assert page.refresh_button.text() == "刷新"
assert page.refresh_button.width() >= 50
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
assert not page.refresh_button.icon().isNull()
assert page.refresh_button.accessibleName() == "刷新接诊台"
assert page.refresh_button.width() >= 34
assert page.refresh_button.geometry().right() < page.filter_disclosure.button.geometry().left()
assert not page.queue_date_button.isVisible()
page.note_edit.setFocus()
application.processEvents()
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
+168 -32
View File
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidge
from doctor_workstation.ui import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
from doctor_workstation.ui.theme import apply_theme
from doctor_workstation.ui.theme import COLORS, apply_theme
def _logical_pixel(image: Any, x: int, y: int):
@@ -134,7 +134,8 @@ def shell_window(
),
("prescriptions", "已开处方", "", "tcm.prescription/lists"),
("patients", "我的患者", "", "firstvisit.myPatient/lists"),
("consultations", "问诊列表", "", "tcm.diagnosis/lists"),
("consultations", "问诊列表", "", "tcm.diagnosis/lists"),
("legacy_reference", "原版框架参照", "", "legacy.reference/lists"),
)
]
monkeypatch.setattr(
@@ -160,38 +161,40 @@ def shell_window(
application.processEvents()
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
def test_legacy_shell_matches_reference_geometry_at_both_acceptance_sizes(
application: QApplication,
shell_window: ShellWindow,
) -> None:
assert shell_window.navigate("legacy_reference")
for width, height in ((1024, 640), (1440, 900)):
shell_window.resize(width, height)
application.processEvents()
assert shell_window.sidebar.width() == 179
# 独立legacy参照路由继续验证原有导轨和外圈留白。
assert shell_window.sidebar.width() == 190
assert shell_window.topbar.height() == 62
assert shell_window.tabs_host.height() == 0
assert shell_window.workspace.width() == width - 26 - 179
assert shell_window.stack.width() == width - 26 - 179
assert shell_window.workspace.width() == width - 26 - 190
assert shell_window.stack.width() == width - 26 - 190
assert shell_window.stack.height() == height - 26 - 62
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert _logical_pixel(image, 20, 300).name().lower() in {
"#f2f5fd",
"#f3f6fd",
"#f2f6fe",
"#f3f6fe",
}
# 侧边栏不再是画布上的一块面板:它就是画布本身,所以导轨内任意一点
# 都必须与外圈留白同色。原先它是 #F4F7FE→#EEF3FD 的斜向渐变,
# 沿整条左边缘都对不上画布,形成一道常驻接缝。
assert _logical_pixel(image, 20, 300).name().lower() == COLORS["canvas"].lower()
assert _logical_pixel(image, 6, 300).name().lower() == COLORS["canvas"].lower()
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
assert _logical_pixel(image, 220, 90).name().lower() == COLORS["canvas_mid"].lower()
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
def test_legacy_topbar_search_actions_and_navigation_controls_stay_aligned(
application: QApplication,
shell_window: ShellWindow,
) -> None:
assert shell_window.navigate("legacy_reference")
for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height)
application.processEvents()
@@ -243,6 +246,7 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
shell_window: ShellWindow,
) -> None:
assert shell_window.navigate("legacy_reference")
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
assert (
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
@@ -405,15 +409,17 @@ def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
]
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
def test_shell_ai_menu_on_approved_pages_opens_global_patient_picker(
application: QApplication,
shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch,
key: str,
) -> None:
reception = shell_window.pages["reception"]
assert isinstance(reception, _ShellPageDouble)
assert shell_window.navigate("reception")
reception.ai_context_available = True
page = shell_window.pages[key]
assert isinstance(page, _ShellPageDouble)
assert shell_window.navigate(key)
page.ai_context_available = True
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
monkeypatch.setattr(
shell_module,
@@ -421,25 +427,34 @@ def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
)
shell_window.ai_top_button.click()
assert shell_window.menu_ai_action.isVisible()
shell_window.menu_ai_action.trigger()
application.processEvents()
assert reception.ai_open_count == 0
assert page.ai_open_count == 0
assert len(opened) == 1
assert shell_window.stack.currentWidget() is reception
assert shell_window.stack.currentWidget() is page
@pytest.mark.parametrize(
("key", "permission"),
[("appointments", "doctor.appointment/lists"), ("consultations", "tcm.diagnosis/lists"),
("patients", "firstvisit.myPatient/lists"), ("prescriptions", "tcm.prescription/lists"),
("prescription_library", "tcm.prescriptionLibrary/lists")],
)
def test_shell_hides_global_ai_entries_without_ai_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
key: str,
permission: str,
) -> None:
navigation = [
NavigationItem(
"appointments",
key,
"问诊列表",
"",
_ShellPageDouble,
("doctor.appointment/lists",),
(permission,),
)
]
monkeypatch.setattr(
@@ -452,13 +467,19 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
window = ShellWindow(
object(),
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
permissions={"doctor.appointment/lists"},
permissions={permission},
)
window.show()
application.processEvents()
assert window.assistant_card.isHidden()
assert window.ai_top_button.isHidden()
assert not window.menu_ai_action.isVisible()
assert window.menu_sidebar_action.isVisible()
assert window._active_page_key == key
assert window.sidebar.width() == 208
assert window.topbar.height() == 76
assert window.centralWidget().layout().contentsMargins().isNull()
window.close()
application.processEvents()
@@ -640,20 +661,135 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
def test_sidebar_collapse_preserves_active_navigation(
def test_topbar_refresh_button_reloads_whichever_page_is_open(
application: QApplication,
shell_window: ShellWindow,
) -> None:
assert shell_window.navigate("consultations")
"""The button existed in the code but was never added to the layout.
It also matters more than it used to: list loads no longer raise a banner,
so this is the only control that acknowledges a manual reload.
"""
button = shell_window.refresh_button
assert button.isVisible()
assert button.parentWidget() is shell_window.topbar
for key in ("consultations", "patients"):
assert shell_window.navigate(key)
application.processEvents()
page = shell_window.pages[key]
before = page.refresh_count
button.click()
application.processEvents()
assert page.refresh_count == before + 1
def test_topbar_refresh_button_spins_while_the_page_loads(
application: QApplication,
shell_window: ShellWindow,
) -> None:
"""A spin that cannot stop is worse than no spin at all."""
button = shell_window.refresh_button
resting = button.icon().cacheKey()
button.click()
application.processEvents()
assert button._timer.isActive()
# The page double never reports itself busy, so the spin ends as soon as the
# minimum has elapsed rather than running for the full cap.
button._elapsed = _ElapsedStub(button._MIN_MS + 1)
button._tick()
assert not button._timer.isActive()
assert button.icon().cacheKey() == resting
class _ElapsedStub:
def __init__(self, value: int) -> None:
self._value = value
def elapsed(self) -> int:
return self._value
def restart(self) -> None:
return None
def test_legacy_window_ground_carries_the_only_corner(
application: QApplication,
shell_window: ShellWindow,
) -> None:
"""The bottom-most layer is the one that rounds; nothing nests inside it.
The rail used to paint its own 16 px corner on top of a square canvas, so
each window corner showed a corner inside a corner.
"""
assert shell_window.navigate("legacy_reference")
application.processEvents()
image = shell_window.grab().toImage()
# Outside the ground's corner there is nothing at all ...
assert _logical_pixel(image, 2, 2).alpha() == 0
# ... and well inside it the ground is the flat canvas colour, both in the
# outer gutter and inside the rail.
assert _logical_pixel(image, 8, 200).name().lower() == COLORS["canvas"].lower()
assert _logical_pixel(image, 60, 200).name().lower() == COLORS["canvas"].lower()
def test_approved_pages_share_shell_geometry_and_other_pages_restore(
application: QApplication,
shell_window: ShellWindow,
) -> None:
"""The six approved pages share chrome; each remaining page keeps its geometry."""
geometries = set()
for key in [*shell_window.pages, "reception", "consultations", "appointments", "patients", "prescriptions", "prescription_library"]:
assert shell_window.navigate(key)
application.processEvents()
geometry = (
shell_window.sidebar.width(),
shell_window.workspace.x(),
shell_window.workspace.width(),
shell_window.stack.width(),
)
if key in {"appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"}:
assert geometry == (208, 208, shell_window.width() - 208, shell_window.width() - 208)
assert shell_window.topbar.height() == 76
assert shell_window.workspace.y() == 0
else:
assert geometry == (190, 203, shell_window.width() - 216, shell_window.width() - 216)
assert shell_window.topbar.height() == 62
assert shell_window.workspace.y() == 13
geometries.add(geometry)
assert len(geometries) == 1, f"navigation moved the shell: {geometries}"
@pytest.mark.parametrize(
("key", "expanded_width"),
[("appointments", 208), ("consultations", 208), ("reception", 208), ("patients", 208), ("prescriptions", 208), ("prescription_library", 208), ("legacy_reference", 190)],
)
def test_sidebar_collapse_preserves_active_navigation(
shell_window: ShellWindow,
key: str,
expanded_width: int,
) -> None:
assert shell_window.navigate(key)
title = shell_window.nav_buttons[key].text()
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 68
assert shell_window.nav_buttons["consultations"].text() == ""
assert shell_window.nav_buttons["consultations"].isChecked()
assert shell_window.nav_buttons[key].text() == ""
assert shell_window.nav_buttons[key].isChecked()
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 195
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
assert shell_window.nav_buttons["consultations"].isChecked()
assert shell_window.sidebar.width() == expanded_width
assert shell_window.nav_buttons[key].text() == title
assert shell_window.nav_buttons[key].isChecked()
def test_shell_directional_controls_have_no_unicode_arrow_text(
+23 -2
View File
@@ -4,8 +4,16 @@ from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QCoreApplication, QSettings
from PySide6.QtGui import QPageSize, QRawFont
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
from PySide6.QtGui import QFont, QPageSize, QRawFont
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
@@ -54,12 +62,25 @@ def test_theme_resolves_real_chinese_glyphs() -> None:
application = QApplication.instance() or QApplication([])
apply_theme(application)
raw_font = QRawFont.fromFont(application.font())
assert raw_font.familyName() == "Noto Sans SC"
assert not application.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
assert application.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
assert glyphs
assert all(glyph > 0 for glyph in glyphs)
assert len(set(glyphs)) > 1
# QSS must not override the resolved platform face on real controls.
for widget in (QLabel("甄养堂医生工作站"), QPushButton("确认接诊")):
widget.ensurePolished()
resolved = QRawFont.fromFont(widget.font())
assert resolved.familyName() == raw_font.familyName()
assert all(glyph > 0 for glyph in resolved.glyphIndexesForString(widget.text()))
assert not widget.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
assert widget.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
widget.close()
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
application = QApplication.instance() or QApplication([])