Tweak error reporting in _run_scan and add a lot more testing for _run_scan

This commit is contained in:
Julian Stirling 2025-08-04 00:57:18 +01:00
parent d7a7ac4c7d
commit 82691a6b78
3 changed files with 162 additions and 21 deletions

View file

@ -20,7 +20,6 @@ from PIL import Image
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.utilities import ErrorCapturingThread
from openflexure_microscope_server import scan_directories from openflexure_microscope_server import scan_directories
from openflexure_microscope_server import scan_planners from openflexure_microscope_server import scan_planners
from openflexure_microscope_server import stitching from openflexure_microscope_server import stitching
@ -368,14 +367,15 @@ class SmartScanThing(lt.Thing):
self._scan_logger.info("Stopping scan because it was cancelled.") self._scan_logger.info("Stopping scan because it was cancelled.")
self._save_final_scan_data(scan_result="cancelled by user") self._save_final_scan_data(scan_result="cancelled by user")
except scan_directories.NotEnoughFreeSpaceError as e: except scan_directories.NotEnoughFreeSpaceError as e:
self._save_final_scan_data(scan_result=str(e)) self._save_final_scan_data(scan_result=f"NotEnoughFreeSpaceError: {e}")
self._scan_logger.error( self._scan_logger.error(
f"Stopping scan to avoid filling up the disk: {e}", f"Stopping scan to avoid filling up the disk: {e}",
exc_info=e, exc_info=e,
) )
raise e raise e
except Exception as e: except Exception as e:
self._save_final_scan_data(scan_result=str(e)) err_name = type(e).__name__
self._save_final_scan_data(scan_result=f"{err_name}: {e}")
self._scan_logger.error( self._scan_logger.error(
f"The scan stopped because of an error: {e} " f"The scan stopped because of an error: {e} "
"Attempting to stitch and archive the images acquired so far.", "Attempting to stitch and archive the images acquired so far.",
@ -388,7 +388,6 @@ class SmartScanThing(lt.Thing):
# Start streaming in the default resolution again as soon as possible # Start streaming in the default resolution again as soon as possible
self._cam.start_streaming() self._cam.start_streaming()
# This is what happens if the scan completes successfully or the # This is what happens if the scan completes successfully or the
# user cancels it. # user cancels it.
self._return_to_starting_position() self._return_to_starting_position()

View file

@ -23,6 +23,7 @@ class MockCameraThing(Mock):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialise the mock camera, args and kwargs are the mock args and kwargs."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.background_detector_status = PropertyMock( self.background_detector_status = PropertyMock(

View file

@ -23,11 +23,16 @@ from datetime import datetime
from fastapi import HTTPException from fastapi import HTTPException
import pytest import pytest
from labthings_fastapi.exceptions import InvocationCancelledError
from openflexure_microscope_server.things.smart_scan import ( from openflexure_microscope_server.things.smart_scan import (
SmartScanThing, SmartScanThing,
ScanNotRunningError, ScanNotRunningError,
) )
from openflexure_microscope_server.scan_directories import ScanData from openflexure_microscope_server.scan_directories import (
ScanData,
NotEnoughFreeSpaceError,
)
from .mock_things.mock_csm import MockCSMThing from .mock_things.mock_csm import MockCSMThing
from .mock_things.mock_autofocus import MockAutoFocusThing from .mock_things.mock_autofocus import MockAutoFocusThing
@ -275,7 +280,7 @@ def _expected_scan_data():
@pytest.fixture @pytest.fixture
def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker): def scan_thing_mocked_for_scan_data(smart_scan_thing, mocker):
"""A scan thing that is mocked so that _collect_scan_data will return.""" """Return a scan thing that is mocked so that _collect_scan_data will run."""
# Give the scan thing a scan invocation logger so it thinks a scan is running. # Give the scan thing a scan invocation logger so it thinks a scan is running.
smart_scan_thing._scan_logger = LOGGER smart_scan_thing._scan_logger = LOGGER
mocker.patch.object( mocker.patch.object(
@ -331,6 +336,7 @@ def scan_thing_mocked_for_run_scan(scan_thing_mocked_for_scan_data, mocker):
scan_thing = scan_thing_mocked_for_scan_data scan_thing = scan_thing_mocked_for_scan_data
scan_thing._cam = MockCameraThing() scan_thing._cam = MockCameraThing()
scan_thing._scan_images_taken = 0 scan_thing._scan_images_taken = 0
mocker.patch.object(scan_thing, "_cancel")
mocker.patch.object(scan_thing, "_main_scan_loop") mocker.patch.object(scan_thing, "_main_scan_loop")
mocker.patch.object(scan_thing, "_return_to_starting_position") mocker.patch.object(scan_thing, "_return_to_starting_position")
mocker.patch.object(scan_thing, "_perform_final_stitch") mocker.patch.object(scan_thing, "_perform_final_stitch")
@ -339,23 +345,158 @@ def scan_thing_mocked_for_run_scan(scan_thing_mocked_for_scan_data, mocker):
return scan_thing return scan_thing
def test_run_scan(scan_thing_mocked_for_run_scan): def check_run_scan(scan_thing, caplog, expected_exception=None):
"""Run _save_final_scan_data, check save is called with final results in ScanData.""" """Run _run_scan with a mocked scan_thing, capture logs and call counts.
scan_thing = scan_thing_mocked_for_run_scan
scan_thing._scan_data = scan_thing._run_scan()
scan_thing._cam.start_streaming.assert_called() This can be used to check how run_scan executes in different exit modes.
# Save scan data is called at the start and the end! A separate tests is above for scan completing with no exception.
assert scan_thing._ongoing_scan.save_scan_data.call_count == 2
# The scan was a success
final_scan_data = scan_thing._ongoing_scan.save_scan_data.call_args[0][0]
assert final_scan_data.scan_result == "success"
:returns: a tuple of:
* The final result as reported in scan_data
* The logging records
* a dictionary of call counts
"""
if expected_exception is None:
with caplog.at_level(logging.WARNING):
scan_thing._scan_data = scan_thing._run_scan()
else:
with pytest.raises(expected_exception), caplog.at_level(logging.WARNING):
scan_thing._scan_data = scan_thing._run_scan()
# The preview stitcher object should still exist. And images dir should be set. # The preview stitcher object should still exist. And images dir should be set.
assert scan_thing._preview_stitcher.images_dir == "scans/test_name_0001/images/" assert scan_thing._preview_stitcher.images_dir == "scans/test_name_0001/images/"
# Other calls should be: final_scan_data = scan_thing._ongoing_scan.save_scan_data.call_args[0][0]
scan_thing._main_scan_loop.assert_called() calls = {
scan_thing._return_to_starting_position.assert_called() "cam_start_streaming_calls": scan_thing._cam.start_streaming.call_count,
scan_thing._perform_final_stitch.assert_called() "main_scan_loop_calls": scan_thing._main_scan_loop.call_count,
scan_thing.purge_empty_scans.assert_called() "return_to_start_calls": scan_thing._return_to_starting_position.call_count,
"perform_final_stitch_calls": scan_thing._perform_final_stitch.call_count,
"purge_empty_scans_calls": scan_thing.purge_empty_scans.call_count,
"save_scan_data_calls": scan_thing._ongoing_scan.save_scan_data.call_count,
}
return final_scan_data.scan_result, caplog.records, calls
def test_run_scan(scan_thing_mocked_for_run_scan, caplog):
"""Run _save_final_scan_data, check save is called with final results in ScanData."""
result, logs, calls = check_run_scan(scan_thing_mocked_for_run_scan, caplog)
assert result == "success"
assert len(logs) == 0
expected_calls_numbers = {
"cam_start_streaming_calls": 2,
"main_scan_loop_calls": 1,
"return_to_start_calls": 1,
"perform_final_stitch_calls": 1,
"purge_empty_scans_calls": 1,
"save_scan_data_calls": 2,
}
assert calls == expected_calls_numbers
def test_run_scan_wrong_image_value(scan_thing_mocked_for_run_scan, caplog):
"""Check correct methods called if _scan_images_taken doesn't start at 0.
This should never happen but shows a good clear path for an error before
_main_scan_loop starts.
"""
scan_thing = scan_thing_mocked_for_run_scan
scan_thing._scan_images_taken = 2
result, logs, calls = check_run_scan(scan_thing, caplog, RuntimeError)
assert result.startswith("RuntimeError:")
assert len(logs) == 1
assert logs[0].levelno == logging.ERROR
# Main loop not run, nor are return to start, final stitch, or purging of empty
# scans. Save scan data is still called twice
expected_calls_numbers = {
"cam_start_streaming_calls": 2,
"main_scan_loop_calls": 0,
"return_to_start_calls": 0,
"perform_final_stitch_calls": 0,
"purge_empty_scans_calls": 0,
"save_scan_data_calls": 2,
}
assert calls == expected_calls_numbers
def test_run_scan_err_in_main_loop(scan_thing_mocked_for_run_scan, caplog, mocker):
"""Check correct methods called if main_loop errors."""
scan_thing = scan_thing_mocked_for_run_scan
mocker.patch.object(
scan_thing, "_main_scan_loop", side_effect=FileNotFoundError("mocked")
)
result, logs, calls = check_run_scan(scan_thing, caplog, FileNotFoundError)
assert result.startswith("FileNotFoundError:")
assert len(logs) == 1
assert logs[0].levelno == logging.ERROR
# Main loop not run, nor are return to start, final stitch, or purging of empty
# scans. Save scan data is still called twice
expected_calls_numbers = {
"cam_start_streaming_calls": 2,
"main_scan_loop_calls": 1,
"return_to_start_calls": 0,
"perform_final_stitch_calls": 0,
"purge_empty_scans_calls": 0,
"save_scan_data_calls": 2,
}
assert calls == expected_calls_numbers
def test_run_scan_cancelled(scan_thing_mocked_for_run_scan, caplog, mocker):
"""Check correct methods called scan is cancelled."""
scan_thing = scan_thing_mocked_for_run_scan
mocker.patch.object(
scan_thing, "_main_scan_loop", side_effect=InvocationCancelledError()
)
result, logs, calls = check_run_scan(scan_thing, caplog)
assert result == "cancelled by user"
# No logs at warning level.
assert len(logs) == 0
# Main loop not run, nor are return to start, final stitch, or purging of empty
# scans. Save scan data is still called twice
expected_calls_numbers = {
"cam_start_streaming_calls": 2,
"main_scan_loop_calls": 1,
"return_to_start_calls": 1,
"perform_final_stitch_calls": 1,
"purge_empty_scans_calls": 1,
"save_scan_data_calls": 2,
}
assert calls == expected_calls_numbers
def test_run_scan_fill_disk(scan_thing_mocked_for_run_scan, caplog, mocker):
"""Check correct methods called if disk fills up."""
scan_thing = scan_thing_mocked_for_run_scan
mocker.patch.object(
scan_thing, "_main_scan_loop", side_effect=NotEnoughFreeSpaceError()
)
result, logs, calls = check_run_scan(scan_thing, caplog, NotEnoughFreeSpaceError)
assert result.startswith("NotEnoughFreeSpaceError:")
assert len(logs) == 1
assert logs[0].levelno == logging.ERROR
# Main loop not run, nor are return to start, final stitch, or purging of empty
# scans. Save scan data is still called twice
expected_calls_numbers = {
"cam_start_streaming_calls": 2,
"main_scan_loop_calls": 1,
"return_to_start_calls": 0,
"perform_final_stitch_calls": 0,
"purge_empty_scans_calls": 0,
"save_scan_data_calls": 2,
}
assert calls == expected_calls_numbers