"""Non-blocking motions, and a new one taking the place of the old.""" import threading import pytest from dora_ulite6.config import Config from dora_ulite6.control import ( ACCEPTED, DONE, MOVING, PREEMPTED, Control, ServiceCall, ) from tests.conftest import FakeArm, wait_for @pytest.fixture def control(monkeypatch): """Start a Control over a fake arm whose motions can be held open.""" arms = [] def build(config): arm = FakeArm(config) arm.block = threading.Event() arms.append(arm) return arm monkeypatch.setattr("dora_ulite6.control.ULite6", build) control = Control(Config(telemetry_interval_s=0.01, reconnect_interval_s=0.01)) control.arms = arms control.start() wait_for(lambda: control.robot is not None) yield control arms[0].block.set() control.shutdown() class Mail: """Accumulates what the control threads produced, so nothing is missed.""" def __init__(self, control): self.control = control self.messages = [] def drain(self): self.messages.extend(self.control.take_messages()) return self.messages def states(self, request_id): """Return the motion_state lifecycle published for one request, in order.""" self.drain() return [ m.fields["state"] for m in self.messages if m.output == "motion_state" and m.fields["request_id"] == request_id ] def responses(self, request_id): self.drain() return [ m.fields for m in self.messages if m.output == "response" and m.metadata.get("request_id") == request_id ] def wait_for_state(self, request_id, state): wait_for(lambda: state in self.states(request_id)) def motion(request_id, wait=False, service="home", **args): return ServiceCall(request_id=request_id, row={"service": service, **args}, wait=wait) def test_a_non_blocking_motion_is_answered_before_the_arm_stops(control): mail = Mail(control) control.start_motion(motion("m1", wait=False)) wait_for(lambda: mail.responses("m1")) # The caller has its answer while the arm is still held open — the response # was never waiting on the motion, so the motion has not even been reported # as finished. assert not control.arms[0].block.is_set() assert DONE not in mail.states("m1") assert mail.responses("m1")[0] == { "ok": True, "service": "home", "message": "accepted", "accepted": True, } def test_a_non_blocking_motion_reports_completion_only_on_the_state(control): mail = Mail(control) control.start_motion(motion("m1", wait=False)) mail.wait_for_state("m1", MOVING) control.arms[0].block.set() mail.wait_for_state("m1", DONE) assert mail.states("m1") == [ACCEPTED, MOVING, DONE] # Exactly one response, the acknowledgement. A second would break the # one-response-per-request contract the caller correlates by. assert len(mail.responses("m1")) == 1 def test_a_blocking_motion_is_answered_only_when_the_arm_stops(control): mail = Mail(control) control.start_motion(motion("m1", wait=True)) mail.wait_for_state("m1", MOVING) assert mail.responses("m1") == [] control.arms[0].block.set() wait_for(lambda: mail.responses("m1")) assert mail.responses("m1")[0]["ok"] is True def test_accepted_is_published_before_the_thread_reaches_the_motion(control): # The race a bare moving/idle flag leaves open: a caller polling right after # its request must not read a state that says nothing is moving. mail = Mail(control) control.arms[0].block = threading.Event() control.start_motion(motion("m1", wait=False)) assert mail.states("m1")[0] == ACCEPTED def test_a_new_motion_preempts_the_one_running(control): mail = Mail(control) control.start_motion(motion("m1", wait=False)) mail.wait_for_state("m1", MOVING) control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0)) mail.wait_for_state("m1", PREEMPTED) assert "stop" in control.arms[0].calls assert mail.states("m1") == [ACCEPTED, MOVING, PREEMPTED] def test_the_preempting_motion_then_runs(control): mail = Mail(control) control.start_motion(motion("m1", wait=False)) mail.wait_for_state("m1", MOVING) control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0)) mail.wait_for_state("m1", PREEMPTED) control.arms[0].stopped = False control.arms[0].block.set() mail.wait_for_state("m2", DONE) # The software stop the preemption caused is cleared before the new move. assert "clear" in control.arms[0].calls assert any(isinstance(call, tuple) and call[0] == "move_to" for call in control.arms[0].calls) def test_a_preempted_blocking_caller_is_released_with_a_failure(control): mail = Mail(control) control.start_motion(motion("m1", wait=True)) mail.wait_for_state("m1", MOVING) control.start_motion(motion("m2", wait=False)) wait_for(lambda: mail.responses("m1")) answer = mail.responses("m1")[0] assert answer["ok"] is False assert "preempted by m2" in answer["message"] def test_a_motion_still_waiting_its_turn_is_dropped_without_moving_the_arm(control): # Driven straight at the branch rather than through the threads: whether a # preemption lands before or after the control thread picks the motion up is # a race by nature, and only one side of it is this behaviour. mail = Mail(control) call = motion("m2", wait=True, service="move_to", x=1.0, y=2.0, z=3.0) call.preempted_by = "m3" control._run_motion(call) assert mail.states("m2") == [PREEMPTED] assert mail.responses("m2")[0]["ok"] is False assert "preempted by m3" in mail.responses("m2")[0]["message"] assert not any(isinstance(c, tuple) and c[0] == "move_to" for c in control.arms[0].calls) def test_every_preempted_request_is_answered_exactly_once(control): mail = Mail(control) control.start_motion(motion("m1", wait=True)) mail.wait_for_state("m1", MOVING) control.start_motion(motion("m2", wait=True)) mail.wait_for_state("m1", PREEMPTED) # m2 may or may not have started by now; either way m3 takes its place, and # what must hold is that nobody is left without an answer. control.start_motion(motion("m3", wait=True)) control.arms[0].block.set() for request_id in ("m1", "m2", "m3"): wait_for(lambda rid=request_id: mail.responses(rid)) assert len(mail.responses(request_id)) == 1, request_id def test_a_preemption_that_stopped_nothing_still_clears_for_the_one_that_did(control): # A preemption of a motion still waiting its turn stops nothing, and must # not swallow the pending clear owed by an earlier one that did. mail = Mail(control) control.start_motion(motion("m1", wait=False)) mail.wait_for_state("m1", MOVING) control.start_motion(motion("m2", wait=False)) # stops m1 mail.wait_for_state("m1", PREEMPTED) control.start_motion(motion("m3", wait=False)) # may stop nothing control.arms[0].block.set() mail.wait_for_state("m3", DONE) assert "clear" in control.arms[0].calls def test_a_service_that_does_not_move_the_arm_never_preempts(control): mail = Mail(control) control.start_motion(motion("m1", wait=False)) mail.wait_for_state("m1", MOVING) control.submit(ServiceCall(request_id="s1", row={"service": "clear"})) assert PREEMPTED not in mail.states("m1") assert "stop" not in control.arms[0].calls