Get unit tests passing with Scan Workflows

This commit is contained in:
Julian Stirling 2026-01-16 00:08:33 +00:00
parent c82523dd5b
commit 2a3a54b936
5 changed files with 149 additions and 135 deletions

View file

@ -22,11 +22,13 @@ from typing import Callable, Optional
import pytest
from fastapi import HTTPException
from pydantic import BaseModel
from labthings_fastapi.exceptions import InvocationCancelledError
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.scan_directories import NotEnoughFreeSpaceError
from openflexure_microscope_server.stitching import StitchingSettings
from openflexure_microscope_server.things.smart_scan import (
ActiveScanData,
ScanNotRunningError,
@ -221,21 +223,28 @@ MOCK_SCAN_DIR = "scans/test_name_0001/images/"
MOCK_START_POS = {"x": 123, "y": 456, "z": 789}
class MockWorkflowSettingModel(BaseModel):
"""A mock model to check that ActiveScanData can hold arbitrary models."""
foo: str = "bar"
bar: str = "foo"
dx: int = 123
dy: int = 456
def _expected_scan_data():
"""Return the expected ActiveScanData object for a SmartScan with default properties."""
expected_dict = {
"scan_name": MOCK_SCAN_NAME,
"starting_position": MOCK_START_POS,
"overlap": 0.45,
"max_dist": 45000,
"dx": 100,
"dy": 100,
"autofocus_dz": 1000,
"autofocus_on": True,
"skip_background": True,
"stitch_automatically": True,
"correlation_resize": 0.5,
"save_resolution": (1640, 1232),
"stitch_automatically": True,
"stitching_settings": {
"overlap": 0.45,
"correlation_resize": 0.5,
},
"workflow": "Mock",
"workflow_settings": MockWorkflowSettingModel(),
}
return ActiveScanData(start_time=datetime.now(), **expected_dict)
@ -245,14 +254,14 @@ def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker):
"""Return a scan thing that is mocked so that _collect_scan_data will run."""
# Set the lock so it thinks the scan is running
with smart_scan_thing._scan_lock:
mocker.patch.object(
smart_scan_thing,
"_calc_displacement_from_test_image",
return_value=[100, 100],
)
smart_scan_thing._stage.position = MOCK_START_POS
smart_scan_thing._workflow.all_settings.return_value = (
MockWorkflowSettingModel(),
StitchingSettings(correlation_resize=0.5, overlap=0.45),
)
smart_scan_thing._workflow.save_resolution = (1640, 1232)
mock_ongoing_scan = mocker.Mock()
mock_ongoing_scan.name = MOCK_SCAN_NAME
mock_ongoing_scan.images_dir = MOCK_SCAN_DIR
@ -265,7 +274,7 @@ def test_collect_scan_data(scan_thing_mocked_for_scan_data):
"""Run _collect_scan_data, and check the ActiveScanData object has the expected values."""
scan_thing = scan_thing_mocked_for_scan_data
data = scan_thing._collect_scan_data()
data = scan_thing._collect_scan_data(scan_thing._workflow)
expected_data = _expected_scan_data()
time_diff = expected_data.start_time - data.start_time
assert abs(time_diff.total_seconds()) < 1
@ -278,7 +287,7 @@ def test_save_final_scan_data(scan_thing_mocked_for_scan_data):
"""Run _save_final_scan_data, check save is called with final results in ActiveScanData."""
scan_thing = scan_thing_mocked_for_scan_data
scan_thing._scan_data = scan_thing._collect_scan_data()
scan_thing._scan_data = scan_thing._collect_scan_data(scan_thing._workflow)
scan_thing._scan_data.image_count = 44
scan_thing._save_final_scan_data("Mocked!")
# _ongoing_scan is a mock so we can check that save_scan data was called and get
@ -320,10 +329,10 @@ def check_run_scan(scan_thing, caplog, expected_exception=None):
"""
if expected_exception is None:
with caplog.at_level(logging.WARNING):
scan_thing._scan_data = scan_thing._run_scan()
scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow)
else:
with pytest.raises(expected_exception), caplog.at_level(logging.WARNING):
scan_thing._scan_data = scan_thing._run_scan()
scan_thing._scan_data = scan_thing._run_scan(scan_thing._workflow)
# The preview stitcher object should still exist. And images dir should be set.
assert scan_thing._preview_stitcher.images_dir == MOCK_SCAN_DIR

View file

@ -25,6 +25,7 @@ from openflexure_microscope_server.things.autofocus import (
_get_capture_index_by_id,
_get_peak_turning_point,
)
from openflexure_microscope_server.things.scan_workflows import HistoScanWorkflow
RANDOM_GENERATOR = np.random.default_rng()
@ -267,20 +268,28 @@ def autofocus_thing():
return create_thing_without_server(AutofocusThing, mock_all_slots=True)
def test_create_stack(autofocus_thing, caplog):
@pytest.fixture
def histo_scan_workflow():
"""Return an autofocus thing connected to a server."""
return create_thing_without_server(HistoScanWorkflow, mock_all_slots=True)
def test_create_stack(histo_scan_workflow, caplog):
"""Run create stack with default values and check there is no coercion or logging."""
initial_min_images_to_test = autofocus_thing.stack_min_images_to_test
initial_images_to_save = autofocus_thing.stack_images_to_save
initial_min_images_to_test = histo_scan_workflow.stack_min_images_to_test
initial_images_to_save = histo_scan_workflow.stack_images_to_save
with caplog.at_level(logging.INFO):
stack_params = autofocus_thing.create_stack_params(
stack_params = histo_scan_workflow.create_smart_stack_params(
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert len(caplog.records) == 0
assert autofocus_thing.stack_min_images_to_test == initial_min_images_to_test
assert autofocus_thing.stack_images_to_save == initial_images_to_save
assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test
assert histo_scan_workflow.stack_images_to_save == initial_images_to_save
assert (
stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test
)
assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save
@pytest.mark.parametrize(
@ -293,13 +302,13 @@ def test_create_stack(autofocus_thing, caplog):
],
)
def test_coercing_stack_test_ims(
initial_test_ims, coerced_test_ims, expected_log_start, autofocus_thing, caplog
initial_test_ims, coerced_test_ims, expected_log_start, histo_scan_workflow, caplog
):
"""Run create stack with images to test set to values requiring coercion, and check result."""
autofocus_thing.stack_min_images_to_test = initial_test_ims
histo_scan_workflow.stack_min_images_to_test = initial_test_ims
with caplog.at_level(logging.WARNING):
stack_params = autofocus_thing.create_stack_params(
stack_params = histo_scan_workflow.create_smart_stack_params(
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
@ -308,7 +317,9 @@ def test_coercing_stack_test_ims(
# Check the value is coerced in the stack_params
assert stack_params.min_images_to_test == coerced_test_ims
# Check that the setting in the Thing was updated to the coerced value
assert stack_params.min_images_to_test == autofocus_thing.stack_min_images_to_test
assert (
stack_params.min_images_to_test == histo_scan_workflow.stack_min_images_to_test
)
@pytest.mark.parametrize(
@ -321,13 +332,13 @@ def test_coercing_stack_test_ims(
],
)
def test_coercing_stack_save_ims(
initial_save_ims, coerced_save_ims, expected_log_start, autofocus_thing, caplog
initial_save_ims, coerced_save_ims, expected_log_start, histo_scan_workflow, caplog
):
"""Run create stack with images to save set to values requiring coercion, and check result."""
autofocus_thing.stack_images_to_save = initial_save_ims
histo_scan_workflow.stack_images_to_save = initial_save_ims
with caplog.at_level(logging.WARNING):
stack_params = autofocus_thing.create_stack_params(
stack_params = histo_scan_workflow.create_smart_stack_params(
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
@ -336,13 +347,13 @@ def test_coercing_stack_save_ims(
# Check the value is coerced in the stack_params
assert stack_params.images_to_save == coerced_save_ims
# Check that the setting in the Thing was updated to the coerced value
assert stack_params.images_to_save == autofocus_thing.stack_images_to_save
assert stack_params.images_to_save == histo_scan_workflow.stack_images_to_save
@pytest.mark.parametrize("pass_on", [1, 2, 3, 4])
def test_run_smart_stack(pass_on, autofocus_thing, mocker):
def test_run_smart_stack(pass_on, histo_scan_workflow, autofocus_thing, mocker):
"""Test Running smart stack with the stack passing on different attempts."""
stack_params = autofocus_thing.create_stack_params(
stack_params = histo_scan_workflow.create_smart_stack_params(
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
assert stack_params.max_attempts == 3
@ -364,8 +375,8 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
failed_return = (False, fake_captures, "pick_me")
return_list = [failed_return] * (pass_on - 1) + [successful_return]
# Mock z_stack and looping_autofocus
autofocus_thing.z_stack = mocker.Mock(side_effect=return_list)
# Mock smart_z_stack and looping_autofocus
autofocus_thing.smart_z_stack = mocker.Mock(side_effect=return_list)
autofocus_thing.looping_autofocus = mocker.Mock()
# Run it
@ -380,9 +391,10 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
# Final z is the one from the id returned by the stack "pick_me"
assert final_z == 555
# z_stack should run up until the time it passes. Running no more than max_attempts
# smart_z_stack should run up until the time it passes. Running no more than
# max_attempts
n_stacks = min(pass_on, stack_params.max_attempts)
assert autofocus_thing.z_stack.call_count == n_stacks
assert autofocus_thing.smart_z_stack.call_count == n_stacks
# Move absolute should be 1 less time that the number of times z_stack_run
assert autofocus_thing._stage.move_absolute.call_count == n_stacks - 1
# As should looping autofocus
@ -396,14 +408,16 @@ def test_run_smart_stack(pass_on, autofocus_thing, mocker):
assert autofocus_thing._cam.save_from_memory.call_count == (1 if success else 0)
def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing, mocker):
"""Set up a z_stack, run it, and return the result.
def setup_and_run_smart_z_stack(
check_returns, check_turning_points, histo_scan_workflow, autofocus_thing, mocker
):
"""Set up a smart_z_stack, run it, and return the result.
:param check_returns: The return values from check_stack_result. Note that if this
is a list, it will be set as a side effect (and should be a list of tuples of
results). If it a tuple (or anything else), it is set as a return value.
"""
stack_params = autofocus_thing.create_stack_params(
stack_params = histo_scan_workflow.create_smart_stack_params(
autofocus_dz=2000, images_dir="/this/is/fake", save_resolution=(1640, 1232)
)
stack_params.settling_time = 0 # Don't settle or tests take forever.
@ -413,58 +427,70 @@ def setup_and_run_z_stack(check_returns, check_turning_points, autofocus_thing,
autofocus_thing.check_stack_result = mocker.Mock(side_effect=check_returns)
else:
autofocus_thing.check_stack_result = mocker.Mock(return_value=check_returns)
return autofocus_thing.z_stack(
return autofocus_thing.smart_z_stack(
stack_parameters=stack_params,
check_turning_points=check_turning_points,
)
def test_z_stack_turning_toggle_passed(autofocus_thing, mocker):
def test_z_stack_turning_toggle_passed(histo_scan_workflow, autofocus_thing, mocker):
"""Check that the toggling of turning points is passed to the check."""
check_returns = ("success", "mock_id")
for check_turning in [True, False]:
setup_and_run_z_stack(check_returns, check_turning, autofocus_thing, mocker)
setup_and_run_smart_z_stack(
check_returns, check_turning, histo_scan_workflow, autofocus_thing, mocker
)
check_kwargs = autofocus_thing.check_stack_result.call_args.kwargs
assert check_kwargs["check_turning_points"] == check_turning
def test_z_stack_returns_on_success_and_restart(autofocus_thing, mocker):
def test_z_stack_returns_on_success_and_restart(
histo_scan_workflow, autofocus_thing, mocker
):
"""Check that if the check returns success or restart then the stack exits with correct return value."""
for result in ["success", "restart"]:
check_returns = (result, "mock_id")
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
ret = setup_and_run_smart_z_stack(
check_returns, True, histo_scan_workflow, autofocus_thing, mocker
)
assert autofocus_thing.check_stack_result.call_count == 1
# Check the number of images taken is exactly the call count.
ims_taken = autofocus_thing.capture_stack_image.call_count
assert ims_taken == autofocus_thing.stack_min_images_to_test
assert ims_taken == histo_scan_workflow.stack_min_images_to_test
# And the result is as expected.
assert ret[0] == (result == "success")
def test_z_stack_exits_if_focus_never_found(autofocus_thing, mocker):
def test_z_stack_exits_if_focus_never_found(
histo_scan_workflow, autofocus_thing, mocker
):
"""Check that if the check returns continue the stack exits eventually with a failure."""
check_returns = ("continue", "mock_id")
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
ret = setup_and_run_smart_z_stack(
check_returns, True, histo_scan_workflow, autofocus_thing, mocker
)
assert autofocus_thing.check_stack_result.call_count == EXTRA_STACK_CAPTURES + 1
# Check the number of images taken is the maximum possible, set by the min images to
# test and the number of extra images that can be taken
ims_taken = autofocus_thing.capture_stack_image.call_count
max_ims = autofocus_thing.stack_min_images_to_test + EXTRA_STACK_CAPTURES
max_ims = histo_scan_workflow.stack_min_images_to_test + EXTRA_STACK_CAPTURES
assert ims_taken == max_ims
# And the result is as expected.
assert not ret[0]
def test_z_stack_return(autofocus_thing, mocker):
def test_z_stack_return(histo_scan_workflow, autofocus_thing, mocker):
"""Check z-stack returns as expected for more complex cases the fixed results above."""
for i in range(2, EXTRA_STACK_CAPTURES):
check_returns = [
("restart" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
]
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
ret = setup_and_run_smart_z_stack(
check_returns, True, histo_scan_workflow, autofocus_thing, mocker
)
# Calculate images taken
images_taken = autofocus_thing.stack_min_images_to_test + i - 1
images_taken = histo_scan_workflow.stack_min_images_to_test + i - 1
assert autofocus_thing.capture_stack_image.call_count == images_taken
# Check it reports a failure
assert not ret[0]
@ -473,7 +499,9 @@ def test_z_stack_return(autofocus_thing, mocker):
check_returns = [
("success" if j == i - 1 else "continue", f"id_{j}") for j in range(i)
]
ret = setup_and_run_z_stack(check_returns, True, autofocus_thing, mocker)
ret = setup_and_run_smart_z_stack(
check_returns, True, histo_scan_workflow, autofocus_thing, mocker
)
# Calculate images taken
assert autofocus_thing.capture_stack_image.call_count == images_taken
# Check it reports a success

View file

@ -11,15 +11,16 @@ import time
from copy import copy
import pytest
from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.stitching import (
STITCHING_RESOLUTION,
BaseStitcher,
FinalStitcher,
PreviewStitcher,
StitcherValidationError,
StitchingSettings,
)
from ..shared_utils.lt_test_utils import LabThingsTestEnv
@ -88,87 +89,41 @@ FINAL_EXPECTED_COMMAND = [
FAKE_DIR,
]
def test_final_stitcher_command_defaults(caplog):
"""Check the FinalStitcher stitches with expected default values.
It should warn when default values are used as they are a fallback.
"""
n_logs = 0
# Test with no dictionary data and with irrelevant dictionary data.
with caplog.at_level(logging.WARNING):
for data_dict in [None, {"irrelevant": "data"}]:
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, scan_data_dict=data_dict)
# Should log for overlap being None and correlation_resize being None
n_logs += 2
assert len(caplog.records) == n_logs
assert stitcher.command == FINAL_EXPECTED_COMMAND
DEFAULT_SETTINGS = StitchingSettings(correlation_resize=0.5, overlap=0.1)
def test_final_stitcher_command_tiff(caplog):
def test_final_stitcher_command_tiff():
"""Check that the tiff can be requested."""
# Modify defaults
expected_command = copy(FINAL_EXPECTED_COMMAND)
expected_command[4] = "--stitch_tiff"
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER, stitch_tiff=True)
# Should log for overlap being None and correlation_resize being None
assert len(caplog.records) == 2
stitcher = FinalStitcher(
FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS, stitch_tiff=True
)
assert stitcher.command == expected_command
def test_final_stitcher_command_set_val_directly():
"""Check that values are set as expected when directly input."""
# Modify defaults
expected_command = copy(FINAL_EXPECTED_COMMAND)
expected_command[8] = "0.36"
expected_command[10] = "0.25"
# Test with no data dictionary, irrelevant data, and also the wrong data
# When wrong data is submitted, it should take the directly input data.
dict_vals = [
None,
{"irrelevant": "data"},
{"overlap": 0.2, "save_resolution": [5, 5]},
]
for data_dict in dict_vals:
stitcher = FinalStitcher(
FAKE_DIR,
logger=LOGGER,
overlap=0.4,
correlation_resize=0.25,
scan_data_dict=data_dict,
)
assert stitcher.command == expected_command
def test_final_stitcher_command_set_with_dict():
def test_final_stitcher_command_with_settings():
"""Check that values are set as expected when set from a ScanData dictionary."""
# Modify defaults
expected_command = copy(FINAL_EXPECTED_COMMAND)
expected_command[8] = "0.36"
expected_command[10] = "0.25"
# Check same thing works with a dictionary, resize is calculated from the saved image
# resolution. Make 4x bigger than STITCHING_RESOLUTION to get 0.25
resolution = [dim * 4 for dim in STITCHING_RESOLUTION]
# Check legacy key as well as current one:
for resolution_key in ["save_resolution", "capture resolution"]:
stitcher = FinalStitcher(
FAKE_DIR,
logger=LOGGER,
scan_data_dict={"overlap": 0.4, resolution_key: resolution},
)
assert stitcher.command == expected_command
stitcher = FinalStitcher(
FAKE_DIR,
logger=LOGGER,
stitching_settings=StitchingSettings(correlation_resize=0.25, overlap=0.4),
)
assert stitcher.command == expected_command
def _validation_error_tester(scan_path, **kwargs):
"""Check each type of stitcher throws a validation error for the given init args."""
# If scan_data_dict is in the kwargs only test the scan_data_dict
if "scan_data_dict" not in kwargs:
with pytest.raises(StitcherValidationError):
BaseStitcher(scan_path, **kwargs).command
with pytest.raises(StitcherValidationError):
PreviewStitcher(scan_path, **kwargs).command
"""Check stitcher throws a validation error for the given init args."""
with pytest.raises(StitcherValidationError):
FinalStitcher(scan_path, logger=LOGGER, **kwargs).command
BaseStitcher(scan_path, **kwargs).command
with pytest.raises(StitcherValidationError):
PreviewStitcher(scan_path, **kwargs).command
def test_validation_error():
@ -176,10 +131,23 @@ def test_validation_error():
The stitcher should throw a validation error each attempt.
"""
# Tests for preview (and base) stitcher
_validation_error_tester("/dir;rm -rf /;", overlap=".2", correlation_resize=".25")
_validation_error_tester(FAKE_DIR, overlap=".2", correlation_resize=".25;rm -rf /;")
_validation_error_tester(FAKE_DIR, overlap=".2;rm -rf /;", correlation_resize=".25")
_validation_error_tester(FAKE_DIR, scan_data_dict={"overlap": ".2;rm -rf /;"})
class EvilModel(BaseModel):
overlap: str
correlation_resize: str
with pytest.raises(StitcherValidationError):
FinalStitcher(
FAKE_DIR,
logger=LOGGER,
stitching_settings=EvilModel(
overlap=".2;rm -rf /;", correlation_resize=".25"
),
)
def test_extra_arg_validation():
@ -188,7 +156,9 @@ def test_extra_arg_validation():
Currently extra args do not come from user input. But this makes checks more
future-proof.
"""
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER)
stitcher = FinalStitcher(
FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS
)
stitcher._extra_args = ["&&rm -rf /&&"]
with pytest.raises(StitcherValidationError):
stitcher.command
@ -233,7 +203,9 @@ class StitchingTestThing(lt.Thing):
@lt.action
def run_final(self):
"""Run the final stitcher."""
stitcher = FinalStitcher(FAKE_DIR, logger=self.logger)
stitcher = FinalStitcher(
FAKE_DIR, logger=self.logger, stitching_settings=DEFAULT_SETTINGS
)
# Send in the argument HANG to mock-stitch and it just hang for 10s
stitcher._extra_args = ["HANG"]
stitcher.run()
@ -277,9 +249,8 @@ def test_final_stitching_command(caplog, mocker):
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
with caplog.at_level(logging.INFO):
# Input values to prevent logging
stitcher = FinalStitcher(
FAKE_DIR, logger=LOGGER, overlap=0.1, correlation_resize=0.5
FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS
)
# For the final stitcher it will always complete before returning.
stitcher.run()
@ -315,16 +286,17 @@ def test_final_stitching_command_cancelled(stitching_test_env, mocker):
assert re.match(r"^Invocation [0-9a-f-]+ was cancelled", logs[-1]["message"])
def test_final_stitching_command_error(caplog, mocker):
def test_final_stitching_command_error(mocker):
"""Check that ChildProcessError is raised if the final stitch errors."""
mock_cmd = f"python {MOCK_STITCHER}"
mocker.patch("openflexure_microscope_server.stitching.STITCHING_CMD", mock_cmd)
with caplog.at_level(logging.INFO):
stitcher = FinalStitcher(FAKE_DIR, logger=LOGGER)
# Send in the argument ERROR to mock-stitch and it will raise an error rather
# than echo.
stitcher._extra_args = ["ERROR"]
with pytest.raises(ChildProcessError):
stitcher.run()
stitcher = FinalStitcher(
FAKE_DIR, logger=LOGGER, stitching_settings=DEFAULT_SETTINGS
)
# Send in the argument ERROR to mock-stitch and it will raise an error rather
# than echo.
stitcher._extra_args = ["ERROR"]
with pytest.raises(ChildProcessError):
stitcher.run()