fixed platform not moving problem
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"""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
|
||||
@@ -52,3 +52,45 @@ def test_an_unknown_code_points_at_zdevelop():
|
||||
|
||||
def test_a_documented_code_gets_no_hint_appended():
|
||||
assert errors.describe_with_hint(30000) == errors.describe(30000)
|
||||
|
||||
|
||||
# ------------------------------------------------- host-library (PC) codes
|
||||
|
||||
|
||||
def test_a_host_library_code_is_not_sent_to_the_firmware_table():
|
||||
"""``20008`` is not a controller code: ZDevelop cannot have it.
|
||||
|
||||
It is what ``ZAux_OpenEth`` returned on the first, transient attempt at the
|
||||
bench, and the old text sent the operator to a table that cannot contain it.
|
||||
"""
|
||||
message = errors.describe_with_hint(20008)
|
||||
|
||||
assert "20008" in message
|
||||
assert errors.UNKNOWN_HINT not in message
|
||||
assert "上位机" in message
|
||||
assert errors.HOST_LIB_HINT_ZH in message
|
||||
|
||||
|
||||
def test_the_host_library_hint_is_localised():
|
||||
zh = errors.describe_with_hint(20008)
|
||||
en = errors.describe_with_hint(20008, language="en_US")
|
||||
|
||||
assert errors.HOST_LIB_HINT_EN in en
|
||||
assert zh != en
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [20000, 20008, 25000, 29999])
|
||||
def test_the_host_library_range_is_recognised(code):
|
||||
assert errors.is_host_lib_error(code)
|
||||
assert "错误码" not in errors.describe(code)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [19999, 30000, 30005, 12345, 222, 0])
|
||||
def test_codes_outside_the_host_range_are_not_labelled_as_library_faults(code):
|
||||
assert not errors.is_host_lib_error(code)
|
||||
|
||||
|
||||
def test_a_controller_code_keeps_the_firmware_hint():
|
||||
"""222 is firmware (``Other error``), so ZDevelop is still the right pointer."""
|
||||
message = errors.describe_with_hint(222)
|
||||
assert errors.UNKNOWN_HINT in message
|
||||
|
||||
@@ -79,6 +79,16 @@ def test_the_default_preferences_file_matches_the_schema_shape():
|
||||
assert key in schema, f"preferences.default.json has an unknown key: {key}"
|
||||
|
||||
|
||||
def test_the_shipped_defaults_carry_the_controller_task_number():
|
||||
"""Re-initialisation names a task; if this key is missing every re-init is wrong."""
|
||||
from orisys_autocal.motion import DEFAULT_BAS_TASK
|
||||
|
||||
shipped = json.loads(
|
||||
(LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Unit-to-millimetre conversion.
|
||||
"""Unit conversion: linear mm and rotary degrees.
|
||||
|
||||
The reference program carries the bench measurement in a comment::
|
||||
Bench notes::
|
||||
|
||||
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
|
||||
# 当量5 → 1000单位=1mm,不是100单位=1mm
|
||||
# 轴2: 12500 用户单位 = 90°
|
||||
# 轴3: 29500 用户单位 = 90°
|
||||
|
||||
These are the assertions that keep that comment honest.
|
||||
These are the assertions that keep those notes honest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,8 +18,12 @@ import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
deg_to_user_units,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
units_per_degree,
|
||||
user_units_to_deg,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
@@ -71,3 +77,35 @@ def test_zero_falls_back_because_it_would_divide(text):
|
||||
@pytest.mark.parametrize(("text", "expected"), [("5", 5.0), (" 2.5 ", 2.5), ("-3", -3.0)])
|
||||
def test_usable_entries_are_parsed(text, expected):
|
||||
assert safe_float(text, 42.0) == expected
|
||||
|
||||
|
||||
def test_rotary_bench_constants():
|
||||
assert ROTARY_UNITS_PER_90_DEG[2] == 12500.0
|
||||
assert ROTARY_UNITS_PER_90_DEG[3] == 29500.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("axis", "user_units", "expected_deg"),
|
||||
[
|
||||
(2, 12500.0, 90.0),
|
||||
(2, 25000.0, 180.0),
|
||||
(3, 29500.0, 90.0),
|
||||
(3, 14750.0, 45.0),
|
||||
(2, 0.0, 0.0),
|
||||
(3, -29500.0, -90.0),
|
||||
],
|
||||
)
|
||||
def test_rotary_conversion_matches_the_bench(axis, user_units, expected_deg):
|
||||
assert user_units_to_deg(axis, user_units) == pytest.approx(expected_deg)
|
||||
assert deg_to_user_units(axis, expected_deg) == pytest.approx(user_units)
|
||||
|
||||
|
||||
def test_units_per_degree_is_units_over_ninety():
|
||||
assert units_per_degree(2) == pytest.approx(12500.0 / 90.0)
|
||||
assert units_per_degree(3) == pytest.approx(29500.0 / 90.0)
|
||||
|
||||
|
||||
def test_uncalibrated_rotary_axis_is_not_a_number():
|
||||
assert math.isnan(units_per_degree(0))
|
||||
assert math.isnan(user_units_to_deg(1, 1000.0))
|
||||
assert math.isnan(deg_to_user_units(4, 90.0))
|
||||
|
||||
Reference in New Issue
Block a user