444 lines
17 KiB
Python
444 lines
17 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 PySide6 import __version__ as pyside_version
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
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
|
|
|
|
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 ----------------------------------------------------------
|
|
from orisys_autocal.motion import DemoTransport
|
|
|
|
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()
|
|
oversize = []
|
|
for child in _visible_descendants(window):
|
|
if _inside_scroll_area(child):
|
|
continue # a rail's content is meant to exceed its viewport
|
|
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()}"
|
|
)
|
|
checker.that(
|
|
not oversize, f"{theme} {width}x{height} tab={key}: {oversize}"
|
|
)
|
|
window.workspace.set_current_key("axes")
|
|
print(f" {width}x{height}: every tab 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",
|
|
)
|
|
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: window._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: window._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}",
|
|
)
|
|
checker.that(
|
|
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
|
|
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
|
|
)
|
|
window.workspace.set_current_key("axes")
|
|
|
|
try:
|
|
window.poller.shutdown()
|
|
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
|
pass
|
|
window.close()
|
|
app.processEvents()
|
|
|
|
return checker.report()
|
|
|
|
|
|
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())
|