Files
orisys-app/apps/autocal/tools/panel_check.py
T
ykcheungab bcc55a3b97 修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。

离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
2026-10-15 06:52:28 +08:00

778 lines
32 KiB
Python

#!/usr/bin/env python3
"""Dependency-free offscreen check of the AutoCal window.
Builds the real window with the simulated platform and asserts the invariants
that are easy to break and hard to notice: theme tokens present, layout fitting
inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
# The report quotes labels, and half of them are Chinese. A Windows console
# defaults to a legacy code page that cannot encode them, so the run ended in a
# UnicodeEncodeError *after* every check had passed — the worst kind of noise.
for _stream in (sys.stdout, sys.stderr):
_reconfigure = getattr(_stream, "reconfigure", None)
if _reconfigure is not None:
try:
_reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - stream already detached
pass
RESOLUTIONS = ((1440, 900), (1920, 1080))
class Checker:
"""Collects failures instead of asserting, so one run reports everything."""
def __init__(self) -> None:
self.failures: list[str] = []
self.checks = 0
def that(self, condition: bool, message: str) -> None:
self.checks += 1
if not condition:
self.failures.append(message)
def report(self) -> int:
print()
if self.failures:
print(f"FAILED — {len(self.failures)} of {self.checks} checks:")
for failure in self.failures:
print(f" - {failure}")
return 1
print(f"OK — {self.checks} checks passed")
return 0
def _visible_descendants(widget):
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
def main() -> int:
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.theme import build_qss, load_colors
from orisys_autocal.motion import ConnectionSpec
from orisys_autocal.ui.axis_panel import COLUMNS
from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6 import __version__ as pyside_version
from PySide6.QtWidgets import QApplication
checker = Checker()
app = QApplication.instance() or QApplication([])
print(f"Qt binding: PySide6 {pyside_version}")
# ---- theme tokens -----------------------------------------------------
for theme in ("dark", "light"):
colors = load_colors(theme)
qss = build_qss(theme)
checker.that(bool(colors), f"{theme}: no colour tokens")
checker.that(len(qss) > 500, f"{theme}: the stylesheet looks empty")
checker.that(
len(build_qss(theme)) == len(qss), f"{theme}: the stylesheet is not stable"
)
# ---- locale completeness ---------------------------------------------
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.preferences import load_preferences
# Redirect the preference store into the app's own (gitignored) outputs before
# any window exists. This check opens and closes real windows, and closing one
# writes the whole in-memory document back: a check that dials in a press-axis
# zero would otherwise leave that fabricated measurement on the operator's
# machine, and every later run would start from it.
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
scratch = APP_ROOT / "outputs" / "panel_check"
scratch.mkdir(parents=True, exist_ok=True)
scratch_prefs = scratch / "preference.json"
if scratch_prefs.exists():
scratch_prefs.unlink()
paths_mod.preference_path = lambda: scratch_prefs
prefs_pkg.store.preference_path = lambda: scratch_prefs
prefs_mod.preference_path = lambda: scratch_prefs
prefs = load_preferences()
for language in ("zh_CN", "en_US"):
set_locale(language)
checker.that(t("app.title") != "app.title", f"{language}: app.title missing")
checker.that(t("axes.title") != "axes.title", f"{language}: axes.title missing")
checker.that(
t("bus.state_ok") != "bus.state_ok", f"{language}: bus.state_ok missing"
)
set_locale("zh_CN")
# ---- window ----------------------------------------------------------
import math
from orisys_autocal.motion import (
DemoTransport,
plan_pose_move,
plan_return_to_initial,
user_units_to_deg,
)
for theme in ("dark", "light"):
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
window.poller.service._transport = DemoTransport() # noqa: SLF001
app.setStyleSheet(build_qss(theme))
print(f"\n--- theme={theme} ---")
for width, height in RESOLUTIONS:
window.resize(width, height)
window.show()
app.processEvents()
# Every tab, not just the default one: a page that overflows only
# when its tab is selected is exactly the defect this catches.
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
checker.that(
not _overflowing(window),
f"{theme} {width}x{height} tab={key}: {_overflowing(window)}",
)
window.workspace.set_current_key("axes")
print(f" {width}x{height}: every tab fits")
# The device setup is a separate top-level window now, so the sweep has
# to be run over it as well: a dialog whose own controls do not fit is
# exactly the defect the sweep exists for, one window further out.
window._show_device_setup() # noqa: SLF001 - the documented entry point
app.processEvents()
for tab in ("connection", "bus"):
window.device_setup.select_tab(tab)
app.processEvents()
checker.that(
not _overflowing(window.device_setup),
f"{theme} {width}x{height} setup tab={tab}: "
f"{_overflowing(window.device_setup)}",
)
window.device_setup.close()
app.processEvents()
print(f" {width}x{height}: the device setup fits")
# ---- the permanent shell ----------------------------------------
# The workspace is five pages: three run screens, the sensor page and the
# calibration-record review. (Multi-point replaced the old surface/points
# pair, so this list is the one workspace.TABS declares.)
checker.that(
window.workspace.page_keys()
== ["axes", "sequence", "finger", "sensor", "records"],
f"unexpected tab set: {window.workspace.page_keys()}",
)
checker.that(
window.status_strip.isVisible(),
"the motion status strip is not visible",
)
checker.that(
window.status_strip.height() <= 48,
f"the status strip is {window.status_strip.height()}px tall",
)
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
checker.that(
window.status_strip.isVisible(),
f"the status strip vanished on tab {key}",
)
checker.that(
bool(window.workspace.tabText(window.workspace.currentIndex())),
f"tab {key} has no label",
)
window.workspace.set_current_key("axes")
# The readout rail must show all five drives without scrolling.
readout_rows = len(window.axis_readout._rows)
checker.that(readout_rows == 5, f"the readout rail shows {readout_rows} axes")
checker.that(
window.axis_readout.width() >= 200,
f"the readout rail is only {window.axis_readout.width()}px wide",
)
# The sequence tab: three zones, a persisted profile, and a point list
# whose coordinates are generated rather than typed.
sequence = window.sequence_panel
checker.that(
sequence.profile_keys()
== [
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
],
f"sequence profile fields changed: {sequence.profile_keys()}",
)
checker.that(
sequence.validation_checks()
== ["on_surface", "within_travel", "contact_confirmable"],
f"sequence validation checks changed: {sequence.validation_checks()}",
)
for name, phase in sequence.pending_phases().items():
button = sequence._pending_buttons[name]
checker.that(
not button.isEnabled() and phase in button.toolTip(),
f"sequence control {name} is usable or does not name its phase",
)
checker.that(
sequence.planned_steps() == [],
"the sequence tab handed out steps a runner could start",
)
checker.that(
sequence.point_row_count() > 0,
"the sequence point list is empty",
)
checker.that(
sequence.table.columnCount() == len(sequence.column_keys()),
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
)
# The shell: one rail, a run bar, and a health chip.
checker.that(
window.main_splitter.count() == 2,
f"the layout has {window.main_splitter.count()} columns, expected 2",
)
checker.that(
window.left_rail.widget() is window.rail_content,
"the setup rail is not scrollable",
)
# The rail carries what is read while working. Connection and bus
# initialisation moved into the device-setup dialog: back in the rail they
# pushed the axis readout below the fold.
rail_children = {
type(child).__name__ for child in window.rail_content.findChildren(object)
}
checker.that(
"AxisReadoutPanel" in rail_children,
f"the axis readout is not in the rail: {sorted(rail_children)}",
)
checker.that(
"BusDataPanel" in rail_children,
"the bus-data monitor is not in the rail",
)
checker.that(
"ConnectionPanel" not in rail_children and "BusPanel" not in rail_children,
f"a start-up panel is still in the rail: {sorted(rail_children)}",
)
checker.that(
not window.device_setup.isVisible(),
"the device setup is open before anyone asked for it",
)
# Where the three move controls live, and why. Execute is about the selected
# point, so it belongs to the finger screen; the stop is repeated next to it
# *and* stays in the strip, because an emergency stop has to be where the
# operator's eyes are; returning to zero is a machine action wanted after any
# tab's work, so it lives in the always-visible rail and is not duplicated.
checker.that(
window.finger_panel.execute_button.parent() is not None
and window.finger_panel.stop_button.parent() is not None,
"the finger screen is missing an execute or stop button",
)
checker.that(
window.status_strip.stop_button.isEnabled() is not None,
"the strip has no stop button",
)
checker.that(
window.axis_readout.return_button.text() == t("move.return_initial"),
f"the rail return button says "
f"{window.axis_readout.return_button.text()!r}",
)
checker.that(
window.axis_readout.return_button.parent() is window.axis_readout,
"the return button is not in the always-visible rail",
)
# The mapping page is the third tab of the device setup, the press-axis
# relation the fourth, and the service already holds the mapping shown.
checker.that(
window.device_setup.tabs.count() == 4,
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 4",
)
checker.that(
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}",
)
checker.that(
window.device_setup.tabs.tabText(3) == t("setup.tab_press"),
f"the fourth setup tab is {window.device_setup.tabs.tabText(3)!r}",
)
checker.that(
window.device_setup.press_page.press()["calibrated"] is False,
f"the press page starts calibrated: "
f"{window.device_setup.press_page.press()}",
)
# With no controller, a move must be refused with a reason and the execute
# button must say so rather than appearing to work.
disconnected = window.poller.service.check_axes_move({"x": 1.0})
checker.that(
disconnected is not None,
"a move was allowed with no controller connected",
)
checker.that(
not window.finger_panel.execute_button.isEnabled(),
"execute is enabled with no controller connected",
)
checker.that(
window.device_setup.connection_panel is window.connection_panel
and window.device_setup.bus_panel is window.bus_panel,
"the window's panel references are not the ones in the dialog",
)
# Three ways in, all of them the operator's, all landing on one method.
window.status_strip.setup_button.click()
app.processEvents()
checker.that(
window.device_setup.isVisible(),
"the setup button did not open the device setup",
)
window.device_setup.close()
window.status_strip.chip_bus.clicked.emit()
app.processEvents()
checker.that(
window.device_setup.isVisible()
and window.device_setup.current_tab() == "bus",
"the bus chip did not open the bus tab",
)
window.device_setup.close()
window.brand_bar.connectionChipClicked.emit()
app.processEvents()
checker.that(
window.device_setup.isVisible()
and window.device_setup.current_tab() == "connection",
"the brand-bar chip did not open the connection tab",
)
window.device_setup.close()
app.processEvents()
checker.that(
window.run_bar.isVisible(),
"the run bar is not visible",
)
checker.that(
not window.run_bar.abort_button.isEnabled(),
"abort is enabled with no run in progress",
)
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
checker.that(
window.run_bar.isVisible(),
f"the run bar vanished on tab {key}",
)
window.workspace.set_current_key("axes")
checker.that(
bool(window.status_strip.chip_health.text()),
"the poll-health chip has no text",
)
checker.that(
"QStatusBar" in window.statusBar().styleSheet(),
"the status bar is not using its own theme token",
)
# A collapsed bus-data group must not cost the page any height.
checker.that(
not window.bus_data_panel.isVisible(),
"the bus-data panel is expanded by default",
)
print(" shell: strip, readout rail and sequence profile verified")
panel = window.axis_panel
checker.that(panel.axis_count == 5, "the platform is not five axes")
checker.that(
panel.table.columnCount() == len(COLUMNS) + 1,
"the axis table lost a column",
)
# Every header must fit its column: a clipped header is how a table stops
# being readable without anyone noticing.
for index in range(panel.table.columnCount()):
header = panel.table.horizontalHeaderItem(index).text()
width = panel.table.columnWidth(index)
needed = panel.table.fontMetrics().horizontalAdvance(header) + 12
checker.that(
width >= needed,
f"axis column {index} ({header!r}) is {width}px, needs {needed}px",
)
checker.that(
len(COLUMNS) == 7,
f"the axis table should show seven data columns, has {len(COLUMNS)}",
)
checker.that(
window.axis_panel._detail_label.text(),
"the selected-axis detail line is empty",
)
headers = [
panel.table.horizontalHeaderItem(index).text()
for index in range(panel.table.columnCount())
]
checker.that(
all(headers) and headers[0] == t("axes.axis"),
f"axis table headers are not localised: {headers}",
)
checker.that(
all(
panel.table.item(row, 0) is not None for row in range(panel.axis_count)
),
"an axis row has no data cell",
)
# Motion must be unreachable before a bus is initialised.
checker.that(
not panel._rows[0]["fwd_button"].isEnabled(),
"jog is enabled before the bus is initialised",
)
# Bus data follows the reported bus family. ``isEnabledTo`` rather than
# ``isEnabled``: the panel now lives in a collapsed group, and Qt reports
# a hidden ancestor as disabling everything inside it.
box = window.bus_data_group
window.bus_data_panel.set_connected(True)
window.bus_data_panel.set_bus_type("ecat")
checker.that(
window.bus_data_panel.ecat_box.isEnabledTo(box)
and not window.bus_data_panel.rtex_box.isEnabledTo(box),
"the SDO/RTEX panels are not gated by bus type",
)
# Expanding the group must reveal the panel, not resize the page.
before = window.size()
box.setChecked(True)
app.processEvents()
checker.that(
window.bus_data_panel.isVisible(), "expanding the group did not show the panel"
)
box.setChecked(False)
app.processEvents()
checker.that(
window.size() == before, "toggling bus data resized the window"
)
# A full simulated session: connect, initialise from the shipped BAS.
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
_wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0)
checker.that(
window._last_snapshot.connected, "the simulated controller did not connect"
)
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
window._on_download_bas()
ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0)
checker.that(ok, "the simulated bus did not report initialised")
if ok:
checker.that(
window._last_snapshot.bus.bus_type == "ecat",
"the bus type was not decoded from BUS_TYPE",
)
checker.that(
window._last_snapshot.bus.total_axes == 5,
"the bus did not report five axes",
)
checker.that(
panel._rows[0]["fwd_button"].isEnabled(),
"jog stayed disabled after the bus was initialised",
)
checker.that(
"65" in window.axis_panel._detail_label.text(),
"ATYPE was not read back on the detail line (expected 65, position mode)",
)
print(
f" session: bus={window._last_snapshot.bus.bus_type} "
f"axes={window._last_snapshot.bus.total_axes} "
f"{panel.enabled_count_label.text()}"
)
# The strip must be reporting the live session, from whatever tab.
window.workspace.set_current_key("surface")
app.processEvents()
strip = window.status_strip
checker.that(
strip.chip_connection.text() == t("connection.state_open"),
f"strip connection chip says {strip.chip_connection.text()!r}",
)
checker.that(
strip.chip_bus.text() == t("bus.type_ecat"),
f"strip bus chip says {strip.chip_bus.text()!r}",
)
checker.that(
strip.chip_init.text() == t("bus.state_ok"),
f"strip init chip says {strip.chip_init.text()!r}",
)
checker.that(
t("axes.enabled_count", on=5, total=5) == strip.chip_axes.text()
or "5" in strip.chip_axes.text(),
f"strip axis chip says {strip.chip_axes.text()!r}",
)
window.workspace.set_current_key("axes")
# ---- the pose move ------------------------------------------------
# The whole path end to end, on the simulated controller: solve a pose
# from a picked point, plan it, run it, and check the drives arrived.
# Nothing else exercises the order, the units and the guards together.
window.workspace.set_current_key("finger")
app.processEvents()
finger = window.finger_panel
checker.that(
finger.execute_button.isEnabled(),
"execute is disabled on a connected, initialised platform",
)
checker.that(
finger.stop_button.isEnabled(),
"the local stop is disabled while the platform may move",
)
# A point to solve: pick the centre of the flat image, which is the
# operator's own gesture, so the pose the check plans is a real one.
if not finger.points():
view = finger.view
window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001
app.processEvents()
checker.that(
bool(finger.points()),
"picking the middle of the patch produced no point",
)
solve = finger.solved_pose()
checker.that(
solve is not None,
"the finger screen solved no pose from its point list",
)
if solve is not None:
# The shipped machine has no measured press-axis zero, so the press
# role must come back held: a geometric contact height is not a
# controller coordinate until somebody has measured the relation.
held_targets, held_roles = solve
checker.that(
"z" not in held_targets and held_roles == ("z",),
f"an uncalibrated press axis was commanded anyway: "
f"{sorted(held_targets)} held={held_roles}",
)
print(" press: an uncalibrated press axis is held, not commanded")
# Entering the bench's zero — a dialled-in number, as the operator
# would — turns the same height into a coordinate and the move into
# five axes.
page = window.device_setup.press_page
page.zero_spin.setValue(2.0)
page.calibrated_check.setChecked(True)
app.processEvents()
solve = finger.solved_pose()
targets, held = solve
checker.that(
"z" in targets and not held,
f"execute does not command the press axis: "
f"{sorted(targets)} held={held}",
)
plan = plan_pose_move(
window.poller.service, targets, hold=held, together=True
)
checker.that(
[step.role for step in plan.steps]
== ["yaw", "pitch", "x", "y", "z"],
f"the move order is {[step.role for step in plan.steps]}",
)
checker.that(
all(math.isfinite(step.units) for step in plan.steps),
"a planned step does not convert to user units",
)
# Straight to the mover: the confirmation box is the operator's, and
# a modal dialog in a headless check is a hang (it has been one).
result = window.mover.start(plan)
checker.that(result.ok, f"the mover refused the plan: {result.message}")
moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0)
checker.that(moved, "the pose move did not finish")
checker.that(
not window.mover.state.failure,
f"the pose move failed: {window.mover.state.failure}",
)
for step in plan.steps:
state = window._last_snapshot.axis(step.axis)
if state is None:
continue
if step.unit == "mm":
actual = window.poller.service.to_mm(step.axis, state.dpos)
else:
actual = user_units_to_deg(step.axis, state.dpos)
checker.that(
abs(actual - step.target) <= 0.05,
f"{step.role} ended at {actual:.4f}{step.unit}, "
f"target {step.target:.4f}{step.unit}",
)
print(
" move: "
+ ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps)
+ " reached"
)
# All five axes back to zero together, including the press axis.
back = plan_return_to_initial(window.poller.service)
checker.that(
[step.role for step in back.steps]
== ["yaw", "pitch", "x", "y", "z"],
f"the return order is {[step.role for step in back.steps]}",
)
result = window.mover.start(back)
checker.that(result.ok, f"the return was refused: {result.message}")
returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0)
checker.that(
returned and not window.mover.state.failure,
f"the return did not finish: {window.mover.state.failure}",
)
zeroed = [
window._last_snapshot.axis(step.axis).dpos
for step in back.steps
if window._last_snapshot.axis(step.axis) is not None
]
checker.that(
all(abs(value) <= 1.0 for value in zeroed),
f"the axes did not return to zero: {zeroed}",
)
print(" move: every axis returned to zero")
# Stop cancels the queue: no further axis may be commanded.
window.mover.start(plan)
window._on_stop_all()
app.processEvents()
checker.that(not window.mover.running, "stop left the move queued")
checker.that(
window.mover.state.cancelled,
"stop did not mark the move cancelled",
)
print(" move: stop cancels the queue")
# The guards, on the same connected session: a mapping that claims one
# drive twice must refuse motion rather than quietly pick a role.
service = window.poller.service
saved = service.axis_roles
service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
checker.that(
refusal is not None,
f"a duplicated drive was not refused: {refusal}",
)
service.set_axis_roles(saved)
refusal = service.check_axes_move({"x": 500.0})
checker.that(
refusal is not None and "500" in refusal.message,
f"a target past the travel limit was not refused: {refusal}",
)
refusal = service.check_axes_move({"yaw": 400.0})
checker.that(
refusal is not None,
f"a yaw past the stage envelope was not refused: {refusal}",
)
# The mapping page must agree with the service it feeds.
page = window.device_setup.roles_page
checker.that(
page.roles() == saved,
f"the mapping page shows {page.roles()}, service holds {saved}",
)
page.combos["y"].setCurrentIndex(page.combos["y"].findData(0))
app.processEvents()
checker.that(
bool(page.problem()), "the mapping page accepted two roles on one drive"
)
checker.that(
service.roles_problem() != "",
"the service did not pick up the duplicated mapping",
)
page.combos["y"].setCurrentIndex(page.combos["y"].findData(1))
app.processEvents()
checker.that(
page.problem() == "" and service.roles_problem() == "",
"correcting the mapping did not clear the refusal",
)
print(" move: guards refuse a duplicate mapping and an out-of-range target")
window.workspace.set_current_key("axes")
app.processEvents()
try:
window.poller.shutdown()
except Exception: # noqa: BLE001,S110 - a shutdown must not mask the result
pass
window.close()
app.processEvents()
return checker.report()
def _overflowing(root) -> list[str]:
"""Visible descendants that are larger than the widget that contains them.
Scroll-area content is exempt: a rail's content is meant to exceed its viewport.
Everything else that does not fit its parent is a clipped control.
"""
oversize = []
for child in _visible_descendants(root):
if _inside_scroll_area(child):
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if (
geometry.width() > parent.width() + 1
or geometry.height() > parent.height() + 1
):
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > {parent.width()}x{parent.height()}"
)
return oversize
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
parent = widget.parentWidget()
while parent is not None:
if isinstance(parent, QAbstractScrollArea):
return True
parent = parent.parentWidget()
return False
def _wait_for(app, predicate, timeout: float) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
if __name__ == "__main__":
raise SystemExit(main())