`` in the UI.
+
+ The HTML is limited to a small number of tags.
+ """
+
+ element_type: Literal["container"] = "container"
+ css_class: str
+ children: "UIElementList"
+
+
+UIElementModels = (
+ HeaderBlock
+ | TextBlock
+ | BulletBlock
+ | PropertyControl
+ | ActionButton
+ | Accordion
+ | Container
+)
+
+
+class UIElementList(RootModel[list[UIElementModels]]):
+ """A list of user interface elements.
+
+ Note! Two important things to consider:
+
+ * This cannot be the return of a lt.property. To fully describe the UI recursion
+ is used. Web of Things DataSchema doesn't support recursion. Use an
+ lt.endpoint instead
+ * If the module using this class imports future annotations then this will cause
+ havoc with pydantics forward references.
+ """
+
+
+# Resolve forward references so Pydantic can build a sensible recursive schema
+Accordion.model_rebuild()
+Container.model_rebuild()
+UIElementList.model_rebuild()
+
+# This constant can be used as the value for the `responses` argument of lt.endpoint.
+UI_ELEMENT_RESPONSE = {
+ 200: {
+ "description": "A list of UI element specifications.",
+ "model": UIElementList,
+ }
+}
diff --git a/tests/shared_utils/md_test_cases.py b/tests/shared_utils/md_test_cases.py
new file mode 100644
index 00000000..9a3e3b37
--- /dev/null
+++ b/tests/shared_utils/md_test_cases.py
@@ -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"
"
+
+ 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
diff --git a/tests/unit_tests/data/html_sanitisation_test_cases.md b/tests/unit_tests/data/html_sanitisation_test_cases.md
new file mode 100644
index 00000000..ce0a8027
--- /dev/null
+++ b/tests/unit_tests/data/html_sanitisation_test_cases.md
@@ -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
+
+Hello
+```
+### Result
+```html
+alert("x")
+Hello
+```
+
+
+## Test script in bold tag
+### Input
+```html
+click me!
+```
+### Result
+```html
+click me!
+```
+
+
+## Test script in disallowed tag
+### Input
+```html
+
+```
+### Result
+```html
+```
+
+
+## Test cookie grabber script is escaped
+The script should become escaped and have no tags
+### Input
+```html
+
+```
+### Result
+```html
+var adr = '../evil.php?cakemonster=' + escape(document.cookie);
+```
+
+
+## Test with non-http links
+Remove the javascript from the links.
+### Input
+```html
+link
+encoded
+Email
+Relative
+```
+### Result
+```html
+link
+encoded
+Email
+Relative
+```
+
+
+## Test http links become external
+Remove the javascript from the links.
+### Input
+```html
+OpenFlexure
+```
+### Result
+```html
+OpenFlexure
+```
+
+
+## Test basic formatting preserved
+Check basic elements are not changed
+### Input
+```html
+This is bold italic and on a new
line.
+```
+### Result
+```html
+This is bold italic and on a new
line.
+```
+
+
+## Test http link with path preserved
+Valid HTTP links should preserve their path.
+### Input
+```html
+Docs
+```
+### Result
+```html
+Docs
+```
+
+
+## Test http link with fragment preserved
+Valid HTTP links should preserve their path and fragment.
+### Input
+```html
+Docs Section
+```
+### Result
+```html
+Docs Section
+```
\ No newline at end of file
diff --git a/tests/unit_tests/test_background_detectors.py b/tests/unit_tests/test_background_detectors.py
index f017ef7a..ea746044 100644
--- a/tests/unit_tests/test_background_detectors.py
+++ b/tests/unit_tests/test_background_detectors.py
@@ -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)
diff --git a/tests/unit_tests/test_scan_workflows.py b/tests/unit_tests/test_scan_workflows.py
index 483b1f99..0ffa4a00 100644
--- a/tests/unit_tests/test_scan_workflows.py
+++ b/tests/unit_tests/test_scan_workflows.py
@@ -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",
diff --git a/tests/unit_tests/test_ui.py b/tests/unit_tests/test_ui.py
new file mode 100644
index 00000000..1233458c
--- /dev/null
+++ b/tests/unit_tests/test_ui.py
@@ -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."
+ )
diff --git a/webapp/src/components/appContent.vue b/webapp/src/components/appContent.vue
index 51428b5f..502462d7 100644
--- a/webapp/src/components/appContent.vue
+++ b/webapp/src/components/appContent.vue
@@ -82,7 +82,6 @@ import tabContent from "./genericComponents/tabContent.vue";
// Import new content components
import aboutContent from "./tabContentComponents/aboutContent.vue";
-import backgroundDetectContent from "./tabContentComponents/backgroundDetectContent.vue";
import controlContent from "./tabContentComponents/controlContent.vue";
import loggingContent from "./tabContentComponents/loggingContent.vue";
import powerContent from "./tabContentComponents/powerContent.vue";
@@ -150,15 +149,6 @@ export default {
component: markRaw(controlContent),
requiredThings: [],
},
- {
- id: "background-detect",
- title: "Background Detect",
- icon: "background_replace",
- component: markRaw(backgroundDetectContent),
- // While stage isn't needed; automatic background detect has little function
- // for a manual microscope.
- requiredThings: ["stage"],
- },
{
id: "slide-scan",
title: "Slide Scan",
diff --git a/webapp/src/components/genericComponents/simpleAccordion.vue b/webapp/src/components/genericComponents/simpleAccordion.vue
new file mode 100644
index 00000000..3fcecf8b
--- /dev/null
+++ b/webapp/src/components/genericComponents/simpleAccordion.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/webapp/src/components/labThingsComponents/actionButton.vue b/webapp/src/components/labThingsComponents/actionButton.vue
index e5bfda75..eaaaf115 100644
--- a/webapp/src/components/labThingsComponents/actionButton.vue
+++ b/webapp/src/components/labThingsComponents/actionButton.vue
@@ -322,6 +322,9 @@ export default {
} else if (response.data.status == "cancelled") {
this.$emit("cancelled", response.data);
this.modalNotify(`The action '${this.submitLabel}' was cancelled.`);
+ } else if (response.data.status == "error") {
+ const err_msg = this.log?.[0]?.message ?? "Unknown error";
+ this.$emit("error", err_msg);
}
}
this.taskUrl = null;
diff --git a/webapp/src/components/labThingsComponents/serverSpecifiedActionButton.vue b/webapp/src/components/labThingsComponents/serverSpecifiedActionButton.vue
index 78f81dd0..44b129fe 100644
--- a/webapp/src/components/labThingsComponents/serverSpecifiedActionButton.vue
+++ b/webapp/src/components/labThingsComponents/serverSpecifiedActionButton.vue
@@ -8,6 +8,7 @@
:requires-confirmation="actionData.requires_confirmation"
:confirmation-message="actionData.confirmation_message"
:button-primary="actionData.button_primary"
+ :is-disabled="actionData.disabled"
:modal-progress="actionData.modal_progress"
:stream-with-modal="actionData.stream_with_modal"
@response="actionResponse"
@@ -34,7 +35,7 @@ export default {
},
},
- emits: ["response", "finished"],
+ emits: ["response", "finished", "requestUpdate"],
methods: {
/**
@@ -45,7 +46,14 @@ export default {
*/
actionResponse: function (response) {
if (this.actionData.notify_on_success) {
- this.modalNotify(this.actionData.success_message);
+ if (this.actionData.response_is_success_message) {
+ this.modalNotify(response.output);
+ } else {
+ this.modalNotify(this.actionData.success_message);
+ }
+ if (this.actionData.update_interface_on_response) {
+ this.$emit("requestUpdate");
+ }
this.$emit("response", response);
}
},
diff --git a/webapp/src/components/labThingsComponents/serverSpecifiedInterface.vue b/webapp/src/components/labThingsComponents/serverSpecifiedInterface.vue
new file mode 100644
index 00000000..f69ae102
--- /dev/null
+++ b/webapp/src/components/labThingsComponents/serverSpecifiedInterface.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/tabContentComponents/backgroundDetectComponents/paneBackgroundDetect.vue b/webapp/src/components/tabContentComponents/backgroundDetectComponents/paneBackgroundDetect.vue
deleted file mode 100644
index fc8a90b8..00000000
--- a/webapp/src/components/tabContentComponents/backgroundDetectComponents/paneBackgroundDetect.vue
+++ /dev/null
@@ -1,131 +0,0 @@
-
-
-
-
-
-
diff --git a/webapp/src/components/tabContentComponents/backgroundDetectContent.vue b/webapp/src/components/tabContentComponents/backgroundDetectContent.vue
deleted file mode 100644
index 7af5b0be..00000000
--- a/webapp/src/components/tabContentComponents/backgroundDetectContent.vue
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
diff --git a/webapp/src/components/tabContentComponents/slideScanContent.vue b/webapp/src/components/tabContentComponents/slideScanContent.vue
index 281954b7..c74a739e 100644
--- a/webapp/src/components/tabContentComponents/slideScanContent.vue
+++ b/webapp/src/components/tabContentComponents/slideScanContent.vue
@@ -16,23 +16,8 @@
-