663 lines
24 KiB
Python
663 lines
24 KiB
Python
"""Offline tests for the manual-control thin client, in particular the
|
|
adaptation to the PSI 'Motor Control Unit' box (Pi 3B, 7" 800x480 panel,
|
|
MCP3008 joystick ADC, Ethernet/PoE link).
|
|
|
|
No Pi, no EPICS, no display: the ADC is faked, gpiozero is expected to be
|
|
absent (the backend must degrade), and the GUI tests are skipped unless a
|
|
display is available.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from eco.manual_control.demo import build_fake_beamline
|
|
from eco.manual_control.remote.client import RemoteControlClient
|
|
from eco.manual_control.remote.pi_hardware import (
|
|
HardwareInput,
|
|
Mcp3008Adc,
|
|
config_for,
|
|
)
|
|
from eco.manual_control.remote.server import RemoteControlServer
|
|
from eco.manual_control.remote.transport import socketpair_transports
|
|
|
|
TOKEN = "test-token"
|
|
|
|
|
|
class FakeSpiDev:
|
|
"""spidev stand-in for an MCP3008: channel -> 10-bit count."""
|
|
|
|
max_speed_hz = 0
|
|
|
|
def __init__(self):
|
|
self.values = {0: 1023, 1: 512, 2: 512} # sw released, y/x centred
|
|
|
|
def open(self, bus, device):
|
|
self.bus, self.device = bus, device
|
|
|
|
def xfer2(self, msg):
|
|
value = self.values[(msg[1] >> 4) - 8]
|
|
return [0, (value >> 8) & 3, value & 0xFF]
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_spi(monkeypatch):
|
|
spi = FakeSpiDev()
|
|
monkeypatch.setitem(sys.modules, "spidev", types.SimpleNamespace(SpiDev=lambda: spi))
|
|
return spi
|
|
|
|
|
|
def _serve(token=None):
|
|
"""A box-side transport wired to a server, without any network."""
|
|
srv_tr, cli_tr = socketpair_transports()
|
|
RemoteControlServer(
|
|
build_fake_beamline(), srv_tr, root_name="beamline",
|
|
step_sizes=[0.001, 0.01, 0.1, 1, 10], token=token,
|
|
).start()
|
|
return cli_tr
|
|
|
|
|
|
def _box_listener(port, token=TOKEN):
|
|
"""A listening box; the caller plays the operator via .pending()."""
|
|
from eco.manual_control.remote.box_link import BoxListener
|
|
|
|
return BoxListener(port=port, token=token, bind="127.0.0.1")
|
|
|
|
|
|
def _next_request(listener, timeout=5.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
request = listener.pending()
|
|
if request is not None:
|
|
return request
|
|
time.sleep(0.02)
|
|
return None
|
|
|
|
|
|
def _wait(predicate, timeout=3.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.02)
|
|
return False
|
|
|
|
|
|
def test_caller_with_a_bad_token_never_reaches_the_operator():
|
|
"""The box refuses the wrong token itself - the operator is not bothered."""
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8769)
|
|
try:
|
|
session = connect_to_box(build_fake_beamline(), root_name="b",
|
|
host="127.0.0.1", port=8769, token="wrong",
|
|
token_file=None)
|
|
assert _wait(lambda: session.state == "closed")
|
|
assert "token" in (session.reason or "")
|
|
assert _next_request(listener, timeout=0.5) is None
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_accepted_session_drives_the_box():
|
|
"""Mode 1: nothing connected, a session calls, the operator accepts."""
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8768)
|
|
try:
|
|
session = connect_to_box(build_fake_beamline(), root_name="beamline",
|
|
host="127.0.0.1", port=8768, token=TOKEN,
|
|
token_file=None)
|
|
request = _next_request(listener)
|
|
assert request is not None
|
|
assert session.state == "waiting for the operator"
|
|
assert "@" in request.who and request.namespace == "beamline"
|
|
client = RemoteControlClient(request.accept()).start()
|
|
assert _wait(lambda: bool(client.entries))
|
|
assert _wait(lambda: session.connected)
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_rejected_session_closes_and_leaves_the_current_one_alone():
|
|
"""Mode 2: the operator keeps the session already driving the box."""
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8767)
|
|
try:
|
|
first = connect_to_box(build_fake_beamline(), root_name="first",
|
|
host="127.0.0.1", port=8767, token=TOKEN, token_file=None)
|
|
client = RemoteControlClient(_next_request(listener).accept()).start()
|
|
assert _wait(lambda: first.connected)
|
|
|
|
second = connect_to_box(build_fake_beamline(), root_name="second",
|
|
host="127.0.0.1", port=8767, token=TOKEN, token_file=None)
|
|
_next_request(listener).reject("the box stayed with the current session")
|
|
assert _wait(lambda: second.state == "closed")
|
|
assert "stayed with" in (second.reason or "")
|
|
assert first.connected, "declining must not disturb the current session"
|
|
assert client.connected
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_takeover_closes_the_displaced_session_with_a_reason():
|
|
"""Mode 2: the operator hands the box over; the old session closes itself."""
|
|
from eco.manual_control.remote.box_link import say_bye
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8766)
|
|
try:
|
|
first = connect_to_box(build_fake_beamline(), root_name="first",
|
|
host="127.0.0.1", port=8766, token=TOKEN, token_file=None)
|
|
first_client = RemoteControlClient(_next_request(listener).accept()).start()
|
|
assert _wait(lambda: first.connected)
|
|
|
|
second = connect_to_box(build_fake_beamline(), root_name="second",
|
|
host="127.0.0.1", port=8766, token=TOKEN, token_file=None)
|
|
request = _next_request(listener)
|
|
say_bye(first_client.tr, f"the box was handed to {request.who}") # what the GUI does
|
|
second_client = RemoteControlClient(request.accept()).start()
|
|
|
|
assert _wait(lambda: bool(second_client.entries))
|
|
assert _wait(lambda: second.connected)
|
|
assert _wait(lambda: not first.connected), "displaced session stayed open"
|
|
assert "handed to" in (first.reason or "")
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_psi_box_jog_axis_is_inverted(fake_spi):
|
|
"""The jog axis is flipped relative to the raw ADC reading.
|
|
|
|
On the real box the stick jogged the wrong way, so the preset inverts Y:
|
|
a rising Y reading must now produce a negative jog. (Which physical
|
|
direction that is depends on how the stick is mounted - the point is
|
|
that it is the opposite of the raw axis.)
|
|
"""
|
|
events = []
|
|
|
|
class Recorder:
|
|
jog_start = lambda self, d: events.append(("jog_start", d)) # noqa: E731
|
|
jog_stop = lambda self: events.append(("jog_stop",)) # noqa: E731
|
|
encoder_rotate = lambda self, d: None # noqa: E731
|
|
encoder_short_press = lambda self: None # noqa: E731
|
|
encoder_long_press = lambda self: None # noqa: E731
|
|
|
|
cfg = config_for("psi-mcu-box")
|
|
assert cfg["invert_y"] is True, "the jog axis must stay inverted for this box"
|
|
|
|
hw = HardwareInput.__new__(HardwareInput)
|
|
hw.client = Recorder()
|
|
hw.cfg = cfg
|
|
hw.on_activity = lambda: None
|
|
hw._stop = threading.Event()
|
|
hw._jog_dir = 0
|
|
hw._nav_latch = 0
|
|
hw._joy_sw_down = False
|
|
hw.adc = Mcp3008Adc()
|
|
threading.Thread(target=hw._joystick_loop, daemon=True).start()
|
|
try:
|
|
fake_spi.values[cfg["joy_y_chan"]] = 1023 # raw reading rises
|
|
assert _wait(lambda: events)
|
|
assert events[0] == ("jog_start", -1), f"jog axis not inverted: {events[0]}"
|
|
finally:
|
|
hw.close()
|
|
|
|
|
|
def test_psi_box_preset_matches_the_box_wiring():
|
|
cfg = config_for("psi-mcu-box")
|
|
assert (cfg["enc_a"], cfg["enc_b"], cfg["enc_sw"]) == (5, 6, 13)
|
|
assert cfg["adc"] == "mcp3008"
|
|
assert (cfg["joy_x_chan"], cfg["joy_y_chan"]) == (2, 1) # VRX=CH2, VRY=CH1
|
|
assert cfg["joy_sw_chan"] == 0 and cfg["joy_sw"] is None # switch on the ADC
|
|
assert cfg["extra_sw"] == 26
|
|
|
|
|
|
def test_unknown_preset_rejected():
|
|
with pytest.raises(ValueError):
|
|
config_for("nope")
|
|
|
|
|
|
def test_mcp3008_scaling(fake_spi):
|
|
adc = Mcp3008Adc()
|
|
fake_spi.values[2] = 1023
|
|
assert adc.read_frac(2) == pytest.approx(1.0)
|
|
fake_spi.values[2] = 0
|
|
assert adc.read_frac(2) == 0.0
|
|
fake_spi.values[2] = 512
|
|
assert adc.read_frac(2) == pytest.approx(0.5, abs=0.01)
|
|
|
|
|
|
def test_encoder_and_joystick_fail_independently(fake_spi):
|
|
"""A dead ADC must not take the encoder down with it, or vice versa.
|
|
|
|
On the real box both were dead at once because a single exception in
|
|
setup killed every physical control; the two are separate hardware
|
|
(GPIO vs SPI) and must degrade separately.
|
|
"""
|
|
# gpiozero finds no pin factory off-Pi, but the (faked) SPI ADC works
|
|
hw = HardwareInput(RemoteControlClient(_serve()), preset="psi-mcu-box")
|
|
try:
|
|
assert hw.encoder_ok is False
|
|
assert hw.encoder_error is not None
|
|
assert hw.joystick_ok is True
|
|
assert hw.available is True # half the controls still work
|
|
finally:
|
|
hw.close()
|
|
|
|
|
|
def test_hardware_input_degrades_when_nothing_is_available():
|
|
"""No gpiozero pin factory and no ADC -> GUI-only mode, no crash."""
|
|
hw = HardwareInput(RemoteControlClient(_serve()), preset="psi-mcu-box",
|
|
config={"adc": "none"})
|
|
try:
|
|
assert hw.available is False
|
|
assert hw.encoder_ok is False and hw.joystick_ok is False
|
|
assert hw.joystick_error is not None
|
|
finally:
|
|
hw.close()
|
|
|
|
|
|
def test_joystick_loop_drives_jog_navigate_and_button(fake_spi):
|
|
events = []
|
|
|
|
class Recorder:
|
|
jog_start = lambda self, d: events.append(("jog_start", d)) # noqa: E731
|
|
jog_stop = lambda self: events.append(("jog_stop",)) # noqa: E731
|
|
encoder_rotate = lambda self, d: events.append(("rotate", d)) # noqa: E731
|
|
encoder_short_press = lambda self: events.append(("ok",)) # noqa: E731
|
|
encoder_long_press = lambda self: events.append(("disarm",)) # noqa: E731
|
|
|
|
hw = HardwareInput.__new__(HardwareInput)
|
|
hw.client = Recorder()
|
|
hw.cfg = config_for("psi-mcu-box")
|
|
hw.on_activity = lambda: None
|
|
hw._stop = threading.Event()
|
|
hw._jog_dir = 0
|
|
hw._nav_latch = 0
|
|
hw._joy_sw_down = False
|
|
hw.adc = Mcp3008Adc()
|
|
threading.Thread(target=hw._joystick_loop, daemon=True).start()
|
|
try:
|
|
fake_spi.values[1] = 1023 # rising Y -> jog -1 (the preset inverts Y)
|
|
assert _wait(lambda: ("jog_start", -1) in events)
|
|
fake_spi.values[1] = 512 # centred -> stop
|
|
assert _wait(lambda: ("jog_stop",) in events)
|
|
fake_spi.values[2] = 1023 # stick right -> exactly one detent
|
|
assert _wait(lambda: ("rotate", 1) in events)
|
|
time.sleep(0.15)
|
|
assert [e[0] for e in events].count("rotate") == 1
|
|
fake_spi.values[0] = 0 # stick button (active low) -> one OK
|
|
assert _wait(lambda: ("ok",) in events)
|
|
time.sleep(0.15)
|
|
assert [e[0] for e in events].count("ok") == 1
|
|
finally:
|
|
hw.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"size,expect_landscape", [((800, 480), True), ((480, 320), False)]
|
|
)
|
|
def test_gui_fits_the_panel_exactly(size, expect_landscape):
|
|
tk = pytest.importorskip("tkinter")
|
|
from eco.manual_control.mock_gui import ManualControlApp
|
|
|
|
try:
|
|
probe = tk.Tk()
|
|
except tk.TclError:
|
|
pytest.skip("no display")
|
|
probe.destroy()
|
|
|
|
client = RemoteControlClient(_serve()).start()
|
|
assert _wait(lambda: bool(client.entries))
|
|
app = ManualControlApp(client, mock=False, screen_size=size, font_scale=1.4)
|
|
try:
|
|
app.update_idletasks()
|
|
app.update()
|
|
assert (app.device.winfo_width(), app.device.winfo_height()) == size
|
|
assert app.landscape is expect_landscape
|
|
finally:
|
|
app.destroy()
|
|
|
|
|
|
def test_namespace_control_box_survives_an_unreachable_box_host():
|
|
from eco.utilities.config import Namespace
|
|
|
|
ns = Namespace(name="ns_no_box")
|
|
try:
|
|
ns.start_eco_control_box(port=8794, bind="127.0.0.1", token=TOKEN,
|
|
token_file=None, box_host="pi@no-such-box.invalid")
|
|
finally:
|
|
ns.stop_eco_control_box()
|
|
|
|
|
|
def _namespace_with_control_box(name="bernina"):
|
|
import sys
|
|
import types
|
|
|
|
from eco.utilities.config import Namespace
|
|
|
|
mod_name = f"fake_instrument_{name}"
|
|
mod = types.ModuleType(mod_name)
|
|
sys.modules[mod_name] = mod
|
|
ns = Namespace(name=name, root_module=mod_name)
|
|
ns.append_obj("ControlBox", name="manual_control_box",
|
|
module_name="eco.manual_control.control_box", lazy=True)
|
|
return ns, mod
|
|
|
|
|
|
def test_control_box_manual_covers_the_operational_basics():
|
|
"""The docstring is the manual - keep it complete."""
|
|
from eco.manual_control.control_box import ControlBox
|
|
|
|
manual = ControlBox.__doc__
|
|
for topic in ["ssh gac-bernina@ecobox", "/etc/eco-control-box.token",
|
|
"waiting screen", "pin factory", "python3-lgpio",
|
|
"invert_x", "eco-control-box", "--probe"]:
|
|
assert topic in manual, f"manual does not mention {topic!r}"
|
|
|
|
|
|
def test_namespace_start_eco_control_box_offers_itself_to_the_box():
|
|
"""bernina.namespace.start_eco_control_box() -> the box asks the operator."""
|
|
from eco.elements.adjustable import DummyAdjustable
|
|
from eco.utilities.config import Namespace
|
|
|
|
listener = _box_listener(8765)
|
|
ns = Namespace(name="ns_offer")
|
|
ns.append_obj(DummyAdjustable, name="theta", module_name=None)
|
|
try:
|
|
session = ns.start_eco_control_box(box_host="127.0.0.1", port=8765,
|
|
token=TOKEN, token_file=None)
|
|
request = _next_request(listener)
|
|
assert request is not None and request.namespace == "ns_offer"
|
|
client = RemoteControlClient(request.accept()).start()
|
|
assert _wait(lambda: bool(client.entries))
|
|
assert {e.name for e in client.entries} == {"theta"}
|
|
# idempotent: a second call returns the same session
|
|
assert ns.start_eco_control_box(box_host="127.0.0.1", port=8765) is session
|
|
finally:
|
|
ns.stop_eco_control_box()
|
|
listener.close()
|
|
|
|
|
|
def test_session_notices_the_box_going_away():
|
|
"""If the box vanishes, the session must close itself, not look connected."""
|
|
listener = _box_listener(8764)
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
try:
|
|
session = connect_to_box(build_fake_beamline(), root_name="beamline",
|
|
host="127.0.0.1", port=8764, token=TOKEN, token_file=None)
|
|
client = RemoteControlClient(_next_request(listener).accept()).start()
|
|
assert _wait(lambda: session.connected)
|
|
client.tr.close() # the box app dies / is restarted
|
|
assert _wait(lambda: not session.connected), "session did not notice the box leaving"
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_box_serves_a_new_session_after_the_previous_one_ended():
|
|
"""The box keeps listening, so the next session can simply call again."""
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8763)
|
|
try:
|
|
first = connect_to_box(build_fake_beamline(), root_name="first",
|
|
host="127.0.0.1", port=8763, token=TOKEN, token_file=None)
|
|
first_client = RemoteControlClient(_next_request(listener).accept()).start()
|
|
assert _wait(lambda: first.connected)
|
|
first.stop()
|
|
|
|
second = connect_to_box(build_fake_beamline(), root_name="second",
|
|
host="127.0.0.1", port=8763, token=TOKEN, token_file=None)
|
|
second_client = RemoteControlClient(_next_request(listener).accept()).start()
|
|
assert _wait(lambda: bool(second_client.entries))
|
|
assert _wait(lambda: second.connected)
|
|
finally:
|
|
listener.close()
|
|
|
|
|
|
def test_a_silent_caller_cannot_block_the_box():
|
|
"""A caller that connects and says nothing must not lock everyone out."""
|
|
import socket
|
|
|
|
from eco.manual_control.remote.serve import connect_to_box
|
|
|
|
listener = _box_listener(8762)
|
|
mute = socket.create_connection(("127.0.0.1", 8762)) # connects, sends no hello
|
|
try:
|
|
session = connect_to_box(build_fake_beamline(), root_name="real",
|
|
host="127.0.0.1", port=8762, token=TOKEN, token_file=None)
|
|
request = _next_request(listener)
|
|
assert request is not None, "a silent caller blocked the listener"
|
|
assert request.namespace == "real"
|
|
client = RemoteControlClient(request.accept()).start()
|
|
assert _wait(lambda: bool(client.entries))
|
|
assert _wait(lambda: session.connected)
|
|
finally:
|
|
mute.close()
|
|
listener.close()
|
|
|
|
|
|
def test_control_box_component_offers_the_namespace_it_lives_in():
|
|
"""bernina.manual_control_box.start() with no arguments offers bernina."""
|
|
from eco.elements.adjustable import DummyAdjustable
|
|
|
|
listener = _box_listener(8761)
|
|
ns, mod = _namespace_with_control_box(name="bernina")
|
|
ns.append_obj(DummyAdjustable, name="theta", module_name=None)
|
|
box = mod.manual_control_box
|
|
box.host, box.port, box.token_file = "127.0.0.1", 8761, None
|
|
try:
|
|
box.start(token=TOKEN)
|
|
# start() returns at once; the dialling happens in the background
|
|
assert _wait(lambda: box.state == "waiting for the operator")
|
|
request = _next_request(listener)
|
|
assert request is not None and request.namespace == "bernina"
|
|
client = RemoteControlClient(request.accept()).start()
|
|
assert _wait(lambda: bool(client.entries))
|
|
assert _wait(lambda: box.is_connected)
|
|
assert "theta" in {e.name for e in client.entries}
|
|
assert box.start() is box.server # idempotent
|
|
finally:
|
|
box.stop()
|
|
listener.close()
|
|
|
|
|
|
# --- slots, menu, step memory, memories -------------------------------------
|
|
|
|
def _box(tmp_path=None, **kwargs):
|
|
from eco.manual_control.box import ManualControlBox
|
|
from eco.manual_control.step_store import StepStore
|
|
|
|
kwargs.setdefault("step_sizes", [0.001, 0.01, 0.1, 1, 10])
|
|
kwargs.setdefault("step_store", StepStore(path=None))
|
|
return ManualControlBox(build_fake_beamline(), root_name="bernina", **kwargs)
|
|
|
|
|
|
def _arm(box, *path):
|
|
"""Walk to a leaf by name and arm it."""
|
|
for name in path:
|
|
names = [e.name for e in box.entries]
|
|
box.set_cursor(names.index(name))
|
|
box.activate_cursor()
|
|
|
|
|
|
def test_several_axes_stay_armed_each_with_its_own_step():
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
box.breadcrumb_jump(0)
|
|
_arm(box, "attenuator")
|
|
assert [s.name for s in box.slots] == ["theta", "attenuator"]
|
|
assert box.active_slot == 1 and box.target_name() == "attenuator"
|
|
|
|
box.step_up() # only the active slot changes
|
|
steps = {s.name: s.step_size for s in box.slots}
|
|
assert steps["attenuator"] > steps["theta"]
|
|
|
|
box.select_slot(0)
|
|
assert box.target_name() == "theta"
|
|
assert box.step_size == steps["theta"], "step size must follow the slot"
|
|
|
|
|
|
def test_arming_more_than_max_slots_replaces_the_active_one():
|
|
box = _box(max_slots=2)
|
|
_arm(box, "mono", "theta")
|
|
_arm(box, "two_theta")
|
|
_arm(box, "energy")
|
|
assert len(box.slots) == 2
|
|
assert [s.name for s in box.slots] == ["theta", "energy"]
|
|
|
|
|
|
def test_menu_is_reachable_and_switches_the_stick_axis():
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
box.breadcrumb_jump(0)
|
|
_arm(box, "attenuator")
|
|
|
|
box.toggle_menu()
|
|
assert box.in_menu and box.path_names[-1] == "menu"
|
|
labels = [e.name for e in box.entries]
|
|
assert any(l.startswith("step size:") for l in labels)
|
|
assert any(l.startswith("stick axis:") for l in labels)
|
|
|
|
box.set_cursor([i for i, l in enumerate(labels) if l.startswith("stick axis")][0])
|
|
box.activate_cursor()
|
|
assert [e.name for e in box.entries][0].endswith("back")
|
|
box.set_cursor(1) # first slot
|
|
box.activate_cursor()
|
|
assert box.target_name() == "theta"
|
|
assert not box.in_menu, "choosing a slot returns to the tree"
|
|
|
|
|
|
def test_menu_long_press_goes_back_one_level_not_disarm():
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
box.toggle_menu()
|
|
box.set_cursor(0)
|
|
box.activate_cursor() # into 'step size'
|
|
assert len(box.path_names) >= 3
|
|
box.encoder_long_press()
|
|
assert box.in_menu and box.path_names[-1] == "menu"
|
|
assert box.slots, "backing out of a submenu must not disarm"
|
|
|
|
|
|
def test_motion_mode_switches_between_jog_and_step():
|
|
from eco.manual_control.box import MOTION_JOG, MOTION_STEP
|
|
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
assert box.active.motion == MOTION_JOG
|
|
box.toggle_motion()
|
|
assert box.active.motion == MOTION_STEP
|
|
|
|
|
|
def test_step_size_is_remembered_per_adjustable(tmp_path):
|
|
from eco.manual_control.step_store import StepStore
|
|
|
|
store = StepStore(path=str(tmp_path / "steps.json"))
|
|
box = _box(step_store=store)
|
|
_arm(box, "mono", "theta")
|
|
box.step_up()
|
|
chosen = box.step_size
|
|
|
|
# a fresh box (new session) must come back with the same step
|
|
again = _box(step_store=StepStore(path=str(tmp_path / "steps.json")))
|
|
_arm(again, "mono", "theta")
|
|
assert again.step_size == chosen
|
|
|
|
|
|
def test_first_step_size_comes_from_the_objects_own_tweak():
|
|
from eco.manual_control.step_store import initial_step_size, tweak_step_size
|
|
|
|
class FakeTweak:
|
|
step_sizes = [0.025]
|
|
|
|
class Adj:
|
|
_tweak_instance = FakeTweak()
|
|
|
|
assert tweak_step_size(Adj()) == 0.025
|
|
assert initial_step_size(Adj(), "k", None, [0.1, 1, 10]) == 0.025
|
|
# nothing stored and no tweak -> middle of the list
|
|
assert initial_step_size(object(), "k", None, [0.1, 1, 10]) == 1
|
|
|
|
|
|
def test_memories_can_be_listed_recalled_and_saved_from_the_box():
|
|
"""The box only renders; the recall/save happen in the eco session."""
|
|
recalled, saved = [], []
|
|
|
|
class FakeMemory:
|
|
def memories(self):
|
|
return [{"date": "2026-09-05 11:02", "message": "aligned"},
|
|
{"date": "2026-09-05 12:40", "message": "beam centred"}]
|
|
|
|
def __call__(self, index=None, **kwargs):
|
|
recalled.append(index)
|
|
|
|
def memorize(self, message=None, **kwargs):
|
|
saved.append(message)
|
|
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
box.active.obj.memory = FakeMemory()
|
|
|
|
assert box.has_memories()
|
|
labels = [label for _, label in box.list_memories()]
|
|
assert "aligned" in labels[0] and "beam centred" in labels[1]
|
|
|
|
box.toggle_menu()
|
|
names = [e.name for e in box.entries]
|
|
box.set_cursor(names.index("memories"))
|
|
box.activate_cursor()
|
|
entries = [e.name for e in box.entries]
|
|
assert any("aligned" in e for e in entries)
|
|
|
|
# recalling moves hardware, so it must take a confirmation step
|
|
box.set_cursor([i for i, e in enumerate(entries) if "aligned" in e][0])
|
|
box.activate_cursor()
|
|
assert recalled == [], "recall must not fire before confirmation"
|
|
confirm = [e.name for e in box.entries]
|
|
box.set_cursor([i for i, e in enumerate(confirm) if e.startswith("YES")][0])
|
|
box.activate_cursor()
|
|
assert recalled == [0]
|
|
|
|
box.toggle_menu()
|
|
names = [e.name for e in box.entries]
|
|
box.set_cursor(names.index("memories"))
|
|
box.activate_cursor()
|
|
entries = [e.name for e in box.entries]
|
|
box.set_cursor([i for i, e in enumerate(entries) if e.startswith("save")][0])
|
|
box.activate_cursor()
|
|
assert saved and "control box" in saved[0]
|
|
|
|
|
|
def test_menu_survives_an_adjustable_with_a_broken_memory():
|
|
class Exploding:
|
|
def memories(self):
|
|
raise RuntimeError("memory dir unreadable")
|
|
|
|
box = _box()
|
|
_arm(box, "mono", "theta")
|
|
box.active.obj.memory = Exploding()
|
|
box.toggle_menu()
|
|
names = [e.name for e in box.entries]
|
|
box.set_cursor(names.index("memories"))
|
|
box.activate_cursor()
|
|
assert any("cannot read memories" in e.name for e in box.entries)
|
|
|
|
|
|
def test_control_box_manual_mentions_the_background_service():
|
|
from eco.manual_control.control_box import ControlBox
|
|
|
|
manual = ControlBox.__doc__
|
|
for topic in ["eco-box-server", "eco-dev box-server", "box_server"]:
|
|
assert topic in manual, f"manual does not mention {topic!r}"
|