Files
orisys-app/apps/autocal/tests/test_poller.py
T
2026-09-27 19:20:43 +08:00

238 lines
7.8 KiB
Python

"""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()