openflexure-microscope-server/tests/test_stage.py
2025-08-05 08:30:54 +01:00

170 lines
6.3 KiB
Python

"""Test the stage without creating a full HTTP server and socket connection."""
from collections.abc import Mapping
import tempfile
from fastapi.testclient import TestClient
import pytest
from httpx import HTTPStatusError
import labthings_fastapi as lt
from labthings_fastapi.exceptions import NotConnectedToServerError
from openflexure_microscope_server.things.stage import (
BaseStage,
RedefinedBaseMovementError,
)
from openflexure_microscope_server.things.stage.dummy import DummyStage
@pytest.fixture
def dummy_stage():
"""Return a dummy stage with a very low step time."""
return DummyStage(step_time=0.000001)
@pytest.fixture
def thing_server(dummy_stage):
"""Yield a server with a very basic configuration."""
with tempfile.TemporaryDirectory() as tmpdir:
server = lt.ThingServer(settings_folder=tmpdir)
server.add_thing(dummy_stage, "/stage/")
yield server
@pytest.fixture
def stage_client(thing_server):
"""Yield a labthings ThingClient for the stage."""
with TestClient(thing_server.app) as test_client:
yield lt.ThingClient.from_url("/stage/", client=test_client)
def test_override_base_movement():
"""Child classes of stage should implement functions in the hardware reference frame.
``move_absolute`` and ``move_relative`` are in the program reference frame and
convert to the hardware frame. It is recommended to override
``_hardware_move_relative`` and ``_hardware_move_absolute`` instead.
Check that the expected error is raised if the methods are overridden.
"""
class BadStage1(BaseStage):
@lt.thing_action
def move_relative(
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
pass
with pytest.raises(RedefinedBaseMovementError):
BadStage1()
class BadStage2(BaseStage):
@lt.thing_action
def move_absolute(
self,
cancel: lt.deps.CancelHook,
block_cancellation: bool = False,
**kwargs: Mapping[str, int],
):
pass
with pytest.raises(RedefinedBaseMovementError):
BadStage2()
def _set_axis_direction(dummy_stage, direction):
"""Set the axis direction directly even if not connected to server."""
try:
dummy_stage.axis_direction = direction
except NotConnectedToServerError:
pass
def test_apply_axis_direction_all_pos(dummy_stage):
"""Test the apply axis direction function behaves as expected when axis +v3."""
# Directly create a stage not through a ThingServer to access private methods
_set_axis_direction(dummy_stage, {"x": 1, "y": 1, "z": 1})
# A list of positions to try
positions = [
[1, 2, 3], # list
{"x": 1, "y": 2, "z": 3}, # mapping
{"x": 1, "z": 2, "y": 3}, # mapping out of order
{"x": 3}, # Mapping with only 1 value
{"x": 1, "z": 2}, # Mapping with only 2 values
]
for pos in positions:
assert dummy_stage._apply_axis_direction(pos) == pos
# Check tuple separately as it gets converted to list
assert dummy_stage._apply_axis_direction((1, 2, 0)) == [1, 2, 0]
def test_apply_axis_direction_mixed(dummy_stage):
"""Test the apply axis direction function behaves as expected when axis dirs are mixed."""
# Make x and z negative
_set_axis_direction(dummy_stage, {"x": -1, "y": 1, "z": -1})
# A list of (input position, output position) to try
position_pairs = [
([1, 2, 3], [-1, 2, -3]), # list
((1, 2, 0), [-1, 2, 0]), # tuple (gets converted to list)
({"x": 1, "y": 2, "z": 3}, {"x": -1, "y": 2, "z": -3}), # mapping
({"x": 1, "z": 2, "y": 3}, {"x": -1, "z": -2, "y": 3}), # mapping out of order
({"x": 3}, {"x": -3}), # Mapping with only 1 value
({"x": 1, "z": 2}, {"x": -1, "z": -2}), # Mapping with only 2 values
]
for pos, expected_pos in position_pairs:
assert dummy_stage._apply_axis_direction(pos) == expected_pos
def test_apply_axis_errors(dummy_stage):
"""Test the apply axis direction returns appropriate errors."""
with pytest.raises(TypeError):
dummy_stage._apply_axis_direction(None)
with pytest.raises(TypeError):
dummy_stage._apply_axis_direction("Onwards!")
with pytest.raises(KeyError):
dummy_stage._apply_axis_direction({"x": -1, "y": 2, "z": 4, "up": -3})
with pytest.raises(KeyError):
dummy_stage._apply_axis_direction({"x": -1, "y": 2, "up": -3})
def test_default_values(stage_client, dummy_stage):
"""Check the default values for the dummy stage."""
# axes are x, y, z (note that going through the thing, client the tuple is
# converted to a list.
assert stage_client.axis_names == ["x", "y", "z"]
# position starts at 0, 0, 0
assert stage_client.position == {"x": 0, "y": 0, "z": 0}
# axis direction starts is -1, 1, 1 for the dummy stage
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}
# And check the thing state
assert dummy_stage.thing_state == {"position": {"x": 0, "y": 0, "z": 0}}
def test_direction_inversion(stage_client):
"""Check axes invert as expected when called."""
# Can't set an arbitrary value via a client as read only:
with pytest.raises(HTTPStatusError) as excinfo:
stage_client.axis_direction = {"x": 2, "y": 1, "z": 1}
# Read only should set a 405 error code
assert excinfo.value.response.status_code == 405
# And not modify th initial value
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}
stage_client.invert_axis_direction(axis="x")
assert stage_client.axis_direction == {"x": 1, "y": 1, "z": 1}
stage_client.invert_axis_direction(axis="x")
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}
stage_client.invert_axis_direction(axis="y")
assert stage_client.axis_direction == {"x": -1, "y": -1, "z": 1}
stage_client.invert_axis_direction(axis="y")
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}
stage_client.invert_axis_direction(axis="z")
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": -1}
stage_client.invert_axis_direction(axis="z")
assert stage_client.axis_direction == {"x": -1, "y": 1, "z": 1}