diff --git a/src/openflexure_microscope_server/things/background_detect.py b/src/openflexure_microscope_server/things/background_detect.py index 8039605f..c2753a75 100644 --- a/src/openflexure_microscope_server/things/background_detect.py +++ b/src/openflexure_microscope_server/things/background_detect.py @@ -13,7 +13,11 @@ from pydantic import BaseModel import labthings_fastapi as lt -from openflexure_microscope_server.ui import PropertyControl, property_control_for +from openflexure_microscope_server.ui import ( + UI_ELEMENT_RESPONSE, + UIElementList, + property_control_for, +) SettingsType = TypeVar("SettingsType", bound=BaseModel) BackgroundType = TypeVar("BackgroundType", bound=BaseModel) @@ -72,9 +76,9 @@ class BackgroundDetectAlgorithm(lt.Thing): "Each background detect algorithm must implement an set_background method." ) - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the UI.""" + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the background detect settings.""" raise NotImplementedError( "Each background detect algorithm must implement an settings_ui method." ) @@ -113,15 +117,19 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm): image to be labeled as containing sample. """ - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the UI.""" - return [ - property_control_for(self, "channel_tolerance", label="Channel Tolerance"), - property_control_for( - self, "min_sample_coverage", label="Sample Coverage Required (%)" - ), - ] + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the background detect settings.""" + return UIElementList( + [ + property_control_for( + self, "channel_tolerance", label="Channel Tolerance" + ), + property_control_for( + self, "min_sample_coverage", label="Sample Coverage Required (%)" + ), + ] + ) @lt.property def ready(self) -> bool: @@ -237,15 +245,19 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm): image to be labeled as containing sample. """ - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the UI.""" - return [ - property_control_for(self, "channel_tolerance", label="Channel Tolerance"), - property_control_for( - self, "min_sample_coverage", label="Sample Coverage Required (%)" - ), - ] + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the background detect settings.""" + return UIElementList( + [ + property_control_for( + self, "channel_tolerance", label="Channel Tolerance" + ), + property_control_for( + self, "min_sample_coverage", label="Sample Coverage Required (%)" + ), + ] + ) @lt.property def ready(self) -> bool: diff --git a/src/openflexure_microscope_server/things/scan_workflows.py b/src/openflexure_microscope_server/things/scan_workflows.py index 2380173a..1bf5b826 100644 --- a/src/openflexure_microscope_server/things/scan_workflows.py +++ b/src/openflexure_microscope_server/things/scan_workflows.py @@ -4,8 +4,6 @@ This module contains the base ``ScanWorkflow`` class that all workflows should s as well as specific workflows. """ -from __future__ import annotations - import os from typing import ( Generic, @@ -41,7 +39,15 @@ from openflexure_microscope_server.things.background_detect import ( from openflexure_microscope_server.things.camera import BaseCamera, CaptureParams from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper from openflexure_microscope_server.things.stage import BaseStage -from openflexure_microscope_server.ui import PropertyControl, property_control_for +from openflexure_microscope_server.ui import ( + UI_ELEMENT_RESPONSE, + Accordion, + HeaderBlock, + TextBlock, + UIElementList, + action_button_for, + property_control_for, +) SettingModelType = TypeVar("SettingModelType", bound=BaseModel) @@ -160,9 +166,9 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing): return True, focus_height - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the scan tab.""" + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the workflow's settings in the scan tab.""" raise NotImplementedError( "Each scan workflow must implement a settings_ui method." ) @@ -556,35 +562,96 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]): return imaged, focus_height - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the scan tab.""" - return [ - property_control_for( - self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05 - ), - property_control_for( - self, "stack_images_to_save", label="Images in Stack to Save" - ), - property_control_for( - self, - "stack_min_images_to_test", - label="Minimum number of images to test for focus", - ), - property_control_for(self, "stack_dz", label="Stack dz (steps)", step=5), - property_control_for( - self, "autofocus_dz", label="Autofocus Range (steps)", step=200 - ), - property_control_for( - self, "max_range", label="Maximum Distance (steps)", step=1000 - ), - property_control_for( - self, "skip_background", label="Detect and Skip Empty Fields" - ), - property_control_for( - self, "equal_distances", label="Set Equal x and y Distances" - ), - ] + @lt.action + def check_background(self) -> str: + """Check if sample is background. + + This action is a pre-run check for feeding back to the user. + """ + image_array = self._cam.grab_as_array(stream_name="lores") + is_sample, bg_message = self._background_detector.image_is_sample(image_array) + label = "sample" if is_sample else "background" + + return f"Current image is {label} ({bg_message})" + + @lt.action + def set_background(self) -> None: + """Set the background for this background detector. + + This sets the background for this workflow's background detector as opposed to + the active background detector for the camera. + """ + image_array = self._cam.grab_as_array(stream_name="lores") + self._background_detector.set_background(image_array) + + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the workflow's settings in the scan tab.""" + scan_settings = UIElementList( + [ + property_control_for( + self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05 + ), + property_control_for( + self, "stack_images_to_save", label="Images in Stack to Save" + ), + property_control_for( + self, + "stack_min_images_to_test", + label="Minimum number of images to test for focus", + ), + property_control_for( + self, "stack_dz", label="Stack dz (steps)", step=5 + ), + property_control_for( + self, "autofocus_dz", label="Autofocus Range (steps)", step=200 + ), + property_control_for( + self, "max_range", label="Maximum Distance (steps)", step=1000 + ), + property_control_for( + self, "skip_background", label="Detect and Skip Empty Fields" + ), + property_control_for( + self, "equal_distances", label="Set Equal x and y Distances" + ), + ] + ) + background_ui = self._background_detector.settings_ui() + set_bg_button = action_button_for( + self, + "set_background", + poll_interval=0.1, + submit_label="Set Background", + can_terminate=False, + notify_on_success=True, + success_message="Background image has been updated", + update_interface_on_response=True, + ) + check_bg_button = action_button_for( + self, + "check_background", + poll_interval=0.1, + submit_label="Check Current Image", + disabled=not self._background_detector.ready, + can_terminate=False, + notify_on_success=True, + response_is_success_message=True, + ) + + background_ui.root += [set_bg_button, check_bg_button] + + return UIElementList( + [ + HeaderBlock(text=self.display_name, level=4), + TextBlock(text=self.ui_blurb), + Accordion(title="Background Detect", children=background_ui), + Accordion( + title="Scan Settings", + children=scan_settings, + ), + ] + ) class RegularGridSettingsModel(RectGridSettingsModel): @@ -668,17 +735,31 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]): save_resolution=settings.capture_params.save_resolution, ) - @lt.property - def settings_ui(self) -> list[PropertyControl]: - """A list of PropertyControl objects to create the settings in the scan tab.""" - return [ - property_control_for( - self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05 - ), - property_control_for(self, "x_count", label="Number of columns"), - property_control_for(self, "y_count", label="Number of rows"), - property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"), - ] + @lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE) + def settings_ui(self) -> UIElementList: + """Return the UI for the workflow's settings in the scan tab.""" + scan_settings = UIElementList( + [ + property_control_for( + self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05 + ), + property_control_for(self, "x_count", label="Number of columns"), + property_control_for(self, "y_count", label="Number of rows"), + property_control_for( + self, "autofocus_dz", label="Autofocus Range (steps)" + ), + ] + ) + return UIElementList( + [ + HeaderBlock(text=self.display_name, level=4), + TextBlock(text=self.ui_blurb), + Accordion( + title="Scan Settings", + children=scan_settings, + ), + ] + ) class SnakeWorkflow(RegularGridWorkflow): diff --git a/src/openflexure_microscope_server/ui.py b/src/openflexure_microscope_server/ui.py index 3cb4375e..4620cab2 100644 --- a/src/openflexure_microscope_server/ui.py +++ b/src/openflexure_microscope_server/ui.py @@ -1,11 +1,155 @@ """Functionality for communicating the required user interface for a thing.""" -from typing import Any, Optional +from html import escape +from html.parser import HTMLParser +from typing import Annotated, Any, Literal, Optional +from urllib.parse import urlparse -from pydantic import BaseModel +from pydantic import BaseModel, BeforeValidator, Field, RootModel import labthings_fastapi as lt +# Only allowed tags are italic, bold, and link. Also allows self closing br tags. +ALLOWED_TAGS = {"i", "b", "a"} + +ALLOWED_SCHEMES = {"http", "https"} + + +def strip_control_chars(input_string: str) -> str: + """Remove unprintable characters.""" + return "".join(char for char in input_string if char.isprintable()) + + +def sanitize_http_url(url: str) -> Optional[str]: + """Santitise any url only allowing http and https without queries.""" + url = strip_control_chars(url) + parsed = urlparse(url) + if parsed.scheme not in ALLOWED_SCHEMES: + return None + if not parsed.netloc: + return None + fragment_str = "#" + parsed.fragment if parsed.fragment else "" + return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{fragment_str}" + + +class SafeHTMLParser(HTMLParser): + """An HTMLParser that only allows a very limited subset of HTML.""" + + def __init__(self) -> None: + """Initialise the parser.""" + super().__init__() + self.output = "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + """Append a start tag found in the HTML if it is in the allowed list. + + This is called by the base class when a start tag is found. We only append + the tag if it is our allowed list. We re-write the tag so no extra attributes + can be added. + + No tags can have attributes except for ``a`` tags, why only allow ``href``. We then + append ``target="_blank" rel="noopener noreferrer"`` to ensure the link opens + in a new window. + + :param tag: the tag name + :param attrs: the attributes for the tag. + """ + if tag not in ALLOWED_TAGS: + return + + if tag == "a": + href = None + for attr, value in attrs: + if attr == "href" and value is not None: + # If it is not a httplink this will stay as None + href = sanitize_http_url(value.strip()) + + if href: + safe_href = escape(href, quote=True) + # Always in a new tab + self.output += ( + f'' + ) + + else: + self.output += "" + else: + self.output += f"<{tag}>" + + def handle_endtag(self, tag: str) -> None: + """Append the end tag only if it is in the allowed list.""" + if tag in ALLOWED_TAGS: + self.output += f"" + + def handle_startendtag( + self, tag: str, _attrs: list[tuple[str, Optional[str]]] + ) -> None: + """Append a self-closing tag if it is a new line.""" + if tag == "br": + self.output += "
" + + def handle_data(self, data: str) -> None: + """Append any data inside tags after escaping it.""" + self.output += escape(data) + + def handle_entityref(self, name: str) -> None: + """Append any named character.""" + self.output += f"&{name};" + + def handle_charref(self, name: str) -> None: + """Append any character references.""" + self.output += f"&#{name};" + + +def sanitise_html(html: str) -> str: + """Santitise HTML to only have a small list of allowed tags. + + Tags allowed without attrs: ``, ,`` + + Tags allowed with attrs: ``
`` is allowed with ``href`` only. This automatically + appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally. + + Self closing tags allowed ``
``. + + :param html: The input HTML. + + :return: The sanitised HTML. + """ + parser = SafeHTMLParser() + parser.feed(html) + parser.close() + return parser.output + + +HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)] + + +class HeaderBlock(BaseModel): + """The data required for header.""" + + element_type: Literal["header_block"] = "header_block" + text: HtmlFragment + """The header text (can include basic HTML tags).""" + + level: int = Field(default=2, ge=1, le=6) + """The header level. A number from 1-6.""" + + +class TextBlock(BaseModel): + """The data required for creating a block text.""" + + element_type: Literal["text_block"] = "text_block" + text: HtmlFragment + """The text (can include basic HTML tags).""" + + +class BulletBlock(BaseModel): + """The data required for creating a block text.""" + + element_type: Literal["bullet_block"] = "bullet_block" + bullets: list[HtmlFragment] + """A list of text elements (can include basic HTML tags).""" + class ActionButton(BaseModel): """The data required for creating an actionButton in Vue. @@ -13,13 +157,15 @@ class ActionButton(BaseModel): Currently this cannot be used to submitData, or to submitOnEvent. """ + element_type: Literal["action_button"] = "action_button" + thing: str """The Thing "path" for the Thing instance.""" action: str """The name of the action to be triggered.""" - poll_interval: int = 1 + poll_interval: float = 1 """The interval for polling in seconds.""" submit_label: str = "Submit" @@ -37,6 +183,9 @@ class ActionButton(BaseModel): button_primary: bool = True """Specify whether the button is styled as a primary button.""" + disabled: bool = False + """Specify whether the button is disabled.""" + modal_progress: bool = False """Specify whether to show a progress modal.""" @@ -50,9 +199,21 @@ class ActionButton(BaseModel): notify_on_success: bool = False """Specify whether to a notification on successful completion.""" + response_is_success_message: bool = False + """Set True to use the action response as the success message. + + If ``success_message`` is set, this is never used. + """ + success_message: str = "Success!" """The message to show on successful completion.""" + update_interface_on_response: bool = False + """If True the action button emits a requestUpdate signal when completed. + + Downstream components can subscribe to this and request an update. + """ + def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton: """Create a ActionButton data for the specified Thing Action. @@ -72,6 +233,8 @@ class PropertyControl(BaseModel): Currently this cannot be used to submitData, or to submitOnEvent. """ + element_type: Literal["property_control"] = "property_control" + thing: str """The Thing "path" for the Thing instance.""" @@ -121,3 +284,63 @@ def property_control_for( if "label" not in kwargs: kwargs["label"] = property_name return PropertyControl(thing=thing.name, property_name=property_name, **kwargs) + + +class Accordion(BaseModel): + """The data required for creating an accordion in the UI. + + The HTML is limited to a small number of tags. + """ + + element_type: Literal["accordion"] = "accordion" + title: str + children: "UIElementList" + + +class Container(BaseModel): + """The data required for creating ``
`` 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 @@
-

- {{ workflowDisplayName }} -

-

{{ workflowBlurb }}

+
    -
  • - Scan Settings -
    -
    - -
    -
    -
  • Stitching Settings
    @@ -125,7 +110,7 @@