fixed platform not moving problem

This commit is contained in:
2026-09-23 09:52:45 +08:00
parent f1bec62ca4
commit b3279cc2a6
21 changed files with 1200 additions and 59 deletions
@@ -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
+42
View File
@@ -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}"
+41 -3
View File
@@ -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))