#!/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.preferences import load_preferences 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 ---------------------------------------- checker.that( window.workspace.page_keys() == ["axes", "sequence", "finger", "sensor", "surface", "points"], 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, and the service # already holds the mapping it shows. checker.that( window.device_setup.tabs.count() == 3, f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3", ) 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}", ) # 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: targets, held = solve checker.that( "z" in held and "z" not in targets, f"execute does not hold the press axis back: " f"{sorted(targets)} held={held}", ) plan = plan_pose_move(window.poller.service, targets, hold=held) checker.that( [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"], 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" ) # Returning to the initial state, press axis first. back = plan_return_to_initial(window.poller.service) checker.that( [step.role for step in back.steps] == ["z", "pitch", "yaw", "x", "y"], 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 - 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())