autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
+331
View File
@@ -0,0 +1,331 @@
"""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