"""Offscreen UI smoke tests: construction, layout, gating, and preferences. The window is built for real, with the simulated platform standing in for the controller. Nothing here needs a display or hardware. """ from __future__ import annotations import pytest from conftest import pump, visible_descendants RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080)) def _show(window, size=(1440, 880)): from PySide6.QtWidgets import QApplication window.resize(*size) window.show() QApplication.processEvents() return window def _enabled(widget) -> bool: """Whether ``widget`` is effectively enabled, shown or not. A plain ``isEnabled()`` is False for every child of a window that has not been shown, which makes an assertion about gating depend on whether the test happened to realise the window. ``isEnabledTo`` answers the question the tests actually mean: would a user be able to operate this control? """ return widget.isEnabledTo(widget.window()) # ------------------------------------------------------------------ structure @pytest.mark.parametrize(("theme", "language"), [ ("dark", "zh_CN"), ("light", "zh_CN"), ("dark", "en_US"), ("light", "en_US"), ]) def test_the_window_builds_in_every_theme_and_language(make_window, theme, language): window = _show(make_window(theme=theme, language=language)) assert window.windowTitle() assert window.axis_panel.table.rowCount() == 5 def test_the_product_name_is_localised_in_the_window_title(make_window): window = _show(make_window(language="zh_CN")) zh = window.windowTitle() window._set_language("en_US") assert window.windowTitle() != zh def test_every_axis_row_has_a_labelled_control(make_window): window = _show(make_window()) panel = window.axis_panel for row in range(panel.axis_count): widgets = panel._rows[row] for key in ("enable_button", "fwd_button", "rev_button", "stop_button"): assert widgets[key].text().strip(), f"row {row} {key} has no label" def test_the_table_shows_one_column_per_documented_quantity(make_window): from orisys_app_shared.i18n import t from orisys_autocal.ui.axis_panel import COLUMNS window = _show(make_window()) headers = [ window.axis_panel.table.horizontalHeaderItem(index).text() for index in range(window.axis_panel.table.columnCount()) ] assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")] @pytest.mark.parametrize("size", RESOLUTIONS) def test_every_axis_row_is_reachable(make_window, size): """No drive may be hidden by the splitter. The panel asks for three rows without scrolling; at the default window size all five must be visible, because a clipped fifth row on a five-axis platform reads as a missing drive rather than as a scrollbar. If the panel is ever squeezed below that, the table has to scroll rather than silently cut a row. """ window = _show(make_window(), size) table = window.axis_panel.table rows_height = sum( table.rowHeight(row) for row in range(window.axis_panel.axis_count) ) header = table.horizontalHeader().height() available = table.viewport().height() assert available >= rows_height or table.verticalScrollBar().maximum() > 0, ( f"{size}: rows need {rows_height}px, viewport has {available}px " f"(header {header}px) and it cannot scroll" ) @pytest.mark.parametrize("size", RESOLUTIONS) def test_no_visible_child_escapes_its_parent(make_window, size): window = _show(make_window(), size) oversize = [] for child in visible_descendants(window): if _inside_scroll_area(child): # A scroll area's content is *meant* to exceed its viewport: the rail # scrolls instead of squeezing its controls into an unreadable column. 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 {parent.width()}x{parent.height()}" ) assert not oversize, oversize def _inside_scroll_area(widget) -> bool: """Whether ``widget`` is content of a QAbstractScrollArea, not 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 # ----------------------------------------------------------------------- gating def test_motion_controls_start_disabled_without_a_bus(make_window): window = _show(make_window()) assert not _enabled(window.axis_panel._rows[0]["fwd_button"]) assert not _enabled(window.axis_panel._rows[0]["rev_button"]) def test_bus_data_panels_follow_the_reported_bus_type(make_window): window = _show(make_window()) window.bus_data_panel.set_connected(True) window.bus_data_panel.set_bus_type("ecat") assert _enabled(window.bus_data_panel.ecat_box) assert not _enabled(window.bus_data_panel.rtex_box) window.bus_data_panel.set_bus_type("rtex") assert _enabled(window.bus_data_panel.rtex_box) assert not _enabled(window.bus_data_panel.ecat_box) window.bus_data_panel.set_bus_type("unknown") assert not _enabled(window.bus_data_panel.ecat_box) assert not _enabled(window.bus_data_panel.rtex_box) def test_bus_data_panels_are_disabled_while_disconnected(make_window): window = _show(make_window()) window.bus_data_panel.set_bus_type("ecat") window.bus_data_panel.set_connected(False) assert not _enabled(window.bus_data_panel.ecat_box) def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window): window = _show(make_window()) window.axis_panel.set_connected(True) window.axis_panel.set_axis_count(3) assert _enabled(window.axis_panel._rows[2]["enable_button"]) assert not _enabled(window.axis_panel._rows[3]["enable_button"]) def test_unknown_connection_modes_report_instead_of_pretending(make_window): from orisys_app_shared.i18n import t window = _show(make_window()) panel = window.connection_panel index = panel.mode_combo.findData("com") panel.mode_combo.setCurrentIndex(index) panel._emit_connect() assert panel.status_label.text() != t("connection.state_open") # ------------------------------------------------------------------- actions def test_the_selected_axis_follows_a_table_click(make_window): window = _show(make_window()) window.axis_panel._on_cell_clicked(3, 0) assert window.axis_panel.selected_axis == 3 assert window.axis_panel.axis_index_label.text() == "3" def test_blank_numeric_fields_fall_back_to_defaults(make_window): from orisys_autocal.motion import DEFAULT_UNITS window = _show(make_window()) panel = window.axis_panel panel.units_edit.setText("") assert panel.current_params().units == DEFAULT_UNITS panel.units_edit.setText("not a number") assert panel.current_params().units == DEFAULT_UNITS def test_a_blank_target_does_not_emit_a_move(make_window): window = _show(make_window()) emitted = [] window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value)) window.axis_panel.target_edit.setText("") window.axis_panel._on_move_abs() assert emitted == [] def test_applying_parameters_persists_them_in_the_panel(make_window): from orisys_autocal.motion import AxisParams window = _show(make_window()) panel = window.axis_panel panel.set_selected_axis(1) panel.units_edit.setText("5") panel.speed_edit.setText("250") panel.accel_edit.setText("2500") panel._on_apply() stored = panel.stored_params()[1] assert isinstance(stored, AxisParams) assert stored.units == 5.0 # --------------------------------------------------------------- preferences def test_the_window_geometry_round_trips_through_preferences(make_window): # Above the enforced minimum (1400x860): a smaller request would be clamped, # which tests Qt's constraint rather than the preference round-trip. window = _show(make_window(), (1520, 920)) window._persist_prefs() geometry = window.prefs["window_geometry"] assert len(geometry) == 4 assert geometry[2] == pytest.approx(1520, abs=5) assert geometry[3] == pytest.approx(920, abs=5) def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window): window = _show(make_window(window_geometry=["nonsense"])) assert window.width() > 0 def test_axis_parameters_survive_a_persist_reload(make_window): from orisys_autocal import preferences as prefs_mod window = _show(make_window()) window.axis_panel.set_selected_axis(2) window.axis_panel.units_edit.setText("7") window.axis_panel._on_apply() window._persist_prefs() reloaded = prefs_mod.load_preferences() assert reloaded["axis_params"]["2"]["units"] == 7.0 def test_the_bus_path_is_remembered(make_window): window = _show(make_window()) window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas") window._persist_prefs() assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas" def test_sdo_fields_are_remembered(make_window): window = _show(make_window()) panel = window.bus_data_panel panel.node_edit.setText("2") panel.index_edit.setText("24608") panel.subindex_edit.setText("1") window._persist_prefs() assert window.prefs["sdo_last"]["node"] == 2 assert window.prefs["sdo_last"]["index"] == 24608 # --------------------------------------------------------------- demo session def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app): """End to end through the real signal path: connect, initialise, jog.""" from orisys_autocal.motion import ConnectionSpec window = _show(make_window()) window.connect_controller(ConnectionSpec(mode="demo"), label="connect") assert _wait_for(lambda: window._last_snapshot.connected, qt_app) # Download the shipped ECAT.bas, then wait for the simulated scan. window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas"))) window._on_download_bas() assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0) assert window._last_snapshot.bus.bus_type == "ecat" assert _enabled(window.bus_data_panel.ecat_box) assert _enabled(window.axis_panel._rows[0]["fwd_button"]) # ATYPE is no longer a table column — the seven-column table shows position # and status, and the drive's full frame is on the selected-axis detail line. window.axis_panel.set_selected_axis(0) assert "65" in window.axis_panel._detail_label.text() pump(qt_app, 0.2) def test_closing_the_window_stops_the_poller(make_window, qt_app): window = _show(make_window()) window.close() pump(qt_app, 0.2) assert not window.poller._thread.isRunning() def _shipped_bas(name: str): from orisys_autocal.ui.bus_panel import default_bas_dir return default_bas_dir() / name def _wait_for(predicate, app, timeout: float = 5.0) -> bool: import time deadline = time.monotonic() + timeout while time.monotonic() < deadline: app.processEvents() if predicate(): return True time.sleep(0.02) return False