271 lines
12 KiB
Python
271 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import pytest
|
|
from PySide6.QtCore import QPoint, Qt
|
|
from PySide6.QtGui import QFont
|
|
from PySide6.QtTest import QTest
|
|
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
|
|
|
from doctor_workstation.ui.diagnosis_index_widgets import (
|
|
DiagnosisTableHost,
|
|
_blue_appointment_layout,
|
|
_blue_appointment_text,
|
|
_blue_font,
|
|
)
|
|
from doctor_workstation.ui.reception_style import body_family, heading_family
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def application() -> QApplication:
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
def _settle(application: QApplication) -> None:
|
|
for _ in range(12):
|
|
application.processEvents()
|
|
|
|
|
|
def _row(identifier: int = 501) -> dict:
|
|
return {
|
|
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
|
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
|
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
|
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
|
"appointments": [
|
|
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
|
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
|
],
|
|
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
|
"unserved_days": 1,
|
|
}
|
|
|
|
|
|
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
|
row = _row()
|
|
before = copy.deepcopy(row)
|
|
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
|
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
|
)
|
|
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
|
"—", "2026-08-06 时间 —",
|
|
)
|
|
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
|
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
|
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
|
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
|
for missing_id in (0, "", None):
|
|
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
|
assert row == before
|
|
legacy = DiagnosisTableHost()
|
|
blue = DiagnosisTableHost(tech_blue=True)
|
|
for host in (legacy, blue):
|
|
host.set_rows([row])
|
|
assert "陈医生" not in legacy.model.index(0, 4).data()
|
|
assert blue.model.index(0, 4).data().splitlines() == [
|
|
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
|
]
|
|
assert blue.model.index(0, 9).data() == "—"
|
|
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
|
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
|
blue.set_rows([{**row, "appointments": []}])
|
|
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
|
legacy.close()
|
|
blue.close()
|
|
|
|
|
|
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
|
blue = DiagnosisTableHost(tech_blue=True)
|
|
legacy = DiagnosisTableHost()
|
|
row = _row()
|
|
single = _row(502)
|
|
single["appointments"] = single["appointments"][:1]
|
|
long = copy.deepcopy(single)
|
|
long["id"] = long["diagnosis_id"] = 503
|
|
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
|
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
|
long["has_prescription"] = 1
|
|
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
|
long["followup_doctor_name"] = "复诊医生姓名"
|
|
long["followup_rx_voided"] = True
|
|
blue.set_rows([row, single, long])
|
|
legacy.set_rows([row])
|
|
assert blue.model.columnCount() == 12
|
|
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
|
assert blue.main.horizontalHeader().height() == 41
|
|
assert legacy.main.horizontalHeader().height() == 39
|
|
assert blue.main.rowHeight(0) == 108
|
|
assert blue.main.rowHeight(1) == 72
|
|
assert blue.main.rowHeight(2) > 110
|
|
assert legacy.main.rowHeight(0) == 96
|
|
for index in range(3):
|
|
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
|
assert _blue_font(14).family() == body_family()
|
|
assert _blue_font(14, medium=True).family() == heading_family()
|
|
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
|
assert _blue_font(13).pixelSize() == 13
|
|
blue.close()
|
|
legacy.close()
|
|
|
|
|
|
@pytest.mark.parametrize("tech_blue", [False, True])
|
|
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
|
application: QApplication, tech_blue: bool,
|
|
) -> None:
|
|
window = QWidget()
|
|
layout = QVBoxLayout(window)
|
|
layout.setContentsMargins(7, 7, 7, 7)
|
|
host = DiagnosisTableHost(tech_blue=tech_blue)
|
|
layout.addWidget(host, 1)
|
|
footer = QLabel("始终可达的分页")
|
|
footer.setFixedHeight(42)
|
|
layout.addWidget(footer)
|
|
host.set_rows([_row(i) for i in range(40)])
|
|
window.resize(1280, 640)
|
|
window.show()
|
|
_settle(application)
|
|
minimum = window.minimumSizeHint().height()
|
|
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
|
window.resize(width, height)
|
|
_settle(application)
|
|
# The previous implementation wrote the available height into the
|
|
# fixed child's minimum and enlarged every ancestor on each relayout.
|
|
assert window.size().height() == height
|
|
assert window.minimumSizeHint().height() == minimum
|
|
assert host.fixed.minimumHeight() == 0
|
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
|
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
|
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
|
assert main_scroll.maximum() == fixed_scroll.maximum()
|
|
main_scroll.setValue(main_scroll.maximum() // 2)
|
|
_settle(application)
|
|
assert main_scroll.value() == fixed_scroll.value()
|
|
fixed_scroll.setValue(fixed_scroll.maximum())
|
|
_settle(application)
|
|
assert main_scroll.value() == fixed_scroll.value()
|
|
frozen_x = host.fixed.x()
|
|
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
|
_settle(application)
|
|
assert host.fixed.x() == frozen_x
|
|
assert host.fixed.geometry().right() <= host.rect().right()
|
|
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
|
window.close()
|
|
_settle(application)
|
|
|
|
|
|
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
|
host = DiagnosisTableHost(tech_blue=True)
|
|
host.resize(1300, 420)
|
|
host.set_rows([_row(501), _row(502)])
|
|
host.show()
|
|
_settle(application)
|
|
host.main.selectRow(1)
|
|
box = host.main.visualRect(host.model.index(1, 0)).center()
|
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
|
assert [row["id"] for row in host.selected_records()] == [502]
|
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
|
assert host.selected_records() == []
|
|
assert host.main.currentRow() == 1
|
|
header = host.main.horizontalHeader()
|
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
|
assert host.main.currentRow() == 1
|
|
host.main.selectRow(0)
|
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
|
assert host.selected_records() == []
|
|
host.close()
|
|
|
|
|
|
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
|
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
|
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
|
})
|
|
row = _row()
|
|
# Make the second entry cancellable and the first doctor wrap: hit regions
|
|
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
|
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
|
row["appointments"][1]["status"] = 4
|
|
host.set_rows([row])
|
|
host.resize(1300, 500)
|
|
host.show()
|
|
_settle(application)
|
|
received = []
|
|
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
|
overlay = host.main.indexWidget(host.model.index(0, 4))
|
|
buttons = overlay.findChildren(QToolButton)
|
|
assert len(buttons) == 2
|
|
entries, total = _blue_appointment_layout(row, overlay.width())
|
|
for position, button in enumerate(buttons):
|
|
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
|
assert button.geometry().bottom() < overlay.height()
|
|
button.click()
|
|
assert received == [(501, 2501), (501, 2401)]
|
|
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
|
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
|
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
|
row["video_call_hint"] = {"state": "live"}
|
|
host.set_rows([row])
|
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
|
assert video is not None and video.isEnabled()
|
|
row["patient_id"] = 0
|
|
host.set_rows([row])
|
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
|
assert video is not None and not video.isEnabled()
|
|
host.action_policy["appointment_cancel"] = False
|
|
host.set_rows([row])
|
|
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
|
host.close()
|
|
|
|
|
|
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
|
script = tmp_path / "shell_navigation.py"
|
|
script.write_text('''
|
|
import socket
|
|
from unittest.mock import patch
|
|
from PySide6.QtCore import Qt, QThreadPool
|
|
from PySide6.QtWidgets import QApplication
|
|
from doctor_workstation.services import DemoDoctorRepository
|
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
|
app = QApplication([])
|
|
apply_theme(app)
|
|
def settle():
|
|
for _ in range(12):
|
|
QThreadPool.globalInstance().waitForDone(1000)
|
|
app.processEvents()
|
|
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
|
repo = DemoDoctorRepository()
|
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
|
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
|
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
shell.resize(1536, 960)
|
|
shell.show()
|
|
settle()
|
|
for route in ("consultations", "appointments", "consultations"):
|
|
shell.navigate(route)
|
|
settle()
|
|
host = shell.pages["consultations"].table_host
|
|
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
|
shell.resize(width, height)
|
|
settle()
|
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
|
assert host.fixed.minimumHeight() == 0
|
|
assert shell.height() == height
|
|
shell.close()
|
|
settle()
|
|
print("native navigation geometry stable")
|
|
''', encoding="utf-8")
|
|
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
|
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
|
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", env=env, timeout=90)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert "native navigation geometry stable" in result.stdout
|