"""Guard and state-machine behaviour of the motion service. Driven against the simulated platform, so every assertion here is about AutoCal's own logic: what it refuses, what it reads, and when. No test in this file touches a controller or a DLL. """ from __future__ import annotations import time import pytest from orisys_autocal.motion import ( INIT_FAILED, INIT_OK, INIT_RUNNING, AxisParams, ConnectionSpec, DemoTransport, MotionService, ) @pytest.fixture def service(): return MotionService(DemoTransport()) def _initialise(service, timeout: float = 5.0) -> None: """Download a BAS program and wait for the simulated bus to report ready.""" service.download_bas(__file__, 1) deadline = time.monotonic() + timeout while time.monotonic() < deadline and service.bus.init_status != INIT_OK: service.poll([0]) time.sleep(0.05) # ----------------------------------------------------------------- connection def test_a_fresh_service_is_disconnected_and_touches_nothing(service): assert not service.connected assert not service.bas_loaded assert service.poll([0]).connected is False def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service): result = service.connect(ConnectionSpec(mode="demo")) assert result.ok assert service.connected # No BAS program has run, so the bus type must not be guessed at. snapshot = service.poll([0]) assert snapshot.bus.bus_type == "unknown" assert not snapshot.bus.initialized def test_connect_failure_is_reported_without_raising(service): result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1")) assert not result.ok assert not service.connected def test_disconnect_clears_bus_state(service): service.connect(ConnectionSpec(mode="demo")) service.disconnect() assert not service.connected assert service.bus.bus_type == "unknown" # ---------------------------------------------------------------- bus init def test_download_requires_a_real_file(service): service.connect(ConnectionSpec(mode="demo")) assert not service.download_bas("", 1).ok assert not service.download_bas(r"D:\nope\missing.bas", 1).ok def test_download_needs_a_connection(service): assert not service.download_bas(__file__, 1).ok def test_download_then_poll_reaches_initialised(service): service.connect(ConnectionSpec(mode="demo")) assert service.download_bas(__file__, 1).ok _initialise(service) assert service.bus.init_status == INIT_OK assert service.bus.bus_type == "ecat" assert service.bus.total_axes == 5 assert service.bus.node_count == 5 def test_bus_is_running_immediately_after_download(service): service.connect(ConnectionSpec(mode="demo")) service.download_bas(__file__, 1) assert service.bus.init_status == INIT_RUNNING def test_reinitialise_is_refused_while_already_running(service): service.connect(ConnectionSpec(mode="demo")) service.download_bas(__file__, 1) assert not service.reinit_bus().ok def test_reinitialise_is_refused_without_a_bas_program(service): service.connect(ConnectionSpec(mode="demo")) result = service.reinit_bus() assert not result.ok assert "BAS" in result.message or "Bas" in result.message def test_init_timeout_is_reported_rather_than_raising(): """A controller that never publishes its globals must time out, not hang.""" class SilentTransport(DemoTransport): def user_var(self, name): from orisys_app_shared.zmotion import ZAuxError raise ZAuxError(30002, f"{name} is not defined") service = MotionService(SilentTransport()) service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window service.connect(ConnectionSpec(mode="demo")) service.download_bas(__file__, 1) time.sleep(0.1) snapshot = service.poll([0]) assert snapshot.bus.init_status == INIT_FAILED assert snapshot.bus.message # carries the ZDevelop hint, not an empty string # ------------------------------------------------------------------- guards def test_motion_is_refused_before_the_bus_is_initialised(service): service.connect(ConnectionSpec(mode="demo")) for attempt in ( service.jog(0, True), service.move_abs(0, 100.0), service.move_rel(0, 10.0), service.home(0), ): assert not attempt.ok assert attempt.message def test_motion_is_refused_when_not_connected(service): assert not service.jog(0, True).ok assert not service.move_abs(0, 1.0).ok assert not service.stop(0).ok def test_motion_on_an_unknown_axis_is_refused(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) result = service.jog(99, True) assert not result.ok assert "99" in result.message or "0-4" in result.message @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) def test_a_non_finite_target_is_refused(service, bad): service.connect(ConnectionSpec(mode="demo")) _initialise(service) result = service.move_abs(0, bad) assert not result.ok assert result.message def test_home_needs_a_configured_mode(service): """0 is not a valid homing mode; inheriting one would be worse than saying so.""" service.connect(ConnectionSpec(mode="demo")) _initialise(service) result = service.home(0) assert not result.ok assert "0" in result.message def test_home_runs_once_a_mode_is_set(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) service.set_axis_params(0, AxisParams(home_mode=17)) assert service.home(0).ok def test_home_is_refused_on_rtex(service): """The vendor example refuses RTEX homing; so do we.""" class RtexTransport(DemoTransport): def user_var(self, name): return 1.0 if name == "BUS_TYPE" else super().user_var(name) rtex = MotionService(RtexTransport()) rtex.connect(ConnectionSpec(mode="demo")) _initialise(rtex) assert rtex.bus.bus_type == "rtex" result = rtex.home(0) assert not result.ok assert result.message # ------------------------------------------------------------------- motion def test_jog_and_stop_move_the_state_machine(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) assert service.jog(0, True).ok state = service.poll([0]).axis(0) assert state is not None and not state.idle assert service.stop(0).ok state = service.poll([0]).axis(0) assert state is not None and state.idle def test_absolute_and_relative_moves_change_the_command_position(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) assert service.move_abs(1, 250.0).ok assert service.poll([1]).axis(1).dpos == pytest.approx(250.0) assert service.move_rel(1, -50.0).ok assert service.poll([1]).axis(1).dpos == pytest.approx(200.0) def test_set_axis_params_writes_and_remembers(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) params = AxisParams(units=5.0, speed=200.0, accel=2000.0) assert service.set_axis_params(2, params).ok assert service.params(2) == params assert service.to_mm(2, 1000.0) == pytest.approx(1.0) def test_enable_toggles_the_axis(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) assert service.set_axis_enable(3, False).ok assert service.poll([3]).axis(3).enabled is False assert service.set_axis_enable(3, True).ok assert service.poll([3]).axis(3).enabled is True def test_stop_all_covers_every_axis(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) results = service.stop_all() assert len(results) == service.axis_count assert all(result.ok for result in results) def test_clear_alarm_reaches_the_drive(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) assert service.clear_alarm(0).ok # ----------------------------------------------------------------- bus data def test_sdo_round_trips_on_ethercat(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) assert service.sdo_write(0, 0x6040, 0, 3, 6).ok result, value = service.sdo_read(0, 0x6040, 0, 3) assert result.ok assert value == 6 def test_sdo_is_refused_on_rtex(service): class RtexTransport(DemoTransport): def user_var(self, name): return 1.0 if name == "BUS_TYPE" else super().user_var(name) rtex = MotionService(RtexTransport()) rtex.connect(ConnectionSpec(mode="demo")) _initialise(rtex) result, _value = rtex.sdo_read(0, 0x6040, 0, 3) assert not result.ok def test_rtex_is_refused_on_ethercat(service): service.connect(ConnectionSpec(mode="demo")) _initialise(service) result, _value = service.rtex_read(0, 1) assert not result.ok def test_bus_data_is_refused_before_the_bus_type_is_known(service): service.connect(ConnectionSpec(mode="demo")) result, _value = service.sdo_read(0, 0x6040, 0, 3) assert not result.ok # ------------------------------------------------------------------- polling def test_polling_reports_every_requested_axis(service): service.connect(ConnectionSpec(mode="demo")) snapshot = service.poll([0, 1, 2, 3, 4]) assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4] assert all(state.read_ok for state in snapshot.axes) def test_a_read_failure_is_marked_not_silently_zeroed(): """A failed read must not look like a position of zero.""" class FailingReads(DemoTransport): def get_dpos(self, axis): from orisys_app_shared.zmotion import ZAuxError raise ZAuxError(30000, "no response") service = MotionService(FailingReads()) service.connect(ConnectionSpec(mode="demo")) snapshot = service.poll([0]) state = snapshot.axis(0) assert state is not None assert state.read_ok is False assert state.error def test_language_switches_the_refusal_text(service): assert not service.jog(0, True).ok zh = service.jog(0, True).message service.set_language("en_US") en = service.jog(0, True).message assert zh != en