autocal layout first design
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""The poller thread: serialisation, failure isolation, and clean teardown.
|
||||
|
||||
The two things that must never happen are a controller call on the GUI thread
|
||||
and two controller calls at once. Both are asserted here against a transport
|
||||
that records how it was used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
"""A *connected* demo platform that records thread and overlap of every call.
|
||||
|
||||
Connected on purpose: the service polls only ``BUS_TYPE`` and ``Bus_InitStatus``
|
||||
without a BAS program, so an unconnected transport would keep every read
|
||||
short and nothing could be observed. A ``_concurrent`` guard asserts no two
|
||||
calls ever overlap rather than merely asserting the maximum sum of started
|
||||
calls, which would be trivially satisfied.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
delay: float = 0.0,
|
||||
unexpected_on: set[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.delay = delay
|
||||
self.unexpected_on = unexpected_on or set()
|
||||
self.thread_ids: set[int] = set()
|
||||
self.max_concurrent = 0
|
||||
self.overlaps: list[str] = []
|
||||
self.calls: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
self._concurrent = 0
|
||||
# A fake "BAS program has run": the bus reports EtherCAT and ready.
|
||||
self._init_started = None
|
||||
self._preloaded = True
|
||||
|
||||
def open(self, spec) -> None:
|
||||
super().open(ConnectionSpec(mode="demo"))
|
||||
self._bas_loaded = True
|
||||
|
||||
def user_var(self, name: str) -> float:
|
||||
if name == "BUS_TYPE":
|
||||
return 0.0
|
||||
if name == "Bus_InitStatus":
|
||||
return 1.0
|
||||
if name == "Bus_TotalAxisnum":
|
||||
return 5.0
|
||||
return super().user_var(name)
|
||||
|
||||
def _enter(self, name: str) -> None:
|
||||
with self._lock:
|
||||
self._concurrent += 1
|
||||
if self._concurrent > 1:
|
||||
self.overlaps.append(name)
|
||||
self.max_concurrent = max(self.max_concurrent, self._concurrent)
|
||||
self.calls.append(name)
|
||||
self.thread_ids.add(threading.get_ident())
|
||||
if self.delay:
|
||||
time.sleep(self.delay)
|
||||
if name in self.unexpected_on:
|
||||
raise RuntimeError(f"simulated unexpected fault in {name}")
|
||||
|
||||
def _exit(self) -> None:
|
||||
with self._lock:
|
||||
self._concurrent -= 1
|
||||
|
||||
def get_dpos(self, axis: int) -> float:
|
||||
self._enter("get_dpos")
|
||||
try:
|
||||
return super().get_dpos(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_mpos(self, axis: int) -> float:
|
||||
self._enter("get_mpos")
|
||||
try:
|
||||
return super().get_mpos(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_axis_status(self, axis: int) -> int:
|
||||
self._enter("get_axis_status")
|
||||
try:
|
||||
return super().get_axis_status(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
def get_if_idle(self, axis: int) -> int:
|
||||
self._enter("get_if_idle")
|
||||
try:
|
||||
return super().get_if_idle(axis)
|
||||
finally:
|
||||
self._exit()
|
||||
|
||||
|
||||
def _connected_poller(transport: RecordingTransport, *, axes=(0,)) -> MotionPoller:
|
||||
service = MotionService(transport)
|
||||
assert service.connect(ConnectionSpec(mode="demo")).ok
|
||||
return MotionPoller(service, axes=list(axes))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qt_app_session(qt_app):
|
||||
return qt_app
|
||||
|
||||
def _drain(app, seconds: float = 1.0) -> None:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_completed_commands_are_reported_and_none_are_lost(qt_app):
|
||||
transport = RecordingTransport()
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
repo = {"finished": [], "failed": []}
|
||||
poller.commandFinished.connect(lambda token, result: repo["finished"].append(token))
|
||||
poller.commandFailed.connect(lambda token, message: repo["failed"].append(token))
|
||||
|
||||
tokens = [poller.submit(lambda service: None) for _ in range(5)]
|
||||
assert _wait_for(lambda: len(repo["finished"]) == 5, qt_app)
|
||||
assert sorted(repo["finished"]) == sorted(tokens)
|
||||
assert repo["failed"] == []
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_every_controller_call_happens_on_one_worker_thread(qt_app):
|
||||
transport = RecordingTransport()
|
||||
poller = _connected_poller(transport, axes=(0, 1))
|
||||
try:
|
||||
assert _wait_for(lambda: "get_dpos" in transport.calls, qt_app)
|
||||
poller.submit(lambda service: None)
|
||||
_drain(qt_app, 0.3)
|
||||
assert len(transport.thread_ids) == 1, transport.thread_ids
|
||||
assert threading.get_ident() not in transport.thread_ids, (
|
||||
"a controller call ran on the test's (GUI) thread"
|
||||
)
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_two_controller_calls_never_overlap(qt_app):
|
||||
"""A slow poll plus queued commands must still serialise."""
|
||||
transport = RecordingTransport(delay=0.02)
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
for _ in range(4):
|
||||
poller.submit(lambda service: None)
|
||||
_drain(qt_app, 0.8)
|
||||
assert transport.max_concurrent == 1, (
|
||||
f"{transport.max_concurrent} controller calls overlapped at once"
|
||||
)
|
||||
assert not transport.overlaps, transport.overlaps
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_an_unexpected_fault_is_reported_and_does_not_kill_the_poller(qt_app):
|
||||
"""A non-``ZAuxError`` from inside a command must not stop the timer."""
|
||||
transport = RecordingTransport(unexpected_on={"get_dpos"})
|
||||
poller = _connected_poller(transport)
|
||||
try:
|
||||
failures = []
|
||||
poller.commandFailed.connect(lambda token, message: failures.append((token, message)))
|
||||
|
||||
# The submitted action asks for the position directly, so the fault
|
||||
# surfaces from the command rather than being absorbed by a poll.
|
||||
poller.submit(lambda service: service.transport.get_dpos(0))
|
||||
assert _wait_for(lambda: bool(failures), qt_app)
|
||||
assert failures[0][1]
|
||||
|
||||
# The poller is still alive: later snapshots keep arriving.
|
||||
before = len(transport.calls)
|
||||
_drain(qt_app, 0.4)
|
||||
assert len(transport.calls) > before
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_shutdown_stops_the_thread_and_is_idempotent(qt_app):
|
||||
poller = _connected_poller(RecordingTransport())
|
||||
_drain(qt_app, 0.2)
|
||||
poller.shutdown(timeout_ms=3000)
|
||||
poller.shutdown(timeout_ms=3000) # must not raise or hang
|
||||
|
||||
|
||||
def test_a_refused_command_still_arrives_with_its_reason(qt_app):
|
||||
"""An unconnected service refuses in the guard, without touching the DLL."""
|
||||
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
|
||||
try:
|
||||
results = []
|
||||
poller.commandFinished.connect(lambda token, result: results.append(result))
|
||||
poller.submit(lambda service: service.jog(0, True))
|
||||
assert _wait_for(lambda: bool(results), qt_app)
|
||||
assert results[0].ok is False
|
||||
assert results[0].message
|
||||
finally:
|
||||
poller.shutdown()
|
||||
|
||||
|
||||
def test_connect_and_disconnect_round_trip_through_the_poller(qt_app):
|
||||
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
|
||||
try:
|
||||
states = []
|
||||
poller.connectionChanged.connect(states.append)
|
||||
poller.connect_controller(ConnectionSpec(mode="demo"))
|
||||
assert _wait_for(lambda: poller.service.connected, qt_app)
|
||||
poller.disconnect_controller()
|
||||
assert _wait_for(lambda: not poller.service.connected, qt_app)
|
||||
finally:
|
||||
poller.shutdown()
|
||||
Reference in New Issue
Block a user