From 8f52841f670004c99e820a7631088d707930c340 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 11 Jan 2026 14:10:54 +0000 Subject: [PATCH] Add tests for new simulated camera poperites that update the canvas --- .../things/camera/simulation.py | 20 ++- tests/unit_tests/test_simulated_camera.py | 115 ++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 9bd64338..837faba8 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -50,10 +50,12 @@ DOWNSAMPLE = 2 # for a nice gain in quality. SPRITE_UPSAMPLE = 4 +# A list of 6 digit hex colour codes separated by ;. Allow a trailing ; 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})$") +# regex to capture R, G and B @overload @@ -71,7 +73,7 @@ def _downsample_shape( return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE) if len(shape) == 3: 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]: @@ -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 semicolons. :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: colours = colour_str.split(";") - colour_str = colours[RNG.integers(0, len(colours))] - colour_str = colour_str.lower().strip() - colour_match = COLOUR_REGEX.match(colour_str) + if len(colours) > 1 and colours[-1].strip() == "": + colours.pop(-1) + 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: raise ValueError( f"{colour_str} is not a valid colour. Please use HTML hex notation." diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py index 0e7442ea..0c1f35f0 100644 --- a/tests/unit_tests/test_simulated_camera.py +++ b/tests/unit_tests/test_simulated_camera.py @@ -1,9 +1,14 @@ """Test the functionality specific to the simulated camera.""" +import logging + import pytest +from hypothesis import given +from hypothesis import strategies as st 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.stage.dummy import DummyStage @@ -18,6 +23,116 @@ def test_env() -> lt.ThingClient: 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): """Test that the simulated camera can be calibrated and reports calibration correctly.""" camera = test_env.get_thing_by_type(SimulatedCamera)