Merge branch 'test-env' into 'v3'
Use new LabThingsTestEnv thoughout tests See merge request openflexure/openflexure-microscope-server!454
This commit is contained in:
commit
ff976e7187
52 changed files with 271 additions and 269 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -69,7 +69,6 @@ target/
|
|||
|
||||
#Big-o files
|
||||
*.data
|
||||
tests/images/out/
|
||||
|
||||
#IDE files
|
||||
.vscode/
|
||||
|
|
@ -89,8 +88,8 @@ openflexure_settings/
|
|||
/src/openflexure_microscope_server/static/
|
||||
|
||||
# Files created by test utilities
|
||||
/tests/utilities/*.pstats
|
||||
/tests/utilities/*.png
|
||||
/tests/unit_tests/utilities/*.pstats
|
||||
/tests/unit_tests/utilities/*.png
|
||||
|
||||
# Files created by simulator
|
||||
openflexure/
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ server_integration_tests:
|
|||
- job: build
|
||||
artifacts: true
|
||||
script:
|
||||
- integration-tests/testfile.py
|
||||
- tests/integration_tests/testfile.py
|
||||
|
||||
pages:
|
||||
needs:
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
"""Utilities to help with testing the camera."""
|
||||
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
|
||||
@contextmanager
|
||||
def camera_test_client_and_server(settings_folder: Optional[str] = None):
|
||||
"""Yield a camera ThingClient and the associated camera server.
|
||||
|
||||
This is a context manager, not a pytest fixture, as it needs to be created
|
||||
multiple times in some tests.
|
||||
|
||||
:param cam: The camera Thing to be used. If not supplied a new one will be created.
|
||||
:param settings_folder: The settings folder for the camera, if none is supplied, new
|
||||
temporary directory will be used as the settings folder.
|
||||
"""
|
||||
# Create a temp dir, if the setting folder is set it isn't really needed
|
||||
# but doesn't add much overhead.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
if settings_folder is None:
|
||||
settings_folder = tmpdir
|
||||
thing_conf = {
|
||||
"camera": "openflexure_microscope_server.things.camera.picamera:StreamingPiCamera2",
|
||||
}
|
||||
server = lt.ThingServer(things=thing_conf, settings_folder=settings_folder)
|
||||
|
||||
with TestClient(server.app) as test_client:
|
||||
client = lt.ThingClient.from_url("/camera/", client=test_client)
|
||||
yield client, server
|
||||
del server
|
||||
|
||||
|
||||
@contextmanager
|
||||
def camera_test_client(settings_folder: Optional[str] = None):
|
||||
"""Yield a camera ThingClient on a camera server.
|
||||
|
||||
This is a context manager, not a pytest fixture, as it needs to be created
|
||||
multiple times in some tests.
|
||||
|
||||
:param cam: The camera Thing to be used. If not supplied a new one will be created.
|
||||
:param settings_folder: The settings folder for the camera, if none is supplied, new
|
||||
temporary directory will be used as the settings folder.
|
||||
"""
|
||||
with camera_test_client_and_server(
|
||||
settings_folder=settings_folder
|
||||
) as client_and_server:
|
||||
yield client_and_server[0]
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"""Shared fixtures for picamera tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from .cam_test_utils import camera_test_client_and_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def picamera_client_and_server() -> lt.ThingClient:
|
||||
"""Initialise a test picamera_client and server for the StreamingPiCamera2 Thing.
|
||||
|
||||
This fixture:
|
||||
|
||||
* Sets up a ThingServer,
|
||||
* Registers a StreamingPiCamera2 instance at the "camera" endpoint
|
||||
* Yields a ThingClient and the server for interacting with it during tests.
|
||||
* The picamera thing can be found at server.things["camera"]
|
||||
"""
|
||||
with camera_test_client_and_server() as client_and_server:
|
||||
yield client_and_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def picamera_client(picamera_client_and_server) -> lt.ThingClient:
|
||||
"""Initialise a test picamera_client for the StreamingPiCamera2 Thing.
|
||||
|
||||
This fixture:
|
||||
|
||||
* Sets up a ThingServer,
|
||||
* Registers a StreamingPiCamera2 instance at the "camera" endpoint
|
||||
* return a ThingClient for interacting with it during tests.
|
||||
"""
|
||||
return picamera_client_and_server[0]
|
||||
Binary file not shown.
|
|
@ -52,7 +52,7 @@ def _get_hashes(include_coverage: bool = False) -> dict[str, str]:
|
|||
|
||||
def run_tests() -> None:
|
||||
"""Run the picamera tests, zip the coverage database, hash relevant source files."""
|
||||
subprocess.run(["pytest", "hardware-specific-tests"], check=True)
|
||||
subprocess.run(["pytest", "tests/hardware_specific_tests"], check=True)
|
||||
shutil.copyfile(".coverage", COVERAGE_FILE)
|
||||
hash_dict = _get_hashes(include_coverage=True)
|
||||
with open(HASH_FILE, "w", encoding="utf-8") as json_file:
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ addopts = [
|
|||
|
||||
norecursedirs = [".git", "build", "node_modules"]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
testpaths = ["tests/unit_tests"]
|
||||
|
||||
[tool.ruff.format]
|
||||
# Use native line endings for all files
|
||||
|
|
@ -144,7 +144,7 @@ ignore = [
|
|||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# All testing dirs
|
||||
"{tests,integration-tests,hardware-specific-tests}/**" = [
|
||||
"{tests}/**" = [
|
||||
"B018", # Complaining about useless attribute access in tests, but we need them to check errors are raised
|
||||
"ANN", # Tests are not typehinted for fixtures etc
|
||||
"S101", # Allow asserts in tests
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
"""The unit-test suite for the OpenFlexure Microscope Server.
|
||||
"""Tests for the OpenFlexure Server.
|
||||
|
||||
This package contains all of the unit tests that can be run without specific
|
||||
hardware. See also the `hardware-specific-tests` directory and the
|
||||
`integration-tests` directory for more testing!.
|
||||
Each test suite should be run separately.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -34,13 +34,13 @@ As before, the server must be stopped before running these tests.
|
|||
|
||||
The camera test are very slow as they run on hardware. They can be run with:
|
||||
|
||||
pytest hardware-specific-tests
|
||||
pytest tests/hardware_specific_tests
|
||||
|
||||
However, this will not archive the tests in the Git repository for reporting the coverage. For this, see the section above on reporting the coverage.
|
||||
|
||||
When writing and debugging these unit tests, it is often best to run a specific test and to use the `-s` flag to see the print statements. It is also often useful to use `--pdb` to drop you into a python debug session on any failure. For example, you might run:
|
||||
|
||||
pytest hardware-specific-tests/picamera2/test_exposure_time_drift.py::test_exposure_time_saves_and_loads -s --pdb
|
||||
pytest tests/hardware_specific_tests/picamera2/test_exposure_time_drift.py::test_exposure_time_saves_and_loads -s --pdb
|
||||
|
||||
### CI explanation
|
||||
|
||||
37
tests/hardware_specific_tests/picamera2/cam_test_utils.py
Normal file
37
tests/hardware_specific_tests/picamera2/cam_test_utils.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Utilities to help with testing the camera."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
|
||||
|
||||
from ...shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
|
||||
@contextmanager
|
||||
def camera_test_env(settings_folder: Optional[str] = None):
|
||||
"""Yield a test environment with a server that contains just the camera.
|
||||
|
||||
This is a context manager, not a pytest fixture, as it needs to be created
|
||||
multiple times in some tests.
|
||||
|
||||
:param settings_folder: The settings folder for the camera, if none is supplied, new
|
||||
temporary directory will be used as the settings folder.
|
||||
"""
|
||||
thing_conf = {"camera": StreamingPiCamera2}
|
||||
with LabThingsTestEnv(things=thing_conf, settings_folder=settings_folder) as env:
|
||||
yield env
|
||||
|
||||
|
||||
@contextmanager
|
||||
def camera_test_client(settings_folder: Optional[str] = None):
|
||||
"""Yield a camera ThingClient on a camera server.
|
||||
|
||||
This is a context manager, not a pytest fixture, as it needs to be created
|
||||
multiple times in some tests.
|
||||
|
||||
:param settings_folder: The settings folder for the camera, if none is supplied, new
|
||||
temporary directory will be used as the settings folder.
|
||||
"""
|
||||
with camera_test_env(settings_folder=settings_folder) as env:
|
||||
yield env.get_thing_client("camera")
|
||||
21
tests/hardware_specific_tests/picamera2/conftest.py
Normal file
21
tests/hardware_specific_tests/picamera2/conftest.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Shared fixtures for picamera tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from .cam_test_utils import camera_test_env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def picamera_test_env() -> lt.ThingClient:
|
||||
"""Initialise a test environment with only a StreamingPiCamera2 Thing."""
|
||||
with camera_test_env() as env:
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def picamera_client() -> lt.ThingClient:
|
||||
"""Initialise a test picamera_client (in a LabThings test env)."""
|
||||
with camera_test_env() as env:
|
||||
yield env.get_thing_client("camera")
|
||||
|
|
@ -3,13 +3,15 @@
|
|||
import tempfile
|
||||
from copy import deepcopy
|
||||
|
||||
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
|
||||
|
||||
from .cam_test_utils import camera_test_client
|
||||
|
||||
|
||||
def test_calibration(picamera_client_and_server):
|
||||
def test_calibration(picamera_test_env):
|
||||
"""Check that full auto calibrate completes and set the expected values."""
|
||||
picamera_client, server = picamera_client_and_server
|
||||
picamera_thing = server.things["camera"]
|
||||
picamera_client = picamera_test_env.get_thing_client("camera")
|
||||
picamera_thing = picamera_test_env.get_thing_by_type(StreamingPiCamera2)
|
||||
# Check the calibration_required property used by the calibration wizard
|
||||
assert picamera_thing.calibration_required
|
||||
# Save copy of default tuning file for end of test
|
||||
|
|
@ -19,11 +19,10 @@ import labthings_fastapi as lt
|
|||
|
||||
THIS_DIR: str = os.path.dirname(os.path.realpath(__file__))
|
||||
WORKING_DIR: str = os.path.join(THIS_DIR, "working_dir")
|
||||
REPO_DIR: str = os.path.dirname(THIS_DIR)
|
||||
REPO_DIR: str = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
CONFIG_FILE: str = os.path.join(REPO_DIR, "ofm_config_simulation.json")
|
||||
SERVER_CMD: list[str] = [
|
||||
"openflexure-microscope-server",
|
||||
"--fallback",
|
||||
"-c",
|
||||
CONFIG_FILE,
|
||||
]
|
||||
1
tests/shared_utils/__init__.py
Normal file
1
tests/shared_utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Shared utilities for all test suites."""
|
||||
|
|
@ -3,13 +3,15 @@
|
|||
import tempfile
|
||||
import time
|
||||
from types import TracebackType
|
||||
from typing import Any, Optional, Self
|
||||
from typing import Any, Mapping, Optional, Self, TypeVar
|
||||
|
||||
import requests
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
ThingSubclass = TypeVar("ThingSubclass", bound=lt.Thing)
|
||||
|
||||
ACTION_RUNNING_KEYWORDS = ["idle", "pending", "running"]
|
||||
|
||||
|
||||
|
|
@ -29,7 +31,9 @@ class LabThingsTestEnv:
|
|||
"""
|
||||
|
||||
def __init__(
|
||||
self, things: dict[str, lt.Thing | str], settings_folder: Optional[str] = None
|
||||
self,
|
||||
things: Mapping[str, lt.Thing | str],
|
||||
settings_folder: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialise the test environment.
|
||||
|
||||
|
|
@ -41,7 +45,7 @@ class LabThingsTestEnv:
|
|||
"""
|
||||
self._server: Optional[lt.ThingServer]
|
||||
self._test_client: Optional[TestClient]
|
||||
self._thing_config = things
|
||||
self._things_config = things
|
||||
self._settings_folder = settings_folder
|
||||
self._tmp_dir_obj: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ class LabThingsTestEnv:
|
|||
self._tmp_dir_obj = tempfile.TemporaryDirectory()
|
||||
self._settings_folder = self._tmp_dir_obj.name
|
||||
self._server = lt.ThingServer(
|
||||
things=self._thing_config, settings_folder=self._settings_folder
|
||||
things=self._things_config, settings_folder=self._settings_folder
|
||||
)
|
||||
self._test_client = TestClient(self._server.app)
|
||||
self._test_client.__enter__()
|
||||
|
|
@ -87,13 +91,61 @@ class LabThingsTestEnv:
|
|||
if thing_name not in self.server.things:
|
||||
raise ValueError(f"No Thing named {thing_name}")
|
||||
|
||||
def get_thing(self, thing_name: str) -> lt.Thing:
|
||||
"""Get a Thing from the server by name."""
|
||||
def get_thing_by_name(self, thing_name: str) -> lt.Thing:
|
||||
"""Get a Thing from the server by name.
|
||||
|
||||
:param thing_name: The name of the thing to on the server.
|
||||
|
||||
:return: The Thing with the specified name.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
return self.server.things[thing_name]
|
||||
|
||||
def get_thing_by_type(self, thing_class: type[ThingSubclass]) -> ThingSubclass:
|
||||
"""Get a thing by type.
|
||||
|
||||
:param thing_class: The subclass of thing to match.
|
||||
|
||||
:return: The Thing that matches the subclass.
|
||||
|
||||
:raises RuntimeError: If there are multiple things of the same type, or no
|
||||
matching thing. If there are multiple things of this type use
|
||||
``get_thing_by_name`` or ``get_all_things_by_type``.
|
||||
"""
|
||||
matching = self.get_all_things_by_type(thing_class)
|
||||
n_things = len(matching)
|
||||
if n_things == 0:
|
||||
raise RuntimeError(f"No Thing of type {thing_class} on this server.")
|
||||
if n_things > 1:
|
||||
raise RuntimeError(
|
||||
f"Cannot get Thing by type as there are {n_things} of type "
|
||||
f"{thing_class} on this server."
|
||||
)
|
||||
return next(iter(matching.values()))
|
||||
|
||||
def get_all_things_by_type(
|
||||
self, thing_class: type[ThingSubclass]
|
||||
) -> Mapping[str, ThingSubclass]:
|
||||
"""Get a dictionary of all things by matching a type.
|
||||
|
||||
:param thing_class: The subclass of thing to match.
|
||||
|
||||
:return: A dictionary of Things that match the subclass. If none match this
|
||||
will be and empty dictionary.
|
||||
"""
|
||||
matching = {}
|
||||
for thing_name, thing in self.server.things.items():
|
||||
if isinstance(thing, thing_class):
|
||||
matching[thing_name] = thing
|
||||
return matching
|
||||
|
||||
def get_thing_client(self, thing_name: str) -> lt.ThingClient:
|
||||
"""Get a ThingClient for a Thing by name."""
|
||||
"""Get a ThingClient for a Thing by name.
|
||||
|
||||
:param thing_name: The name of the thing to on the server.
|
||||
|
||||
:return: A LabThings ThingClient for the Thing with the specified name.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
thing = self.server.things[thing_name]
|
||||
return lt.ThingClient.from_url(thing.path, self.client)
|
||||
|
|
@ -102,11 +154,27 @@ class LabThingsTestEnv:
|
|||
self,
|
||||
thing_name: str,
|
||||
action_name: str,
|
||||
action_kwargs: Optional[dict[str, Any]] = None,
|
||||
action_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
) -> requests.Response:
|
||||
"""Start an action and return the server response.
|
||||
|
||||
This response can be used to poll or cancel the action.
|
||||
For most purposes the best way to run an action is to use ``get_thing_client``
|
||||
to create a ThingClient. At this point any actions can be run with a similar
|
||||
Python API to calling directly. However, using ThingClient blocks the test
|
||||
thread.
|
||||
|
||||
This function provides an alternative way to start actions without blocking the
|
||||
test thread. It will return the HTTP response, this response can be used to
|
||||
poll or cancel the action. Use this method if you:
|
||||
|
||||
* Want to test cancelling an action.
|
||||
* Want to inspect the actions logs exactly as they would come to a web client
|
||||
* Direcltly interact with the HTTP API
|
||||
|
||||
:param thing_name: The name of the Thing on the server.
|
||||
:param action_name: The name of the action to start.
|
||||
:action_kwargs: The keyword inputs to the action.
|
||||
:return: A Response object with the HTTP response.
|
||||
"""
|
||||
self.check_thing_exists(thing_name)
|
||||
url = f"/{thing_name}/{action_name}"
|
||||
|
|
@ -117,8 +185,11 @@ class LabThingsTestEnv:
|
|||
|
||||
def poll_action(
|
||||
self, response: requests.Response, interval: float = 0.01
|
||||
) -> dict[str, Any]:
|
||||
"""Poll an action until it completes and return the final response data."""
|
||||
) -> Mapping[str, Any]:
|
||||
"""Poll an action until it completes and return the final response data.
|
||||
|
||||
:param response: The response from starting this action with ``start_action``.
|
||||
"""
|
||||
invocation_data = response.json()
|
||||
|
||||
if "status" not in invocation_data:
|
||||
|
|
@ -135,17 +206,20 @@ class LabThingsTestEnv:
|
|||
return invocation_data
|
||||
|
||||
def cancel_action(self, response: requests.Response) -> None:
|
||||
"""Cancel an ongoing action."""
|
||||
"""Cancel an ongoing action.
|
||||
|
||||
:param response: The response from starting this action with ``start_action``.
|
||||
"""
|
||||
invocation_data = response.json()
|
||||
response = self.client.delete(_invocation_href(invocation_data))
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _get_link(obj: dict[str, Any], rel: str) -> dict[str, Any]:
|
||||
def _get_link(obj: Mapping[str, Any], rel: str) -> Mapping[str, Any]:
|
||||
"""Retrieve a link from an object's `links` list, by its `rel` attribute."""
|
||||
return next(link for link in obj["links"] if link["rel"] == rel)
|
||||
|
||||
|
||||
def _invocation_href(invocation_data: dict[str, Any]) -> str:
|
||||
def _invocation_href(invocation_data: Mapping[str, Any]) -> str:
|
||||
"""Get the invocation href from the invocation response data."""
|
||||
return _get_link(invocation_data, "self")["href"]
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""Use the Simulation camera to test base camera functionality."""
|
||||
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def camera_server() -> lt.ThingClient:
|
||||
"""Add the camera to a ThingServer and start a TestClient application.
|
||||
|
||||
The test client will be needed for the camera to run async frame generation code.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
thing_conf = {
|
||||
"camera": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
|
||||
"stage": "openflexure_microscope_server.things.stage.dummy:DummyStage",
|
||||
}
|
||||
|
||||
server = lt.ThingServer(things=thing_conf, settings_folder=tmpdir)
|
||||
yield server
|
||||
|
||||
|
||||
def test_handle_broken_frame(camera_server):
|
||||
"""Monkey patch the the mjpeg steam so 1 in 5 frames are broken, then test operation.
|
||||
|
||||
This simulates the very occasional broken frames that can occur when grabbing
|
||||
directly from the MJPEG stream.
|
||||
"""
|
||||
camera = camera_server.things["camera"]
|
||||
|
||||
# Money patch the mjpeg_stream grab_frame to break 1 in 5 frames.
|
||||
frame_number = 0
|
||||
original_grabber = camera.mjpeg_stream.grab_frame
|
||||
|
||||
async def flaky_grabber():
|
||||
"""Break 1 in 5 frames."""
|
||||
# Use a non-local variable to know the frame count.
|
||||
nonlocal frame_number
|
||||
frame = await original_grabber()
|
||||
if frame_number % 5 == 2:
|
||||
# Make a weird broken frame
|
||||
frame = frame[:2000] + frame[:2000]
|
||||
frame_number += 1
|
||||
return frame
|
||||
|
||||
camera.mjpeg_stream.grab_frame = flaky_grabber
|
||||
|
||||
with TestClient(camera_server.app):
|
||||
# Check that this does cause broken frames.
|
||||
# The noqa is because we don't know exactly when the error is thrown so we
|
||||
# can't have a single simple statement in the pytest raises.
|
||||
with pytest.raises( # noqa PT012
|
||||
OSError, match="broken data stream when reading image file"
|
||||
):
|
||||
for _i in range(15):
|
||||
jpeg = camera.grab_jpeg()
|
||||
np.asarray(Image.open(jpeg.open()))
|
||||
|
||||
# Check that grab_as_array handles the broken frames and completes without
|
||||
# the same error.
|
||||
for _i in range(15):
|
||||
array = camera.grab_as_array()
|
||||
assert isinstance(array, np.ndarray)
|
||||
|
||||
|
||||
def test_simulation_cam_calibration(camera_server):
|
||||
"""Test that the simulated camera can be calibrated and reports calibration correctly."""
|
||||
camera = camera_server.things["camera"]
|
||||
with TestClient(camera_server.app):
|
||||
assert camera.calibration_required
|
||||
camera.full_auto_calibrate()
|
||||
assert not camera.calibration_required
|
||||
assert camera.background_detector_status.ready
|
||||
6
tests/unit_tests/__init__.py
Normal file
6
tests/unit_tests/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""The unit-test suite for the OpenFlexure Microscope Server.
|
||||
|
||||
This package contains all of the unit tests that can be run without specific
|
||||
hardware. See also the `hardware_specific_tests` directory and the
|
||||
`integration_tests` directory for more testing!.
|
||||
"""
|
||||
69
tests/unit_tests/test_camera.py
Normal file
69
tests/unit_tests/test_camera.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Use the Simulation camera to test base camera functionality."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||
|
||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_env() -> lt.ThingClient:
|
||||
"""Yield a test environment with the Simulated Camera and Dummy Stage."""
|
||||
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
||||
with LabThingsTestEnv(things=thing_conf) as env:
|
||||
yield env
|
||||
|
||||
|
||||
def test_handle_broken_frame(test_env):
|
||||
"""Monkey patch the the mjpeg steam so 1 in 5 frames are broken, then test operation.
|
||||
|
||||
This simulates the very occasional broken frames that can occur when grabbing
|
||||
directly from the MJPEG stream.
|
||||
"""
|
||||
camera = test_env.get_thing_by_type(SimulatedCamera)
|
||||
|
||||
# Money patch the mjpeg_stream grab_frame to break 1 in 5 frames.
|
||||
frame_number = 0
|
||||
original_grabber = camera.mjpeg_stream.grab_frame
|
||||
|
||||
async def flaky_grabber():
|
||||
"""Break 1 in 5 frames."""
|
||||
# Use a non-local variable to know the frame count.
|
||||
nonlocal frame_number
|
||||
frame = await original_grabber()
|
||||
if frame_number % 5 == 2:
|
||||
# Make a weird broken frame
|
||||
frame = frame[:2000] + frame[:2000]
|
||||
frame_number += 1
|
||||
return frame
|
||||
|
||||
camera.mjpeg_stream.grab_frame = flaky_grabber
|
||||
|
||||
# Check that this does cause broken frames.
|
||||
# The noqa is because we don't know exactly when the error is thrown so we
|
||||
# can't have a single simple statement in the pytest raises.
|
||||
with pytest.raises(OSError, match="broken data stream when reading image file"): # noqa PT012
|
||||
for _i in range(15):
|
||||
jpeg = camera.grab_jpeg()
|
||||
np.asarray(Image.open(jpeg.open()))
|
||||
|
||||
# Check that grab_as_array handles the broken frames and completes without
|
||||
# the same error.
|
||||
for _i in range(15):
|
||||
array = camera.grab_as_array()
|
||||
assert isinstance(array, np.ndarray)
|
||||
|
||||
|
||||
def test_simulation_cam_calibration(test_env):
|
||||
"""Test that the simulated camera can be calibrated and reports calibration correctly."""
|
||||
camera = test_env.get_thing_by_type(SimulatedCamera)
|
||||
assert camera.calibration_required
|
||||
camera.full_auto_calibrate()
|
||||
assert not camera.calibration_required
|
||||
assert camera.background_detector_status.ready
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for camera classes. These tests focus on checking the camera APIs are equivalent.
|
||||
|
||||
Tests for specific camera hardware are in the hardware-specific-tests directory. Tests
|
||||
Tests for specific camera hardware are in the hardware_specific_tests directory. Tests
|
||||
on camera functionality using the simulation camera are in "test_camera".
|
||||
"""
|
||||
|
||||
|
|
@ -4,27 +4,23 @@ Rather than spinning up a full uvicorn webserver for each test these tests use
|
|||
the FastAPI ``TestClient`` or directly communicate with the underlying
|
||||
LabThings-FastAPI code. This increases speed of testing significantly.
|
||||
|
||||
For tests that require a full running server see the ``integration-tests``
|
||||
directory in the root of the repository.
|
||||
For tests that require a full running server see the ``integration_tests``
|
||||
directory in the tests directory.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import piexif
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
import labthings_fastapi as lt
|
||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def thing_server():
|
||||
def test_env():
|
||||
"""Yield a server with a very basic configuration."""
|
||||
temp_folder = tempfile.TemporaryDirectory()
|
||||
thing_conf = {
|
||||
"camera": {
|
||||
"class": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
|
||||
|
|
@ -41,51 +37,28 @@ def thing_server():
|
|||
"autofocus": "openflexure_microscope_server.things.autofocus:AutofocusThing",
|
||||
"camera_stage_mapping": "openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper",
|
||||
}
|
||||
|
||||
server = lt.ThingServer(things=thing_conf, settings_folder=temp_folder.name)
|
||||
|
||||
assert os.path.exists(os.path.join(temp_folder.name, "camera"))
|
||||
# Note: yield is important. If return is used the temp folder gets deleted
|
||||
# before the test runs. Silence PT022 as ruff doesn't think yield is needed.
|
||||
yield server # noqa: PT022
|
||||
with LabThingsTestEnv(things=thing_conf) as env:
|
||||
yield env
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(thing_server):
|
||||
"""Yield a FastAPI TestClient for the server."""
|
||||
with TestClient(thing_server.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slower_client(thing_server):
|
||||
"""Yield a FastAPI TestClient for the server with a slower moving stage.
|
||||
|
||||
The step time for the stage is 100 microseconds rather than
|
||||
1 microsecond.
|
||||
"""
|
||||
thing_server.things["stage"].step_time = 0.0001
|
||||
with TestClient(thing_server.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_autofocus(slower_client):
|
||||
def test_autofocus(test_env):
|
||||
"""Test Fast Autofocus can run doesn't raise an exception."""
|
||||
client = slower_client
|
||||
autofocus = lt.ThingClient.from_url("/autofocus/", client)
|
||||
# Adjust the time for stage is 100 microseconds rather than 1 microsecond.
|
||||
test_env.get_thing_by_name("stage").step_time = 0.0001
|
||||
autofocus = test_env.get_thing_client("autofocus")
|
||||
_ = autofocus.fast_autofocus()
|
||||
|
||||
|
||||
def test_grab_jpeg(client):
|
||||
def test_grab_jpeg(test_env):
|
||||
"""Check that grab_jpeg returns a blob that can be opened."""
|
||||
camera = lt.ThingClient.from_url("/camera/", client)
|
||||
camera = test_env.get_thing_client("camera")
|
||||
blob = camera.grab_jpeg()
|
||||
_image = Image.open(blob.open())
|
||||
|
||||
|
||||
def test_capture_jpeg_metadata(client):
|
||||
def test_capture_jpeg_metadata(test_env):
|
||||
"""Check that the position is encoded into the image metadata."""
|
||||
camera = lt.ThingClient.from_url("/camera/", client)
|
||||
camera = test_env.get_thing_client("camera")
|
||||
blob = camera.capture_jpeg()
|
||||
image = Image.open(blob.open())
|
||||
exif_dict = piexif.load(image.info["exif"])
|
||||
|
|
@ -94,9 +67,9 @@ def test_capture_jpeg_metadata(client):
|
|||
assert "position" in metadata["stage"]
|
||||
|
||||
|
||||
def test_stage(client):
|
||||
def test_stage(test_env):
|
||||
"""Test moving th stage forwards and backwards."""
|
||||
stage = lt.ThingClient.from_url("/stage/", client)
|
||||
stage = test_env.get_thing_client("stage")
|
||||
start = stage.position
|
||||
move = {"x": 1, "y": 2, "z": 3}
|
||||
stage.move_relative(**move)
|
||||
|
|
@ -109,16 +82,16 @@ def test_stage(client):
|
|||
assert s == p
|
||||
|
||||
|
||||
def test_capture_array(client):
|
||||
def test_capture_array(test_env):
|
||||
"""Capture array from simulation and check the size is as expected."""
|
||||
camera = lt.ThingClient.from_url("/camera/", client)
|
||||
camera = test_env.get_thing_client("camera")
|
||||
array = np.asarray(camera.capture_array())
|
||||
assert array.shape == (240, 320, 3)
|
||||
|
||||
|
||||
def test_camera_stage_mapping_calibration(client):
|
||||
def test_camera_stage_mapping_calibration(test_env):
|
||||
"""Check that camera stage mapping can run without an exception."""
|
||||
camera = lt.ThingClient.from_url("/camera/", client)
|
||||
camera = test_env.get_thing_client("camera")
|
||||
camera.settling_time = 0
|
||||
camera_stage_mapping = lt.ThingClient.from_url("/camera_stage_mapping/", client)
|
||||
camera_stage_mapping = test_env.get_thing_client("camera_stage_mapping")
|
||||
camera_stage_mapping.calibrate_xy()
|
||||
|
|
@ -11,7 +11,7 @@ import pytest
|
|||
from openflexure_microscope_server import server as ofm_server
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.dirname(THIS_DIR)
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
FULL_CONFIG = os.path.join(REPO_ROOT, "ofm_config_full.json")
|
||||
SIM_CONFIG = os.path.join(REPO_ROOT, "ofm_config_simulation.json")
|
||||
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
"""Test the stage without creating a full HTTP server and socket connection."""
|
||||
|
||||
import itertools
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import HTTPStatusError
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
|
@ -12,12 +10,15 @@ from hypothesis import strategies as st
|
|||
import labthings_fastapi as lt
|
||||
from labthings_fastapi.testing import create_thing_without_server
|
||||
|
||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||
from openflexure_microscope_server.things.stage import (
|
||||
BaseStage,
|
||||
RedefinedBaseMovementError,
|
||||
)
|
||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||
|
||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
# 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),
|
||||
|
|
@ -34,18 +35,6 @@ def dummy_stage():
|
|||
return create_thing_without_server(DummyStage, step_time=0.000001)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_server():
|
||||
"""Yield a server with a very basic configuration."""
|
||||
thing_conf = {
|
||||
"camera": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
|
||||
"stage": "openflexure_microscope_server.things.stage.dummy:DummyStage",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = lt.ThingServer(things=thing_conf, settings_folder=tmpdir)
|
||||
yield server
|
||||
|
||||
|
||||
def test_override_base_movement():
|
||||
"""Child classes of stage should implement functions in the hardware reference frame.
|
||||
|
||||
|
|
@ -157,11 +146,12 @@ def test_direction_inversion(dummy_stage):
|
|||
assert dummy_stage.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
|
||||
|
||||
def test_direction_errors_local_and_http(stage_server):
|
||||
def test_direction_errors_local_and_http():
|
||||
"""Check for expected errors both locally and over http."""
|
||||
dummy_stage = stage_server.things["stage"]
|
||||
with TestClient(stage_server.app) as test_client:
|
||||
stage_client = lt.ThingClient.from_url("/stage/", client=test_client)
|
||||
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
||||
with LabThingsTestEnv(things=thing_conf) as test_env:
|
||||
dummy_stage = test_env.get_thing_by_type(DummyStage)
|
||||
stage_client = test_env.get_thing_client("stage")
|
||||
|
||||
assert stage_client.axis_inverted == {"x": True, "y": False, "z": False}
|
||||
# Can't set an arbitrary value via a client as read only:
|
||||
|
|
@ -22,7 +22,7 @@ from openflexure_microscope_server.stitching import (
|
|||
StitcherValidationError,
|
||||
)
|
||||
|
||||
from .utilities.lt_test_utils import LabThingsTestEnv
|
||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
||||
# A global logger pretending to the logger from a thing
|
||||
LOGGER = logging.getLogger("mock-thing_logger")
|
||||
|
|
@ -16,7 +16,7 @@ from openflexure_microscope_server import utilities
|
|||
|
||||
# Useful for really finding the repo dir when we mock where it is.
|
||||
THIS_DIR = os.path.dirname(__file__)
|
||||
TRUE_REPO_DIR = os.path.dirname(THIS_DIR)
|
||||
TRUE_REPO_DIR = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
|
||||
# For explicit version checking.
|
||||
VER_STRING = "3.0.0-alpha4"
|
||||
Loading…
Add table
Add a link
Reference in a new issue