Merge branch 'v3' into labthings-debug-logging-config
This commit is contained in:
commit
01954167ab
72 changed files with 2722 additions and 1046 deletions
109
tests/shared_utils/md_test_cases.py
Normal file
109
tests/shared_utils/md_test_cases.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Read multi-line test cases from markdown files.
|
||||
|
||||
When checking if text coercion is working multiline Python can be very difficult
|
||||
to read and maintain. This file allows the input and expected results to be written
|
||||
into markdown codeblocks.
|
||||
|
||||
Each test is a second level heading.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Level 2 markdown heading. Group 1 is the text
|
||||
CASE_REGEX = re.compile(r"^##\s(.*)", re.MULTILINE)
|
||||
# Level 3 markdown heading. Group 1 is the text
|
||||
LEVEL3_REGEX = re.compile(r"^###\s(.*)", re.MULTILINE)
|
||||
# Markdown code block.
|
||||
# Group 1 is the language specification after the back ticks
|
||||
# Group 2 is the code
|
||||
CODE_REGEX = re.compile(
|
||||
r"^```([a-zA-Z]+)?\s?\n(?:(.*?)\n)?```", re.MULTILINE | re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
class MdTestCase:
|
||||
"""A class to represent a test case read from a markdown file.
|
||||
|
||||
Useful attributes:
|
||||
|
||||
* title - The title of the test case
|
||||
* input_code - the input code for the test case
|
||||
* result_code - the expected result code for the test case
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, case_text: str, block_type: str) -> None:
|
||||
"""Initialise the test case.
|
||||
|
||||
:param title: The title of the test case.
|
||||
:param case_text: The full text of the test case.
|
||||
:param block_type: The type of code block expected (the label after the triple
|
||||
backticks).
|
||||
"""
|
||||
self.title = title
|
||||
self.block_type = block_type
|
||||
self.input_code, self.result_code = self._parse(case_text)
|
||||
|
||||
def __repr__(self):
|
||||
"""Return the string representation of the test case."""
|
||||
return f"<MdTestCase: {self.title}>"
|
||||
|
||||
def _parse(self, case_text: str) -> tuple[str, str]:
|
||||
"""Parse the case text and return the input and expected return code."""
|
||||
h3_matches = list(LEVEL3_REGEX.finditer(case_text))
|
||||
n_h3_matches = len(h3_matches)
|
||||
if n_h3_matches != 2:
|
||||
raise ValueError(
|
||||
f"Case {self.title} has {n_h3_matches} level 3 headings. 2 expected."
|
||||
)
|
||||
if h3_matches[0].group(1) != "Input" or h3_matches[1].group(1) != "Result":
|
||||
raise ValueError(
|
||||
f"Case {self.title} does not have `Input` and `Result` headings."
|
||||
)
|
||||
|
||||
input_section = case_text[h3_matches[0].end() : h3_matches[1].start()]
|
||||
result_section = case_text[h3_matches[1].end() : None]
|
||||
|
||||
input_code_blocks = list(CODE_REGEX.finditer(input_section))
|
||||
result_code_blocks = list(CODE_REGEX.finditer(result_section))
|
||||
if len(input_code_blocks) != 1 or len(result_code_blocks) != 1:
|
||||
raise ValueError(
|
||||
f"Case {self.title} must have exactly 1 code block per section."
|
||||
)
|
||||
|
||||
input_code_type = input_code_blocks[0].group(1)
|
||||
input_code = input_code_blocks[0].group(2)
|
||||
result_code_type = result_code_blocks[0].group(1)
|
||||
result_code = result_code_blocks[0].group(2)
|
||||
if input_code_type != self.block_type or result_code_type != self.block_type:
|
||||
raise ValueError(
|
||||
f"Case {self.title}: each code block must be labeled as "
|
||||
f"{self.block_type}"
|
||||
)
|
||||
|
||||
# If the code block is empty Regex returns None. Coerce to empty string.
|
||||
input_code = "" if input_code is None else input_code.strip()
|
||||
result_code = "" if result_code is None else result_code.strip()
|
||||
return input_code, result_code
|
||||
|
||||
|
||||
def find_test_cases(path: str, block_type: str) -> list[MdTestCase]:
|
||||
"""Return all test cases in the markdown file.
|
||||
|
||||
:param path: Path to the markdown file containing cases.
|
||||
:param block_type: The label for each triple backtick code block.
|
||||
|
||||
:return: A list of MdTestCase objects for each case.
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as file_obj:
|
||||
text = file_obj.read()
|
||||
test_case_headings = list(CASE_REGEX.finditer(text))
|
||||
test_cases = []
|
||||
n_headings = len(test_case_headings)
|
||||
for i, heading_match in enumerate(test_case_headings):
|
||||
title = heading_match.group(1)
|
||||
start = heading_match.end()
|
||||
end = test_case_headings[i + 1].start() if i + 1 < n_headings else None
|
||||
|
||||
case_text = text[start:end]
|
||||
test_cases.append(MdTestCase(title, case_text, block_type))
|
||||
return test_cases
|
||||
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal file
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# HTML Sanitisation Test Cases
|
||||
|
||||
This file is in markdown not Python for readability.
|
||||
|
||||
Each test is a level 2 heading. The input and expected values are level 3 headings. Multiline inputs and results are treated as single, multiline strings.
|
||||
|
||||
The HTML to test is in triple backtick code blocks.
|
||||
|
||||
Anything not in code blocks is a comment.
|
||||
|
||||
This file is run by /tests/unit_tests/test_ui.py
|
||||
|
||||
## Test scripts removed
|
||||
The script is removed but the contents remain.
|
||||
### Input
|
||||
```html
|
||||
<script>alert("x")</script>
|
||||
<p>Hello</p>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
alert("x")
|
||||
Hello
|
||||
```
|
||||
|
||||
|
||||
## Test script in bold tag
|
||||
### Input
|
||||
```html
|
||||
<b onmouseover=alert('Wufff!')>click me!</b>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
<b>click me!</b>
|
||||
```
|
||||
|
||||
|
||||
## Test script in disallowed tag
|
||||
### Input
|
||||
```html
|
||||
<img src="http://url.to.file.which/not.exist" onerror=alert(document.cookie);>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
```
|
||||
|
||||
|
||||
## Test cookie grabber script is escaped
|
||||
The script should become escaped and have no tags
|
||||
### Input
|
||||
```html
|
||||
<SCRIPT type="text/javascript">
|
||||
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
|
||||
</SCRIPT>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
|
||||
```
|
||||
|
||||
|
||||
## Test with non-http links
|
||||
Remove the javascript from the links.
|
||||
### Input
|
||||
```html
|
||||
<a href="javascript:alert(1)">link</a>
|
||||
<a href="javascript:alert(1)">encoded</a>
|
||||
<a href="mailto:spammy@spam.com">Email</a>
|
||||
<a href="/a/relateive/link">Relative</a>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
<a>link</a>
|
||||
<a>encoded</a>
|
||||
<a>Email</a>
|
||||
<a>Relative</a>
|
||||
```
|
||||
|
||||
|
||||
## Test http links become external
|
||||
Remove the javascript from the links.
|
||||
### Input
|
||||
```html
|
||||
<a href="https://openflexure.org">OpenFlexure</a>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
<a href="https://openflexure.org" target="_blank" rel="noopener noreferrer">OpenFlexure</a>
|
||||
```
|
||||
|
||||
|
||||
## Test basic formatting preserved
|
||||
Check basic elements are not changed
|
||||
### Input
|
||||
```html
|
||||
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
|
||||
```
|
||||
|
||||
|
||||
## Test http link with path preserved
|
||||
Valid HTTP links should preserve their path.
|
||||
### Input
|
||||
```html
|
||||
<a href="https://example.com/docs/page">Docs</a>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
<a href="https://example.com/docs/page" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||
```
|
||||
|
||||
|
||||
## Test http link with fragment preserved
|
||||
Valid HTTP links should preserve their path and fragment.
|
||||
### Input
|
||||
```html
|
||||
<a href="https://example.com/docs/page#section">Docs Section</a>
|
||||
```
|
||||
### Result
|
||||
```html
|
||||
<a href="https://example.com/docs/page#section" target="_blank" rel="noopener noreferrer">Docs Section</a>
|
||||
```
|
||||
|
|
@ -73,7 +73,7 @@ def test_partial_base_classes():
|
|||
bad_algo.ready
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
bad_algo.settings_ui
|
||||
bad_algo.settings_ui()
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
bad_algo.set_background(background_image)
|
||||
|
|
@ -256,7 +256,7 @@ def test_background_detect_settings_ui(detector_cls):
|
|||
As both have identical settings they can be tested together
|
||||
"""
|
||||
detector = create_thing_without_server(detector_cls)
|
||||
ui = detector.settings_ui
|
||||
ui = detector.settings_ui().root
|
||||
|
||||
assert len(ui) == 2
|
||||
assert isinstance(ui[0], PropertyControl)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ from openflexure_microscope_server import utilities
|
|||
def test_merge_patch(target, patch, outcome, err_if_enforce):
|
||||
"""Check that merge_patch returns the expected outcome for each target and patch.
|
||||
|
||||
These test cases are specified in Appedix A of IETF RFC 7396.
|
||||
These test cases are specified in Appendix A of IETF RFC 7396.
|
||||
"""
|
||||
assert utilities.merge_patch(target, patch) == outcome
|
||||
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ def test_bad_scan_names():
|
|||
scan_dir = scan_dir_manager.new_scan_dir("fake scan")
|
||||
assert scan_dir.name == "fake_scan_0001"
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake scan;rm -rf /;")
|
||||
assert scan_dir.name == "fake_scan_rm_-rf____0001"
|
||||
assert scan_dir.name == "fake_scan_rm_-rf_0001"
|
||||
|
||||
|
||||
def test_scan_sequence_and_listing(caplog):
|
||||
|
|
@ -247,6 +247,63 @@ def test_scan_sequence_and_listing(caplog):
|
|||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def test_scan_sequence_case_sensitive():
|
||||
"""Check created scans are added in order and listed correctly, even when cases are different."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
|
||||
# Create some scan data and mark it as successful to get an end date.
|
||||
scan_data = fake_active_scan_data()
|
||||
scan_data.set_final_data(result="Success")
|
||||
# Make 4 scans
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("FAKE_SCAN")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("Fake_Scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("FAKE_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fAkE_sCaN")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
|
||||
# Check they exist and are numbered sequentially
|
||||
all_scans = scan_dir_manager.all_scans
|
||||
assert_unique_of_length(all_scans, 5)
|
||||
assert "fake_scan_0001" in all_scans
|
||||
assert "fake_scan_0002" in all_scans
|
||||
assert "fake_scan_0003" in all_scans
|
||||
assert "fake_scan_0004" in all_scans
|
||||
assert "fake_scan_0005" in all_scans
|
||||
|
||||
|
||||
def test_scan_names_have_single_underscores():
|
||||
"""Check created scans have only one underscore in them, not two."""
|
||||
_clear_scan_dir()
|
||||
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
|
||||
|
||||
# Create some scan data and mark it as successful to get an end date.
|
||||
scan_data = fake_active_scan_data()
|
||||
scan_data.set_final_data(result="Success")
|
||||
# Make 4 scans
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan_")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan__")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan_______")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
|
||||
scan_dir.save_scan_data(scan_data)
|
||||
|
||||
# Check they exist and are numbered sequentially
|
||||
all_scans = scan_dir_manager.all_scans
|
||||
assert_unique_of_length(all_scans, 4)
|
||||
assert "fake_scan_0001" in all_scans
|
||||
assert "fake_scan_0002" in all_scans
|
||||
assert "fake_scan_0003" in all_scans
|
||||
assert "fake_scan_0004" in all_scans
|
||||
|
||||
|
||||
def test_scan_name_non_sequential():
|
||||
"""Check new scan has the correct name if the directories are not sequential."""
|
||||
_clear_scan_dir()
|
||||
|
|
|
|||
|
|
@ -18,7 +18,14 @@ from openflexure_microscope_server.things.scan_workflows import (
|
|||
HistoScanWorkflow,
|
||||
ScanWorkflow,
|
||||
)
|
||||
from openflexure_microscope_server.ui import PropertyControl
|
||||
from openflexure_microscope_server.ui import (
|
||||
Accordion,
|
||||
ActionButton,
|
||||
HeaderBlock,
|
||||
PropertyControl,
|
||||
TextBlock,
|
||||
UIElementList,
|
||||
)
|
||||
|
||||
|
||||
def test_partial_base_classes():
|
||||
|
|
@ -56,7 +63,7 @@ def test_partial_base_classes():
|
|||
bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
bad_workflow.settings_ui
|
||||
bad_workflow.settings_ui()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -370,15 +377,49 @@ def test_histo_acquisition_on_sample(histo_workflow, mocker, caplog):
|
|||
assert histo_workflow._autofocus.run_smart_stack.call_count == 2
|
||||
|
||||
|
||||
def test_histo_workflow_settings_ui(histo_workflow):
|
||||
def test_histo_workflow_settings_ui(histo_workflow, mocker):
|
||||
"""Check that the workflow specifies the expected controls."""
|
||||
ui = histo_workflow.settings_ui
|
||||
mock_prop_control = mocker.Mock(spec=PropertyControl)
|
||||
histo_workflow._background_detector.settings_ui.return_value = UIElementList(
|
||||
[mock_prop_control]
|
||||
)
|
||||
|
||||
assert len(ui) == 8
|
||||
for element in ui:
|
||||
ui = histo_workflow.settings_ui().root
|
||||
|
||||
# The UI is in 4 blocks
|
||||
assert len(ui) == 4
|
||||
# The top is a header
|
||||
assert isinstance(ui[0], HeaderBlock)
|
||||
assert ui[0].text == "Histo Scan"
|
||||
# The then some text (note the & is escaped)
|
||||
assert isinstance(ui[1], TextBlock)
|
||||
fragment = "This scan workflow is optimised for scanning H&E stained biopsies."
|
||||
assert fragment in ui[1].text
|
||||
# Followed by a background detect accordion
|
||||
assert isinstance(ui[2], Accordion)
|
||||
assert ui[2].title == "Background Detect"
|
||||
# And a Scan Settings accordion
|
||||
assert isinstance(ui[3], Accordion)
|
||||
assert ui[3].title == "Scan Settings"
|
||||
|
||||
# Background UI is ...
|
||||
background_ui = ui[2].children.root
|
||||
# what the detector specifies as it settings ...
|
||||
assert background_ui[0] is mock_prop_control
|
||||
# plus 2 action buttons
|
||||
assert len(background_ui) == 3
|
||||
assert isinstance(background_ui[1], ActionButton)
|
||||
assert background_ui[1].action == "set_background"
|
||||
assert isinstance(background_ui[2], ActionButton)
|
||||
assert background_ui[2].action == "check_background"
|
||||
|
||||
# Finally check the contents of the scan settings accordion
|
||||
scan_settings = ui[3].children.root
|
||||
assert len(scan_settings) == 8
|
||||
for element in scan_settings:
|
||||
assert isinstance(element, PropertyControl)
|
||||
|
||||
names = [el.property_name for el in ui]
|
||||
names = [el.property_name for el in scan_settings]
|
||||
expected_names = [
|
||||
"overlap",
|
||||
"stack_images_to_save",
|
||||
|
|
|
|||
|
|
@ -270,7 +270,9 @@ def test_create_stack(histo_scan_workflow, caplog):
|
|||
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 = histo_scan_workflow.create_smart_stack_params()
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params(
|
||||
save_on_failure=not histo_scan_workflow.skip_background
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
assert histo_scan_workflow.stack_min_images_to_test == initial_min_images_to_test
|
||||
|
|
@ -294,7 +296,9 @@ def test_coercing_stack_test_ims(
|
|||
histo_scan_workflow.stack_min_images_to_test = initial_test_ims
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params()
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params(
|
||||
save_on_failure=not histo_scan_workflow.skip_background
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert str(caplog.records[0].msg).startswith(expected_log_start)
|
||||
|
|
@ -322,7 +326,9 @@ def test_coercing_stack_save_ims(
|
|||
histo_scan_workflow.stack_images_to_save = initial_save_ims
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params()
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params(
|
||||
save_on_failure=not histo_scan_workflow.skip_background
|
||||
)
|
||||
|
||||
assert len(caplog.records) == 1
|
||||
assert str(caplog.records[0].msg).startswith(expected_log_start)
|
||||
|
|
@ -397,7 +403,9 @@ def setup_and_run_smart_z_stack(
|
|||
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 = histo_scan_workflow.create_smart_stack_params()
|
||||
stack_params = histo_scan_workflow.create_smart_stack_params(
|
||||
save_on_failure=not histo_scan_workflow.skip_background
|
||||
)
|
||||
stack_params.settling_time = 0 # Don't settle or tests take forever.
|
||||
|
||||
autofocus_thing.capture_stack_image = mocker.Mock()
|
||||
|
|
@ -694,7 +702,6 @@ def test_run_basic_stack_simple(
|
|||
stack_dz=10,
|
||||
images_to_save=3,
|
||||
settling_time=0,
|
||||
backlash_correction=0,
|
||||
origin=StackOrigin.START,
|
||||
)
|
||||
|
||||
|
|
@ -757,7 +764,6 @@ def test_run_basic_stack_center_origin(
|
|||
stack_dz=10,
|
||||
images_to_save=5,
|
||||
settling_time=0,
|
||||
backlash_correction=0,
|
||||
origin=StackOrigin.CENTER,
|
||||
)
|
||||
|
||||
|
|
@ -804,97 +810,6 @@ def test_run_basic_stack_center_origin(
|
|||
assert final_z == expected_final_z, "Final Z position is incorrect"
|
||||
|
||||
|
||||
def test_run_basic_stack_backlash_applied(
|
||||
autofocus_thing, mocker, fake_capture, fake_move_relative
|
||||
):
|
||||
"""Backlash correction should overshoot first, then move back before starting stack."""
|
||||
stack_params = StackParams(
|
||||
stack_dz=10,
|
||||
images_to_save=3,
|
||||
settling_time=0,
|
||||
backlash_correction=50,
|
||||
origin=StackOrigin.START,
|
||||
)
|
||||
|
||||
capture_params = mocker.Mock()
|
||||
capture_params.images_dir = "dummy"
|
||||
capture_params.save_resolution = (100, 100)
|
||||
|
||||
start_z = 0
|
||||
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||
|
||||
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
|
||||
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
|
||||
autofocus_thing._cam.save_from_memory = mocker.Mock()
|
||||
autofocus_thing._cam.clear_buffers = mocker.Mock()
|
||||
|
||||
autofocus_thing.run_basic_stack(stack_params, capture_params)
|
||||
|
||||
calls = autofocus_thing._stage.move_relative.call_args_list
|
||||
|
||||
# First move is overshoot for backlash
|
||||
assert calls[0].kwargs["z"] == -stack_params.backlash_correction, (
|
||||
"Backlash overshoot not applied correctly"
|
||||
)
|
||||
|
||||
# Second move corrects back to starting point
|
||||
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
|
||||
"Backlash correction move not applied correctly"
|
||||
)
|
||||
|
||||
|
||||
def test_run_basic_stack_backlash_and_offset(
|
||||
autofocus_thing, mocker, fake_capture, fake_move_relative
|
||||
):
|
||||
"""Backlash correction and stack offset both applied.
|
||||
|
||||
Stack should begin by moving down by half the height of the stack,
|
||||
plus backlash correction, then move up by backlash correction, then run the basic stack.
|
||||
"""
|
||||
stack_params = StackParams(
|
||||
stack_dz=100,
|
||||
images_to_save=3,
|
||||
settling_time=0,
|
||||
backlash_correction=50,
|
||||
origin=StackOrigin.CENTER,
|
||||
)
|
||||
|
||||
capture_params = mocker.Mock()
|
||||
capture_params.images_dir = "dummy"
|
||||
capture_params.save_resolution = (100, 100)
|
||||
|
||||
start_z = 0
|
||||
autofocus_thing._stage.position = {"x": 0, "y": 0, "z": start_z}
|
||||
|
||||
autofocus_thing.capture_stack_image = mocker.Mock(side_effect=fake_capture)
|
||||
autofocus_thing._stage.move_relative = mocker.Mock(side_effect=fake_move_relative)
|
||||
autofocus_thing._cam.save_from_memory = mocker.Mock()
|
||||
autofocus_thing._cam.clear_buffers = mocker.Mock()
|
||||
|
||||
autofocus_thing.run_basic_stack(stack_params, capture_params)
|
||||
|
||||
calls = autofocus_thing._stage.move_relative.call_args_list
|
||||
|
||||
# Combined initial offset + backlash overshoot
|
||||
total_stack_range = stack_params.stack_dz * (stack_params.images_to_save - 1)
|
||||
expected_first_move = -(total_stack_range // 2 + stack_params.backlash_correction)
|
||||
assert calls[0].kwargs["z"] == expected_first_move, (
|
||||
"Combined overshoot not applied correctly"
|
||||
)
|
||||
|
||||
# Backlash correction back to base of stack
|
||||
assert calls[1].kwargs["z"] == stack_params.backlash_correction, (
|
||||
"Backlash correction move not applied correctly"
|
||||
)
|
||||
|
||||
# Subsequent moves in stack
|
||||
expected_stack_moves = [stack_params.stack_dz] * (stack_params.images_to_save - 1)
|
||||
actual_stack_moves = [c.kwargs["z"] for c in calls[2:]]
|
||||
assert actual_stack_moves == expected_stack_moves, (
|
||||
"Stack moves after backlash/offset not correct"
|
||||
)
|
||||
|
||||
|
||||
def test_run_basic_stack_end_origin(
|
||||
autofocus_thing, mocker, fake_capture, fake_move_relative
|
||||
):
|
||||
|
|
@ -903,7 +818,6 @@ def test_run_basic_stack_end_origin(
|
|||
stack_dz=10,
|
||||
images_to_save=4,
|
||||
settling_time=0,
|
||||
backlash_correction=0,
|
||||
origin=StackOrigin.END,
|
||||
)
|
||||
|
||||
|
|
@ -948,7 +862,6 @@ def test_invalid_stack_images_raises():
|
|||
stack_dz=10,
|
||||
images_to_save=capture_count,
|
||||
settling_time=0,
|
||||
backlash_correction=0,
|
||||
origin=StackOrigin.START,
|
||||
)
|
||||
|
||||
|
|
@ -960,7 +873,6 @@ def test_invalid_stack_settling_raises():
|
|||
stack_dz=10,
|
||||
images_to_save=1,
|
||||
settling_time=-1,
|
||||
backlash_correction=0,
|
||||
origin=StackOrigin.START,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ from pydantic import BaseModel
|
|||
import labthings_fastapi as lt
|
||||
|
||||
from openflexure_microscope_server.stitching import (
|
||||
FORBIDDEN_COMMANDS,
|
||||
BaseStitcher,
|
||||
FinalStitcher,
|
||||
PreviewStitcher,
|
||||
StitcherValidationError,
|
||||
StitchingSettings,
|
||||
validate_command,
|
||||
)
|
||||
|
||||
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||
|
|
@ -32,6 +34,29 @@ THIS_DIR: str = os.path.dirname(os.path.realpath(__file__))
|
|||
MOCK_STITCHER: str = os.path.join(THIS_DIR, "mock_stitching", "mock-stitch.py")
|
||||
|
||||
|
||||
def test_validate_command_success():
|
||||
"""Test valid commands pass validation."""
|
||||
validate_command(["openflexure-stitch", "--stitching_mode", "all", "path/to/scan"])
|
||||
validate_command(["--resize", "0.5", "8192"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd_name", FORBIDDEN_COMMANDS)
|
||||
def test_validate_command_forbidden(cmd_name):
|
||||
"""Test forbidden commands raise error."""
|
||||
# As the main command
|
||||
with pytest.raises(
|
||||
StitcherValidationError, match=f"Forbidden element '{cmd_name}' detected"
|
||||
):
|
||||
validate_command([cmd_name, "some_arg"])
|
||||
|
||||
# As an argument (case-insensitive)
|
||||
with pytest.raises(
|
||||
StitcherValidationError,
|
||||
match=f"Forbidden element '{cmd_name.upper()}' detected",
|
||||
):
|
||||
validate_command(["safe-command", cmd_name.upper()])
|
||||
|
||||
|
||||
def test_base_stitcher():
|
||||
"""Test the logic in BaseStitcher.
|
||||
|
||||
|
|
|
|||
24
tests/unit_tests/test_ui.py
Normal file
24
tests/unit_tests/test_ui.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Testing the server specified user interface."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server import ui
|
||||
|
||||
from ..shared_utils.md_test_cases import find_test_cases
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
HTML_SANITISATION_TEST_FILE = os.path.join(
|
||||
THIS_DIR, "data", "html_sanitisation_test_cases.md"
|
||||
)
|
||||
HTML_SANITISATION_TEST_CASES = find_test_cases(HTML_SANITISATION_TEST_FILE, "html")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("test_case", HTML_SANITISATION_TEST_CASES)
|
||||
def test_html_block_sanitises(test_case):
|
||||
"""Check each case in the test case file sanitises as expected."""
|
||||
sanitised = ui.sanitise_html(test_case.input_code).strip()
|
||||
assert sanitised == test_case.result_code, (
|
||||
f"Test case: '{test_case.title}': Input code doesn't sanitise as expected."
|
||||
)
|
||||
169
tests/unit_tests/test_utilities.py
Normal file
169
tests/unit_tests/test_utilities.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Unit tests for utility functions."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from openflexure_microscope_server.utilities import (
|
||||
_WINDOWS_RESERVED_NAMES,
|
||||
is_path_safe,
|
||||
make_name_safe,
|
||||
)
|
||||
|
||||
PATHS_WITH_DOTS = {"a./b/c.", "path./to/file.", "./path./to/file."}
|
||||
|
||||
PATHS_WITH_SPACES = {"path /to /file ", "path/to /file", "path/a long path name /file"}
|
||||
|
||||
RESERVED_NAME_PATHS = {"path/CON/file", "path/NUL.txt/file", "C:/AUX/test"}
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
UNSAFE_CHARACTER_PATHS = {
|
||||
"C:/path/asterisk/data*.csv",
|
||||
"data/files/read_me_first!.txt",
|
||||
"logs/system<error>.log",
|
||||
"projects/alpha#1/notes.txt",
|
||||
}
|
||||
else:
|
||||
UNSAFE_CHARACTER_PATHS = {
|
||||
"/var/log/app:backup.log",
|
||||
"/etc/configs/network-settings\v1",
|
||||
"/tmp/file_with_@_symbol.txt",
|
||||
"/home/user/script(1).py",
|
||||
}
|
||||
|
||||
|
||||
def test_make_name_safe_basic():
|
||||
"""Test basic functionality of make_name_safe."""
|
||||
assert make_name_safe("normal_name") == "normal_name"
|
||||
assert make_name_safe("name with spaces") == "name_with_spaces"
|
||||
assert make_name_safe("name/with/slashes") == "name_with_slashes"
|
||||
assert make_name_safe("name\\with\\backslashes") == "name_with_backslashes"
|
||||
assert make_name_safe("sPoNgEmoCk") == "spongemock"
|
||||
|
||||
|
||||
def test_make_name_safe_trailing_chars():
|
||||
"""Test that trailing dots and spaces are removed."""
|
||||
assert make_name_safe("name.") == "name"
|
||||
assert make_name_safe("name ") == "name"
|
||||
assert make_name_safe("name. ") == "name"
|
||||
assert make_name_safe("name .") == "name"
|
||||
assert make_name_safe(".") == "_"
|
||||
assert make_name_safe(" ") == "_"
|
||||
assert make_name_safe(". ") == "_"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _WINDOWS_RESERVED_NAMES)
|
||||
def test_make_name_safe_reserved_names(name):
|
||||
"""Test Windows reserved names."""
|
||||
# Base name should be sanitized
|
||||
assert make_name_safe(name) == f"{name.lower()}_"
|
||||
# Case-insensitive
|
||||
assert make_name_safe(name.lower()) == f"{name.lower()}_"
|
||||
# With extension
|
||||
assert make_name_safe(f"{name}.txt") == f"{name.lower()}.txt_"
|
||||
# Multiple extensions
|
||||
assert make_name_safe(f"{name}.tar.gz") == f"{name.lower()}.tar.gz_"
|
||||
|
||||
|
||||
def test_make_name_safe_reserved_names_false_positives():
|
||||
"""Test that names containing but not equal to reserved names are safe."""
|
||||
assert make_name_safe("CONSTANT") == "constant"
|
||||
assert make_name_safe("CON2") == "con2"
|
||||
assert make_name_safe("ICON") == "icon"
|
||||
assert make_name_safe("icon") == "icon"
|
||||
assert make_name_safe("iCon") == "icon"
|
||||
assert make_name_safe("iCOn") == "icon"
|
||||
assert make_name_safe("icOn") == "icon"
|
||||
assert make_name_safe("CHILLI CON CARNE") == "chilli_con_carne"
|
||||
|
||||
|
||||
def test_is_path_safe_basic(caplog):
|
||||
"""Test basic functionality of is_path_safe."""
|
||||
# Note: behavior depends on platform for separators
|
||||
with caplog.at_level(logging.WARNING):
|
||||
if sys.platform.startswith("win"):
|
||||
assert is_path_safe("C:\\path\\to/file")
|
||||
assert is_path_safe("C:\\a very long path\\to/file")
|
||||
else:
|
||||
assert is_path_safe("path/to/file")
|
||||
assert is_path_safe("a very long path/to/file")
|
||||
|
||||
# No errors should be raised. These paths are safe.
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", UNSAFE_CHARACTER_PATHS)
|
||||
def test_is_path_safe_unsafe_characters_in_components(caplog, path):
|
||||
"""Test unsafe characters within path components."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not is_path_safe(path)
|
||||
assert (
|
||||
f"{path} contains characters that may be unsafe on this platform."
|
||||
in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", RESERVED_NAME_PATHS)
|
||||
def test_is_path_safe_reserved_in_components(caplog, path):
|
||||
"""Test reserved names within path components."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not is_path_safe(path)
|
||||
assert (
|
||||
f"{path} contains a reserved system name and may cause issues."
|
||||
in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
def test_is_path_safe_reserved_name_components(caplog):
|
||||
"""Test reserved names are okay as part of a larger dir name."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert is_path_safe("chilli con carne/recipe")
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PATHS_WITH_SPACES)
|
||||
def test_is_path_safe_trailing_space_in_components(caplog, path):
|
||||
"""Test trailing spaces in path components."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
# Test Windows - cannot have trailing spaces
|
||||
if sys.platform.startswith("win"):
|
||||
assert not is_path_safe(path)
|
||||
assert len(caplog.records) == 1
|
||||
assert f"{path} contains unsafe trailing spaces." in caplog.messages
|
||||
else:
|
||||
# Trailing spaces allows in POSIX systems
|
||||
assert is_path_safe(path)
|
||||
# No warnings should be raised
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
|
||||
def test_is_path_safe_relative(caplog):
|
||||
"""Test that relative path components are preserved."""
|
||||
# Relative imports ./foo, ../foo or ../../foo are allowed.
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert is_path_safe("./openflexure/data/")
|
||||
assert is_path_safe("../openflexure/data/")
|
||||
assert is_path_safe("../../openflexure/data/")
|
||||
assert is_path_safe(".")
|
||||
assert is_path_safe("..")
|
||||
|
||||
assert len(caplog.records) == 0
|
||||
|
||||
assert not is_path_safe("a_bad/../relative_path")
|
||||
assert len(caplog.records) == 1
|
||||
assert (
|
||||
"File path a_bad/../relative_path may be unsafe due to unexpected relative navigation."
|
||||
in caplog.messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filepath", PATHS_WITH_DOTS)
|
||||
def test_is_path_safe_dots_warning(caplog, filepath):
|
||||
"""Test that a user is warned about trailing dots in their file paths. The paths should remain unchanged."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not is_path_safe(filepath)
|
||||
assert (
|
||||
f"File path {filepath} may be unsafe due to trailing dots."
|
||||
in caplog.messages
|
||||
)
|
||||
|
|
@ -19,7 +19,7 @@ THIS_DIR = os.path.dirname(__file__)
|
|||
TRUE_REPO_DIR = os.path.dirname(os.path.dirname(THIS_DIR))
|
||||
|
||||
# For explicit version checking.
|
||||
VER_STRING = "3.0.0-alpha4"
|
||||
VER_STRING = "3.0.0-alpha5"
|
||||
|
||||
# This is the regex provided by https://semver.org/
|
||||
SEMVER_REGEX = re.compile(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue