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