Add tests for new simulated camera poperites that update the canvas

This commit is contained in:
Julian Stirling 2026-01-11 14:10:54 +00:00
parent dfe50f5c56
commit 8f52841f67
2 changed files with 129 additions and 6 deletions

View file

@ -50,10 +50,12 @@ DOWNSAMPLE = 2
# for a nice gain in quality. # for a nice gain in quality.
SPRITE_UPSAMPLE = 4 SPRITE_UPSAMPLE = 4
# A list of 6 digit hex colour codes separated by ;. Allow a trailing ;
COLOUR_LIST_REGEX = re.compile( COLOUR_LIST_REGEX = re.compile(
r"\s*^(#[0-9a-fA-F]{6})\s*(?:;\s*(#[0-9a-fA-F]{6})\s*)*$" r"^\s*(#[0-9a-fA-F]{6})\s*(?:;\s*(#[0-9a-fA-F]{6})\s*)*;?\s*$"
) )
COLOUR_REGEX = re.compile(r"^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$") COLOUR_REGEX = re.compile(r"^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$")
# regex to capture R, G and B
@overload @overload
@ -71,7 +73,7 @@ def _downsample_shape(
return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE) return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE)
if len(shape) == 3: if len(shape) == 3:
return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE, shape[2]) return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE, shape[2])
raise ValueError("A shape should be a 2 or 3 element tuple.") raise ValueError("Shape should be a 2 or 3 element tuple.")
def colour_str_to_colour(colour_str: str) -> tuple[int, int, int]: def colour_str_to_colour(colour_str: str) -> tuple[int, int, int]:
@ -80,13 +82,19 @@ def colour_str_to_colour(colour_str: str) -> tuple[int, int, int]:
:param colour_str: Should be a hex colour such as #33aa33 or a list separated by :param colour_str: Should be a hex colour such as #33aa33 or a list separated by
semicolons. semicolons.
:return: The colour as a tuple of 3 integers from 0 to 255 in value :return: The colour as a tuple of 3 integers from 0 to 255 in value
:raises ValueError: If the hex string is not valid. :raises ValueError: If the hex string is not valid. This should never happen if the
user enters a bad colour string as the colour property setter checks the
whole string regex.
""" """
if ";" in colour_str: if ";" in colour_str:
colours = colour_str.split(";") colours = colour_str.split(";")
colour_str = colours[RNG.integers(0, len(colours))] if len(colours) > 1 and colours[-1].strip() == "":
colour_str = colour_str.lower().strip() colours.pop(-1)
colour_match = COLOUR_REGEX.match(colour_str) single_colour_str = colours[RNG.integers(0, len(colours))]
else:
single_colour_str = colour_str
single_colour_str = single_colour_str.lower().strip()
colour_match = COLOUR_REGEX.match(single_colour_str)
if colour_match is None: if colour_match is None:
raise ValueError( raise ValueError(
f"{colour_str} is not a valid colour. Please use HTML hex notation." f"{colour_str} is not a valid colour. Please use HTML hex notation."

View file

@ -1,9 +1,14 @@
"""Test the functionality specific to the simulated camera.""" """Test the functionality specific to the simulated camera."""
import logging
import pytest import pytest
from hypothesis import given
from hypothesis import strategies as st
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.things.camera import simulation
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.stage.dummy import DummyStage
@ -18,6 +23,116 @@ def test_env() -> lt.ThingClient:
yield env yield env
def test_downsample_shape_2d():
"""Test downsampling for 2D array."""
shape_2d = (100, 80)
result_2d = simulation._downsample_shape(shape_2d)
assert len(result_2d) == 2
assert result_2d == (100 // simulation.DOWNSAMPLE, 80 // simulation.DOWNSAMPLE)
def test_downsample_shape_3d():
"""Test downsampling for 3D array, should not affect 3rd axis or shape."""
shape_3d = (120, 60, 3)
result_3d = simulation._downsample_shape(shape_3d)
assert len(result_3d) == 3
assert result_3d == (120 // simulation.DOWNSAMPLE, 60 // simulation.DOWNSAMPLE, 3)
def test_downsample_shape_invalid_length():
"""Shapes that are not length 2 or 3 should raise ValueError."""
with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."):
simulation._downsample_shape((1,))
with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."):
simulation._downsample_shape((1, 2, 3, 4))
def all_colours_present(
col_str: str, colours: list[tuple[int, int, int]], tries: int = 100
) -> bool:
"""Check that for a given colour string that all listed colours are returned.
A helper function for testing simulation.colour_str_to_colour. As the result is
randomised the test just tries multiple times. In theory could fail, so pick
tries high enough if there are lots of colours in col_str.
"""
found = [False for _c in colours]
for _i in range(tries):
col_tuple = simulation.colour_str_to_colour(col_str)
if col_tuple not in colours:
raise ValueError("Unexpected colour returned.")
found[colours.index(col_tuple)] = True
# Don't exit early or we don't confrm that extra colours are not returned.
return all(found)
def test_colour_str_to_colour():
"""Test colour_str_to_colour with some basic predefined test cases."""
# A basic test
assert simulation.colour_str_to_colour("#123456") == (0x12, 0x34, 0x56)
# A basic test with a trailing semicolon and surrounding spacing
assert simulation.colour_str_to_colour(" #123456 ; ") == (0x12, 0x34, 0x56)
# A 2 colour test
assert all_colours_present(
"#123456; #654321", [(0x12, 0x34, 0x56), (0x65, 0x43, 0x21)]
)
# A failure_test (to check the helper function works!)
with pytest.raises(ValueError, match="Unexpected colour returned."):
assert all_colours_present(
"#123456; #654321", [(0x12, 0x34, 0x56), (0x11, 0x11, 0x11)]
)
# And some incorrect strings that should fire an error in colour_str_to_colour
bad_colours = ["foobar", "pink", "#123", "#123456, #654321"]
for colour_str in bad_colours:
with pytest.raises(
ValueError, match=r".*not a valid colour. Please use HTML hex notation."
):
simulation.colour_str_to_colour(colour_str)
@given(st.from_regex(simulation.COLOUR_LIST_REGEX, fullmatch=True))
def test_colour_list_regex(colour_str):
"""Check that anything matching the regex doesn't error when generating colours.
This will error if colour_str when splot into individual colours produces
# an incorrect colour. Trying 100 times for each colour_str as the returned colour
is randomised. Hypothesis will try to create the strings that match the regex but
break the test.
"""
for _ in range(100):
simulation.colour_str_to_colour(colour_str)
def test_canvas_regeneration(test_env, caplog):
"""Check canvas is regenerated if blob density of colour are changed."""
camera = test_env.get_thing_by_type(SimulatedCamera)
cached_canvas = camera.canvas
original_colour = camera.colour
# First try a bad colour string
with caplog.at_level(logging.WARNING):
camera.colour = "foobar"
assert len(caplog.messages) == 1
assert caplog.messages[0] == "foobar is not a valid colour string."
# Value and canvas unchanged
assert camera.colour == original_colour
assert camera.canvas is cached_canvas
# Set a valid colour
camera.colour = "#123456"
assert camera.colour == "#123456"
# canvas updated
assert camera.canvas is not cached_canvas
# Cache again
cached_canvas = camera.canvas
camera.blob_density = 321
assert camera.blob_density == 321
# Canvas updated again
assert camera.canvas is not cached_canvas
def test_simulation_cam_calibration(test_env): def test_simulation_cam_calibration(test_env):
"""Test that the simulated camera can be calibrated and reports calibration correctly.""" """Test that the simulated camera can be calibrated and reports calibration correctly."""
camera = test_env.get_thing_by_type(SimulatedCamera) camera = test_env.get_thing_by_type(SimulatedCamera)