Merge branch 'faster-stage-tests' into 'v3'

Speed up `tests/test_stage.py` significantly

See merge request openflexure/openflexure-microscope-server!353
This commit is contained in:
Julian Stirling 2025-08-08 10:17:15 +00:00
commit c9cb0c65e1
2 changed files with 46 additions and 12 deletions

View file

@ -0,0 +1,19 @@
"""Create a fake CancelHook used instead of a dependency, it cannot be used to cancel!
Certain actions require a CancelHook to be supplied if called directly. The only method
we use from the LabThings CancelHook is ``sleep()``. The CanceHook ``sleep()`` works
exactly like ``time.sleep()`` except with raise an ``InvocationCancelledError`` if the
server receives a cancellation request.
This is just an object with a sleep method.
"""
import time
class MockCancel:
"""A class to use when directly calling an action with CancelHook dependency."""
def sleep(self, time_in_seconds: float) -> None:
"""Sleep for the input number of seconds."""
time.sleep(time_in_seconds)

View file

@ -18,6 +18,9 @@ from openflexure_microscope_server.things.stage import (
)
from openflexure_microscope_server.things.stage.dummy import DummyStage
from .mock_things.mock_cancel import MockCancel
# Keep the size and number of moves fairly small or the tests can take forever
point3d = st.tuples(
st.integers(min_value=-100, max_value=100),
@ -191,23 +194,26 @@ def test_direction_inversion(stage_client, dummy_stage):
assert excinfo.value.response.status_code == 422
def _test_move_relative(stage_client, dummy_stage, axis_inverted, path):
def _test_move_relative(dummy_stage, axis_inverted, path):
"""Test moving relative, ensuring position and hardware position behave as expected.
:param axis_inverted: Is used to set the inversion.
:param path: The 3d path to move over, generated by hypothesis.
"""
cancel = MockCancel()
_set_axis_direction(dummy_stage, axis_inverted)
# Explicitly do axes calculation here to check logic in main code.
x_dir = -1 if axis_inverted["x"] else 1
y_dir = -1 if axis_inverted["y"] else 1
z_dir = -1 if axis_inverted["z"] else 1
position = list(stage_client.position.values())
position = list(dummy_stage.position.values())
for movement in path:
stage_client.move_relative(x=movement[0], y=movement[1], z=movement[2])
dummy_stage.move_relative(
cancel=cancel, x=movement[0], y=movement[1], z=movement[2]
)
position = [pos + move for pos, move in zip(position, movement)]
stage_pos = stage_client.get_xyz_position()
stage_pos = dummy_stage.get_xyz_position()
hw_pos = dummy_stage._hardware_position
assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir
assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir
@ -226,6 +232,11 @@ def test_move_relative(stage_client, dummy_stage, path):
3 paths are tried for each case of axis inversion. This checks both that the
reported position changes as is input in path, and that the hardware position is
inverted when appropriate.
NOTE: it is essential that `stage_client` is imported even if any time it is used
dummy stage could be used. This is because the fixture that creates stage client
handles adding the dummy_stage to a server. It needs to have been added to a
server for it not to throw errors about not being connected to a server.
"""
# Note that the fixture is not reset. This is fine, because the stage_should work
# no matter the starting position.
@ -239,30 +250,31 @@ def test_move_relative(stage_client, dummy_stage, path):
print(path)
for axis_inverted in inversion_combinations:
_test_move_relative(
stage_client=stage_client,
dummy_stage=dummy_stage,
axis_inverted=axis_inverted,
path=path,
)
def _test_move_absolute(stage_client, dummy_stage, axis_inverted, path):
def _test_move_absolute(dummy_stage, axis_inverted, path):
"""Test moving relative, ensuring position and hardware position behave as expected.
:param axis_inverted: Is used to set the inversion.
:param path: The 3d path to move over, generated by hypothesis.
"""
cancel = MockCancel()
_set_axis_direction(dummy_stage, axis_inverted)
# Explicitly do axes calculation here to check logic in main code.
x_dir = -1 if axis_inverted["x"] else 1
y_dir = -1 if axis_inverted["y"] else 1
z_dir = -1 if axis_inverted["z"] else 1
position = list(stage_client.position.values())
position = list(dummy_stage.position.values())
for move_to in path:
stage_client.move_absolute(x=move_to[0], y=move_to[1], z=move_to[2])
dummy_stage.move_absolute(
cancel=cancel, x=move_to[0], y=move_to[1], z=move_to[2]
)
position = move_to
stage_pos = stage_client.get_xyz_position()
stage_pos = dummy_stage.get_xyz_position()
hw_pos = dummy_stage._hardware_position
assert position[0] == stage_pos[0] == hw_pos["x"] * x_dir
assert position[1] == stage_pos[1] == hw_pos["y"] * y_dir
@ -281,6 +293,11 @@ def test_move_absolute(stage_client, dummy_stage, path):
3 paths are tried for each case of axis inversion. This checks both that the
reported position changes as is input in path, and that the hardware position is
inverted when appropriate.
NOTE: it is essential that `stage_client` is imported even if any time it is used
dummy stage could be used. This is because the fixture that creates stage client
handles adding the dummy_stage to a server. It needs to have been added to a
server for it not to throw errors about not being connected to a server.
"""
# Note that the fixture is not reset. This is fine, because the stage_should work
# no matter the starting position.
@ -291,10 +308,8 @@ def test_move_absolute(stage_client, dummy_stage, path):
dict(zip(axis_names, inverted))
for inverted in itertools.product([True, False], repeat=len(axis_names))
]
print(path)
for axis_inverted in inversion_combinations:
_test_move_absolute(
stage_client=stage_client,
dummy_stage=dummy_stage,
axis_inverted=axis_inverted,
path=path,