autocal layout first design
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user