287 lines
9.5 KiB
Python
287 lines
9.5 KiB
Python
"""The controller-command sequence: what is actually sent, and when.
|
|
|
|
These are the regressions that hardware found and the UI suite could not:
|
|
|
|
* ``ZAux_Execute`` must be handed a real reply buffer. Passing ``NULL`` is not
|
|
an error the library reports — it writes through the null pointer and takes the
|
|
process down, so the assertion has to be on the *arguments*.
|
|
* Re-initialisation must stop and start the task the program actually runs on,
|
|
and a firmware refusal that arrives only in the reply text must not be
|
|
reported as a re-initialisation in progress.
|
|
* A session that adopts a program already running on the controller must still
|
|
be able to re-initialise it.
|
|
|
|
No test here touches a controller or a DLL.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orisys_autocal.motion import (
|
|
DEFAULT_BAS_TASK,
|
|
INIT_OK,
|
|
INIT_RUNNING,
|
|
ConnectionSpec,
|
|
DemoTransport,
|
|
MotionService,
|
|
clamp_bas_task,
|
|
)
|
|
from orisys_autocal.motion.transport import _run_filename
|
|
|
|
#: The shipped programs, used as the file the service is asked to download.
|
|
#: Real files rather than per-test temporaries: pytest's ``tmp_path`` needs to
|
|
#: enumerate a directory outside the workspace, which a confined run denies.
|
|
_BAS_DIR = Path(__file__).resolve().parents[1] / "config" / "bas"
|
|
SHIPPED_ECAT = _BAS_DIR / "ECAT.bas"
|
|
SHIPPED_RTEX = _BAS_DIR / "RTEX.bas"
|
|
|
|
# ------------------------------------------------------------------ D1: the buffer
|
|
|
|
|
|
class _RecordingFunc:
|
|
"""A ``ZAux_Execute`` double: records its arguments, writes a reply."""
|
|
|
|
def __init__(self, reply: bytes = b"", code: int = 0) -> None:
|
|
self.calls: list[tuple] = []
|
|
self.reply = reply
|
|
self.code = code
|
|
|
|
def __call__(self, handle, command, response, length):
|
|
size = int(getattr(length, "value", length))
|
|
self.calls.append((handle, command, response, size))
|
|
if response is None:
|
|
raise AssertionError("ZAux_Execute was called with a NULL reply buffer")
|
|
if self.reply:
|
|
ctypes.memmove(response, self.reply, min(len(self.reply), size - 1))
|
|
return self.code
|
|
|
|
|
|
class _FakeDll:
|
|
def __init__(self, execute: _RecordingFunc) -> None:
|
|
self.ZAux_Execute = execute
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_fake_dll():
|
|
from orisys_app_shared.zmotion import ZmotionClient
|
|
|
|
client = ZmotionClient()
|
|
execute = _RecordingFunc(reply=b"Online command error, not find.\n")
|
|
client._dll = _FakeDll(execute) # noqa: SLF001 - the seam under test
|
|
client._handle = ctypes.c_void_p(0x1234) # noqa: SLF001 - pretend connected
|
|
return client, execute
|
|
|
|
|
|
def test_execute_passes_a_real_reply_buffer(client_with_fake_dll):
|
|
"""The null-buffer bug: a NULL psResponse is a native crash, not an error."""
|
|
client, execute = client_with_fake_dll
|
|
|
|
result = client.execute('RUN "ECAT.bas",4')
|
|
|
|
assert result.code == 0
|
|
_handle, command, response, length = execute.calls[0]
|
|
assert command == b'RUN "ECAT.bas",4'
|
|
assert response is not None
|
|
assert length >= 1024
|
|
|
|
|
|
def test_execute_returns_the_controllers_reply_text(client_with_fake_dll):
|
|
"""The firmware explains a refused RUN only in the reply."""
|
|
client, _execute = client_with_fake_dll
|
|
|
|
result = client.execute('RUN "nope.bas",4')
|
|
|
|
assert "not find" in str(result.value)
|
|
|
|
|
|
def test_transport_execute_hands_the_reply_back():
|
|
from orisys_autocal.motion.transport import NativeTransport
|
|
|
|
class _StubClient:
|
|
dll_dir = "stub"
|
|
|
|
def execute(self, command):
|
|
from orisys_app_shared.zmotion import ZmotionValue
|
|
|
|
return ZmotionValue(0, "controller says hello")
|
|
|
|
assert NativeTransport(_StubClient()).execute("?X") == "controller says hello"
|
|
|
|
|
|
# ------------------------------------------------- D2: the re-init command shape
|
|
|
|
|
|
class RecordingTransport(DemoTransport):
|
|
"""Demo platform that keeps every online command it was sent."""
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
super().__init__(**kwargs)
|
|
self.commands: list[str] = []
|
|
|
|
def execute(self, command: str) -> str:
|
|
self.commands.append(command)
|
|
return super().execute(command)
|
|
|
|
|
|
def _initialise(service, timeout: float = 5.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
|
|
service.poll([0])
|
|
time.sleep(0.02)
|
|
|
|
|
|
def test_reinit_stops_and_runs_the_configured_task():
|
|
"""Task 2 is not the vendor's task; stopping it restarts nothing."""
|
|
transport = RecordingTransport()
|
|
service = MotionService(transport, bas_task=4)
|
|
service.connect(ConnectionSpec(mode="demo"))
|
|
service.download_bas(str(SHIPPED_ECAT), 1)
|
|
_initialise(service)
|
|
|
|
transport.commands.clear()
|
|
result = service.reinit_bus()
|
|
|
|
assert result.ok, result.message
|
|
assert transport.commands == ["STOPTASK 4", 'RUN "ECAT.bas",4']
|
|
assert service.bus.init_status == INIT_RUNNING
|
|
|
|
|
|
def test_reinit_task_number_comes_from_configuration():
|
|
transport = RecordingTransport()
|
|
service = MotionService(transport, bas_task=6)
|
|
service.connect(ConnectionSpec(mode="demo"))
|
|
service.download_bas(str(SHIPPED_RTEX), 1)
|
|
_initialise(service)
|
|
|
|
transport.commands.clear()
|
|
assert service.reinit_bus().ok
|
|
assert transport.commands == ["STOPTASK 6", 'RUN "RTEX.bas",6']
|
|
|
|
|
|
def test_a_controller_refusal_in_the_reply_is_not_reported_as_running():
|
|
"""``ZAux_Execute`` returns 0 for a RUN naming a program it does not have."""
|
|
transport = RecordingTransport()
|
|
service = MotionService(transport, bas_task=4)
|
|
service.connect(ConnectionSpec(mode="demo"))
|
|
service.download_bas(str(SHIPPED_ECAT), 1)
|
|
_initialise(service)
|
|
|
|
# The controller has ECAT.bas; pretend the operator's file was renamed.
|
|
transport._bas_file = "something-else.bas" # noqa: SLF001 - the stub's state
|
|
result = service.reinit_bus()
|
|
|
|
assert not result.ok
|
|
assert "not find" in result.message
|
|
# And the bus must not be left claiming an initialisation is under way.
|
|
assert service.bus.init_status != INIT_RUNNING
|
|
|
|
|
|
# --------------------------------------------------- D3: the adopted-program path
|
|
|
|
|
|
def test_reinit_works_for_a_program_the_session_adopted():
|
|
"""The controller does not report the file name; the UI's remembered path does."""
|
|
|
|
class AdoptedProgram(RecordingTransport):
|
|
def user_var(self, name):
|
|
return {"BUS_TYPE": 0.0, "Bus_TotalAxisnum": 4.0, "Bus_InitStatus": 1.0}[name]
|
|
|
|
transport = AdoptedProgram()
|
|
service = MotionService(transport, bas_task=4)
|
|
assert service.connect(
|
|
ConnectionSpec(mode="demo"), bas_path=r"C:\somewhere\ECAT.bas"
|
|
).ok
|
|
|
|
# This stub never changes its answer, so let the 25 s settle window lapse
|
|
# rather than waiting for it: what is under test is the command, not the wait.
|
|
service._init_timeout = 0.01 # noqa: SLF001 - shortening the vendor window
|
|
time.sleep(0.02)
|
|
|
|
transport.commands.clear()
|
|
result = service.reinit_bus()
|
|
|
|
assert result.ok, result.message
|
|
assert transport.commands == ["STOPTASK 4", 'RUN "ECAT.bas",4']
|
|
|
|
|
|
def test_adopted_program_without_a_known_file_says_so():
|
|
"""The old message claimed no program was loaded while the bus panel said OK."""
|
|
|
|
class AdoptedProgram(DemoTransport):
|
|
def user_var(self, name):
|
|
return {"BUS_TYPE": 0.0, "Bus_TotalAxisnum": 4.0, "Bus_InitStatus": 1.0}[name]
|
|
|
|
service = MotionService(AdoptedProgram())
|
|
assert service.connect(ConnectionSpec(mode="demo")).ok
|
|
|
|
result = service.reinit_bus()
|
|
|
|
assert not result.ok
|
|
assert "文件名" in result.message or "file name" in result.message
|
|
|
|
|
|
def test_reinit_needs_the_bus_not_to_be_mid_run():
|
|
service = MotionService(DemoTransport())
|
|
service.connect(ConnectionSpec(mode="demo"))
|
|
service.download_bas(str(SHIPPED_ECAT), 1)
|
|
|
|
assert not service.reinit_bus().ok # still inside the demo's init delay
|
|
|
|
|
|
# ------------------------------------------------------- task-number hygiene
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, -1, 9, 100, None, "x", ""])
|
|
def test_a_nonsense_task_number_falls_back_to_the_vendor_default(value):
|
|
assert clamp_bas_task(value) == DEFAULT_BAS_TASK
|
|
|
|
|
|
@pytest.mark.parametrize("value", [1, 2, 4, 8, "4"])
|
|
def test_a_plausible_task_number_survives(value):
|
|
assert clamp_bas_task(value) == int(value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"command,expected",
|
|
[
|
|
('RUN "ECAT.bas",4', "ECAT.bas"),
|
|
('RUN "a/b c/R TEX.bas",2', "a/b c/R TEX.bas"),
|
|
("STOPTASK 4", ""),
|
|
],
|
|
)
|
|
def test_run_filename_parsing(command, expected):
|
|
assert _run_filename(command) == expected
|
|
|
|
|
|
# ------------------------------------------------------- the simulated controller
|
|
|
|
|
|
def test_the_demo_rejects_a_program_it_does_not_have():
|
|
"""The simulation must misbehave the way the firmware does, or tests lie."""
|
|
transport = DemoTransport()
|
|
transport.open(ConnectionSpec(mode="demo"))
|
|
transport.bas_download(str(SHIPPED_ECAT), 1)
|
|
|
|
assert transport.execute('RUN "ECAT.bas",4') == ""
|
|
assert "not find" in transport.execute('RUN "other.bas",4')
|
|
|
|
|
|
def test_the_demo_keeps_the_downloaded_file_name():
|
|
transport = DemoTransport()
|
|
transport.open(ConnectionSpec(mode="demo"))
|
|
transport.bas_download(str(SHIPPED_RTEX), 1)
|
|
|
|
assert transport._bas_file == "RTEX.bas" # noqa: SLF001 - the stub's state
|
|
|
|
|
|
def test_a_missing_connection_is_still_reported():
|
|
service = MotionService(DemoTransport())
|
|
result = service.reinit_bus()
|
|
assert not result.ok
|
|
assert result.message
|