332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""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
|