#!/usr/bin/env python3 """Dependency-free smoke run for the customer UI. ``pytest`` is the canonical runner (``apps/customer/tests``), but this script needs nothing beyond the app's own runtime dependencies, so it can be used in minimal environments: python tools/ui_check.py It builds the real main window offscreen and asserts the layout, theming and status-feedback invariants that the UI work depends on. """ from __future__ import annotations import os import sys from pathlib import Path APP_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = APP_ROOT.parents[1] for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): if _path.is_dir() and str(_path) not in sys.path: sys.path.insert(0, str(_path)) 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) os.chdir(APP_ROOT) FAILURES: list[str] = [] CHECKS = 0 def check(condition: bool, message: str) -> None: global CHECKS CHECKS += 1 if not condition: FAILURES.append(message) def section(name: str) -> None: print(f"\n== {name} ==") def main() -> int: from PyQt5.QtWidgets import QApplication from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss, load_colors from orisys_customer.app_window import CustomerMainWindow app = QApplication.instance() or QApplication(sys.argv) set_locale("zh_CN") section("theme tokens") dark = set(load_colors("dark")) light = set(load_colors("light")) check(dark == light, f"token keys differ: {sorted(dark ^ light)}") for theme in ("dark", "light"): qss = build_qss(theme) check(len(qss) > 1000, f"{theme} qss suspiciously short") check( load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"], f"{theme}: arrow canvas collides with accent", ) check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled") check("QHeaderView::section" in qss, f"{theme}: table header not styled") check("QRadioButton::indicator" in qss, f"{theme}: radio not styled") check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled") scene_count = 0 for theme in ("dark", "light"): for sensor in ("box", "finger"): section(f"window {theme}/{sensor}") scene_count += 1 app.setStyleSheet(build_qss(theme)) window = CustomerMainWindow( prefs={ "language": "zh_CN", "theme": theme, "sensor_type": sensor, "show_cal_panel": True, } ) window.resize(1440, 880) window.show() app.processEvents() check(window.windowTitle() != "", "window title empty") check( window.status_chip.text() != "", "status chip has no phase text", ) check( window.toolbar.btn_run.text().strip() not in ("", "▶"), f"run button is unlabelled: {window.toolbar.btn_run.text()!r}", ) panels = [ window.mesh3d_panel, window.arrow_panel, window.force_plots, window.cal_panel, ] for panel, width in zip(panels, window.content_splitter.sizes()): if panel.isVisible(): check( width >= panel.minimumWidth(), f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}", ) check( window.force_plots.width() >= window.force_plots.minimumWidth(), "force plots narrower than its minimum", ) # value cards must keep their text visible for card, label in zip( window.force_plots.value_cards, window.force_plots.value_labels ): check( label.height() >= label.fontMetrics().height(), "value card text is clipped", ) check(label.text() != "", "value card is empty") # demo data flows through the plots and the calibration table for i in range(90): window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N") app.processEvents() check( window.force_plots.history.sample_count > 0, "plots did not accept samples", ) window.cal_panel.set_running(True) check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled") window.cal_panel._store.upsert( "fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02) ) window.cal_panel._refresh_table() check(window.cal_panel.table.rowCount() == 1, "calibration row missing") check( not window.cal_panel.empty_hint.isVisible(), "empty hint still visible with rows", ) window._set_phase("running") check("status_running" not in window.status_chip.text(), "chip not localized") window._set_theme("light" if theme == "dark" else "dark") app.processEvents() window._persist_prefs() check( len(window.prefs.get("splitter_sizes", [])) == 4, "splitter sizes not persisted", ) window.close() app.processEvents() section("small window") app.setStyleSheet(build_qss("dark")) window = CustomerMainWindow( prefs={ "language": "en_US", "theme": "dark", "sensor_type": "finger", "show_cal_panel": True, "show_finger_3d": True, } ) window.resize(1280, 720) window.show() app.processEvents() for panel, width in zip( [ window.mesh3d_panel, window.arrow_panel, window.force_plots, window.cal_panel, ], window.content_splitter.sizes(), ): if panel.isVisible(): check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed") oversize = [] for child in window.findChildren(object): if not hasattr(child, "isVisible") or not child.isVisible(): continue if not hasattr(child, "geometry"): 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(type(child).__name__) check(not oversize, f"widgets overflow their parent: {oversize}") window.close() app.processEvents() print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") for failure in FAILURES: print(f" FAIL: {failure}") return 1 if FAILURES else 0 if __name__ == "__main__": raise SystemExit(main())