diff --git a/pyproject.toml b/pyproject.toml index e7d52c5b..6c1ac620 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,8 @@ requires = ["setuptools>=64", "setuptools_scm[toml]>=8"] build-backend = "setuptools.build_meta" [project] +scripts = { techui-builder = "techui_builder.__main__:app", generate-jsonmap = "techui_builder.generate_jsonmap:app" } + name = "techui-builder" dynamic = ["version"] description = "A package for building Phoebus GUIs" @@ -16,6 +18,7 @@ authors = [ { name = "Oliver Copping", email = "oliver.copping@diamond.ac.uk" }, { name = "Adedamola Sode", email = "adedamola.sode@diamond.ac.uk" }, { name = "Niamh Dougan", email = "niamh.dougan@diamond.ac.uk" }, + { name = "Thomas Kane", email = "thomas.kane@diamond.ac.uk" }, ] license-files = ["LICENSE"] readme = "README.md" @@ -30,8 +33,6 @@ dependencies = [ "softioc>=4.7.0", "epicsdbbuilder>=1.5", ] -scripts = { techui-builder = "techui_builder.__main__:app" } - [dependency-groups] dev = [ "copier", diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 08b6f046..b3d405aa 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -10,12 +10,14 @@ from techui_builder._logger import Logger from techui_builder.autofill import Autofiller from techui_builder.builder import Builder +from techui_builder.generate_jsonmap import JsonMapGenerator from techui_builder.schema_generator import schema_generator logger_ = logging.getLogger(__name__) app = typer.Typer( pretty_exceptions_show_locals=False, + context_settings={"allow_interspersed_args": True}, help=""" A script for building Phoebus GUIs. @@ -112,10 +114,22 @@ def find_bob(bob_file: Path | None, synoptic_dir: Path): # This is the default behaviour when no command provided @app.callback(invoke_without_command=True) def main( - filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")], + filename: Annotated[ + Path | None, typer.Argument(help="The path to techui.yaml") + ] = None, bobfile: Annotated[ Path | None, - typer.Argument(help="Override for template bob file location."), + typer.Option( + "--bob-file", "-b", help="Override for template bob file location." + ), + ] = None, + generate_json_map: Annotated[ + str | None, + typer.Option( + "--generate-jsonmap", + "-j", + help="Generate json mapping for screens in synoptic/ from index.bob.", + ), ] = None, version: Annotated[ bool | None, typer.Option("--version", callback=version_callback) @@ -141,6 +155,18 @@ def main( ) -> None: """Default function called from cmd line tool.""" + if (generate_json_map is not None) and (filename is None): + if not generate_json_map.endswith(".bob"): + raise typer.BadParameter( + "Json map generation requires a bob file as input." + ) + JsonMapGenerator(bob_path=Path(generate_json_map)).write_json_map() + logger_.info(f"Json map generated for (from {generate_json_map})") + return + + if filename is None: + raise typer.BadParameter("Techui.yaml file must be provided as an argument.") + gui = Builder(techui=filename) ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.location) @@ -176,10 +202,9 @@ def main( logger_.info(f"Screens autofilled for {gui.conf.beamline.location}.") - gui.write_json_map(synoptic=dest_bob, dest=gui._write_directory) # noqa: SLF001 - logger_.info( - f"Json map generated for {gui.conf.beamline.location} (from index.bob)" - ) + if generate_json_map is not None: + JsonMapGenerator(bob_path=bob_file).write_json_map() + logger_.info("Json map generated for (from index.bob)") if __name__ == "__main__": diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index 04433429..8cd60481 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -8,7 +8,7 @@ from lxml.etree import Element, SubElement, tostring from lxml.objectify import ObjectifiedElement, fromstring -from techui_builder.builder import _get_action_group +from techui_builder.generate_jsonmap import _get_action_group from techui_builder.models import Component from techui_builder.utils import read_bob diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 7b849c65..6c927cd4 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -1,15 +1,11 @@ -import json import logging import os from collections import defaultdict -from dataclasses import _MISSING_TYPE, dataclass, field +from dataclasses import dataclass, field from pathlib import Path -from typing import Any import yaml from epicsdbbuilder.recordbase import Record -from lxml import etree, objectify -from lxml.objectify import ObjectifiedElement from softioc.builder import records from techui_builder.generate import Generator @@ -19,17 +15,6 @@ logger_ = logging.getLogger(__name__) -@dataclass -class JsonMap: - file: str - display_name: str | None - exists: bool = True - duplicate: bool = False - children: list["JsonMap"] = field(default_factory=list) - macros: dict[str, str] = field(default_factory=dict) - error: str = "" - - @dataclass class Builder: """ @@ -256,403 +241,3 @@ def create_screens(self): f"set in the component [bold]{component_name}[/bold] does not match" " any P field in the ioc.yaml files in services" ) - - def _generate_json_map( - self, - screen_path: Path, - dest_path: Path, - current_component_name: str | None = None, - name_elem: str | None = None, - ) -> JsonMap: - """Recursively generate JSON map from .bob file tree""" - - # ------------ USEFUL FUNCTIONS ------------ - - def _get_display_name( - name_element: str | None, component_name: str | None, file_path: Path - ): - # Validated screen names don't get renegerated - name = name_element - display_name = self._get_component_label( - name_element, - component_name, - name, - ) - # Create valid displayName - display_name = self._parse_display_name(display_name, file_path) - return display_name - - def _next_file_crawl( - file_path: Path, - destination_path: Path, - name_element: str | None, - component_name: str | None, - display_name: str | None, - macro_dictionary: dict[str, Any], - ): - # TODO: misleading var name? - next_file_path = destination_path.joinpath(file_path) - - # Crawl the next file - if next_file_path.is_file(): - # TODO: investigate non-recursive approaches? - child_node = self._generate_json_map( - next_file_path, - destination_path, - current_component_name=component_name, - name_elem=name_element, - ) - else: - child_node = JsonMap( - str(file_path), - display_name, - exists=("IOC" in macro_dictionary or ("https:/" in str(file_path))), - ) - - return child_node - - # ------------------------------------------ - - # Create initial node at top of .bob file - current_node = JsonMap( - str(screen_path.resolve().relative_to(self._write_directory.resolve())), - display_name=None, - ) - - # Get Current Component - if current_component_name is None and screen_path.stem in self.conf.components: - current_component_name = screen_path.stem - - abs_path = screen_path.absolute() - - try: - # Create xml tree from .bob file - tree = objectify.parse(abs_path) - root: ObjectifiedElement = tree.getroot() - - # Set top level display name from root element - current_node.display_name = self._parse_display_name( - root.name.text, screen_path - ) - current_node.display_name = self._get_component_label( - name_elem, - current_component_name, - current_node.display_name, - ) - # Find all elements - widgets = [ - w - for w in root.findall(".//widget") - if w.get("type", default=None) - # in ["symbol", "embedded", "action_button"] - in ["symbol", "action_button", "embedded", "navtabs"] - ] - - for widget_elem in widgets: - # Obtain macros associated with file_elem - macro_dict: dict[str, str] = {} - widget_type = widget_elem.get("type", default=None) - - match widget_type: - case "symbol" | "action_button": - open_display = _get_action_group(widget_elem) - if open_display is None: - continue - - # Use file, name, and macro elements - file_elem = open_display.file - name_elem = widget_elem.name.text - macro_dict = self._get_macros(open_display) - - case "embedded": - file_elem = widget_elem.file - name_elem = widget_elem.name.text - macro_dict = self._get_macros(widget_elem) - - case "navtabs": - tabs = _get_nav_tabs(widget_elem) - if tabs is None: - continue - - for tab in tabs: - name_elem = tab.name.text - file_elem = tab.file - macro_dict = self._get_macros(tab) - - # Extract file path from file_elem - # Keep raw string to preserve urls - file_text = file_elem.text.strip() if file_elem.text else "" - file_path = Path(file_text) - - # If file is already a .bob file, skip it - if not file_path.suffix == ".bob": - continue - - display_name = _get_display_name( - name_elem, current_component_name, file_path - ) - - child_node = _next_file_crawl( - file_path, - dest_path, - name_elem, - current_component_name, - display_name, - macro_dict, - ) - - child_node.macros = macro_dict - # TODO: make this work for only list[JsonMap] - assert isinstance(current_node.children, list) - # TODO: fix typing - current_node.children.append(child_node) - - # We have already done the logic, so skip to the next widget - continue - - case _: - continue - - # Extract file path from file_elem - # Keep raw string to preserve urls - file_text = file_elem.text.strip() if file_elem.text else "" - file_path = Path(file_text) - - # If file is already a .bob file, skip it - if not file_path.suffix == ".bob": - continue - - display_name = _get_display_name( - name_elem, current_component_name, file_path - ) - - child_node = _next_file_crawl( - file_path, - dest_path, - name_elem, - current_component_name, - display_name, - macro_dict, - ) - - if widget_type == "embedded": - for embedded_child in child_node.children: - embedded_child.macros = {**embedded_child.macros, **macro_dict} - embedded_child.display_name = display_name - embedded_child.exists = "IOC" in macro_dict or ( - "https://" in str(embedded_child.file) - ) - current_node.children.append(embedded_child) - - else: - child_node.macros = macro_dict - # TODO: make this work for only list[JsonMap] - assert isinstance(current_node.children, list) - # TODO: fix typing - current_node.children.append(child_node) - - except etree.ParseError as e: - current_node.error = f"XML parse error: {e}" - except Exception as e: - current_node.error = str(e) - - self._fix_names_json_map(current_node) - - return current_node - - def _get_component_label( - self, - name_elem: str | None, - current_component_name: str | None, - display_name: str | None, - ) -> str | None: - """ - Get display name from the label or child labels if they exist, otherwise return - name_elem or existing display_name if name_elem is None. - """ - component = self.conf.components - if name_elem is not None: - if name_elem in component.keys() and component[name_elem].label is not None: - display_name = component[name_elem].label - elif ( - current_component_name is not None - and (current_component_name in component.keys()) - and (component[current_component_name].child_labels is not None) - ): - child_labels = component[current_component_name].child_labels - if child_labels is not None: - # Because name_elem is initially grabbed from - # the .bob file, the generated .bobfile might have - # already propagated the child label from techui.yaml - if name_elem in child_labels.values(): - display_name = name_elem - # In the case of screens not regenerated, such as validated screens, - # the name text will not be updated to the childlabel,so we check - # keys solely for generating the json_map from the top level .bob. - elif name_elem in child_labels: - display_name = child_labels[name_elem] - return display_name - - def _get_macros(self, element: ObjectifiedElement): - if hasattr(element, "macros"): - macros = element.macros.getchildren() - if macros is not None: - return { - str(macro.tag): macro.text - for macro in macros - if macro.text is not None - } - return {} - - def _parse_display_name(self, name: str | None, file_path: Path) -> str | None: - """Parse display name from tag or file_path""" - - if name: - # Return name tag text as displayName - return name - - elif file_path.name: - # Use tail without file ext as displayName - return file_path.name[: -sum(len(suffix) for suffix in file_path.suffixes)] - - else: - # Populate displayName with null - return None - - def _fix_names_json_map( - self, - node: JsonMap, - ) -> None: - """Recursively fix duplicate display names in children""" - if not node.children: - return - - # group by display_name - name_groups: defaultdict[str | None, list] = defaultdict(list) - for child in node.children: - name_groups[child.display_name].append(child) - - # fix duplicates by appending identifiers - for name, children in name_groups.items(): - if name and len(children) > 1: - # append pv names when present - - for child in children: - if "P" in child.macros: - child.display_name = f"{name} ({child.macros['P']})" - - # append NO PV NAME and enumeration when there is no pv name - no_pv_children = [c for c in children if "P" not in c.macros] - for i, child in enumerate(no_pv_children, 1): - child.display_name = f"{name} (NO PV NAME {i})" - - # recursively fix children - for child in node.children: - self._fix_names_json_map(child) - - def write_json_map( - self, - synoptic: Path = Path("example/t01-services/synoptic/index.bob"), - dest: Path = Path("example/t01-services/synoptic"), - ): - """ - Maps the valid entries from the ioc.yaml file - to the required screen in *-mapping.yaml - """ - if not synoptic.exists(): - raise FileNotFoundError( - f"Cannot generate json map for {synoptic}. Has it been generated?" - ) - - map = self._generate_json_map(synoptic, dest) - with open(dest.joinpath("JsonMap.json"), "w") as f: - f.write( - json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)) - + "\n" - ) - # f.writelines( - # self.conf.model_dump_json( - # indent=4, exclude_defaults=True, exclude_none=True - # ) - # ) - - -# Function to convert the JsonMap objects into dictionaries, -# while ignoring default values -def _serialise_json_map(map: JsonMap) -> dict[str, Any]: - def _check_default(key: str, value: Any): - # Is a default factory used? (e.g. list, dict, ...) - if not isinstance( - JsonMap.__dataclass_fields__[key].default_factory, _MISSING_TYPE - ): - # If so, check if value is the same as default factory - default = JsonMap.__dataclass_fields__[key].default_factory() - else: - # If not, check if value is the default value - default = JsonMap.__dataclass_fields__[key].default - return value == default - - d = {} - - # Loop over everything in the json map object's dictionary - for key, val in map.__dict__.items(): - # If children has nested JsonMap object, serialise that too - if key == "children" and len(val) > 0: - val = [_serialise_json_map(v) for v in val] - - # only include any items if they are not the default value - if _check_default(key, val): - continue - - d[key] = val - - # Rename display_name to displayName for JSON camel case convention - if "display_name" in d: - d["displayName"] = d.pop("display_name") - - return d - - -# File and desc are under the "actions", -# so the corresponding tag needs to be found -def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None: - try: - actions = element.actions - assert actions is not None - for action in actions.iterchildren("action"): - if action.get("type", default=None) == "open_display": - return action - return None - except AttributeError: - # TODO: Find better way of handling there being no "actions" group - # TODO: Do widgets always have a name attr, or _can_ it be empty?? - name = element.name - - parent_name = p.name if (p := element.getparent()) is not None else None - - logger_.error( - f"Actions group not found in component [bold]{name}[/bold] on " - f"[bold]{parent_name}[/bold]" - ) - - -def _get_nav_tabs(element: ObjectifiedElement) -> list[ObjectifiedElement] | None: - try: - element_tabs = element.tabs - assert element_tabs is not None - - tabs = list(element_tabs.iterchildren("tab")) - - return tabs - - except AttributeError: - # TODO: Find better way of handling there being no "tabs" group - # TODO: Do widgets always have a name attr, or _can_ it be empty?? - name = element.name - - parent_name = p.name if (p := element.getparent()) is not None else None - - logger_.error( - f"Tabs group not found in component [bold]{name}[/bold] on " - f"[bold]{parent_name}[/bold]" - ) diff --git a/src/techui_builder/generate_jsonmap.py b/src/techui_builder/generate_jsonmap.py new file mode 100644 index 00000000..a9f58e69 --- /dev/null +++ b/src/techui_builder/generate_jsonmap.py @@ -0,0 +1,492 @@ +import json +import logging +from collections import defaultdict +from dataclasses import _MISSING_TYPE, dataclass, field +from pathlib import Path +from typing import Annotated, Any + +import typer +import yaml +from lxml import etree, objectify +from lxml.objectify import ObjectifiedElement + +from techui_builder._logger import Logger +from techui_builder.models import TechUi + +logger_ = logging.getLogger(__name__) + + +def log_level(level: str): + Logger(level) + + +app = typer.Typer( + pretty_exceptions_show_locals=False, + help=""" + A script for generating a .json file mapping of phoebus gui screens. + + This is the required file structure:\n +\n + ixx-services\n + `-- synoptic\n + . |-- techui-support/\n + | | `-- ...\n + . |-- techui.yaml\n + . `-- index.bob\n +""", +) + + +@dataclass +class JsonMap: + file: str + display_name: str | None + exists: bool = True + duplicate: bool = False + children: list["JsonMap"] = field(default_factory=list) + macros: dict[str, str] = field(default_factory=dict) + error: str = "" + + +@dataclass +class JsonMapGenerator: + bob_path: Path = field(default=Path("index.bob")) + techui: Path = field(default=Path("techui.yaml")) + + def __post_init__(self): + # Get the directory to that holds the bob file and techui_yaml, + self._write_directory: Path = self.bob_path.parent + if self.techui == Path("techui.yaml"): + self.techui = self._write_directory.joinpath("techui.yaml") + try: + self.techui_yaml: TechUi = TechUi.model_validate( + yaml.safe_load(self.techui.read_text(encoding="utf-8")) + ) + except Exception as e: + logger_.error(f"Error loading techui.yaml: {e}") + raise + + def generate_json_map( + self, + screen_path: Path, + dest_path: Path, + current_component_name: str | None = None, + name_elem: str | None = None, + ) -> JsonMap: + """Recursively generate JSON map from .bob file tree""" + + # ------------ USEFUL FUNCTIONS ------------ + + def _get_display_name( + name_element: str | None, component_name: str | None, file_path: Path + ): + # Validated screen names don't get renegerated + name = name_element + display_name = self._get_component_label( + name_element, + component_name, + name, + ) + # Create valid displayName + display_name = self._parse_display_name(display_name, file_path) + return display_name + + def _next_file_crawl( + file_path: Path, + destination_path: Path, + name_element: str | None, + component_name: str | None, + display_name: str | None, + macro_dictionary: dict[str, Any], + ): + # TODO: misleading var name? + next_file_path = destination_path.joinpath(file_path) + + # Crawl the next file + if next_file_path.is_file(): + # TODO: investigate non-recursive approaches? + child_node = self.generate_json_map( + next_file_path, + destination_path, + current_component_name=component_name, + name_elem=name_element, + ) + else: + child_node = JsonMap( + str(file_path), + display_name, + exists=("IOC" in macro_dictionary or ("https:/" in str(file_path))), + ) + + return child_node + + # ------------------------------------------ + + # Create initial node at top of .bob file + current_node = JsonMap( + str(screen_path.resolve().relative_to(self._write_directory.resolve())), + display_name=None, + ) + + # Get Current Component + if ( + current_component_name is None + and screen_path.stem in self.techui_yaml.components + ): + current_component_name = screen_path.stem + + abs_path = screen_path.absolute() + + try: + # Create xml tree from .bob file + tree = objectify.parse(abs_path) + root: ObjectifiedElement = tree.getroot() + + # Set top level display name from root element + current_node.display_name = self._parse_display_name( + root.name.text, screen_path + ) + current_node.display_name = self._get_component_label( + name_elem, + current_component_name, + current_node.display_name, + ) + # Find all elements + widgets = [ + w + for w in root.findall(".//widget") + if w.get("type", default=None) + in ["symbol", "action_button", "embedded", "navtabs"] + ] + + for widget_elem in widgets: + # Obtain macros associated with file_elem + macro_dict: dict[str, str] = {} + widget_type = widget_elem.get("type", default=None) + + match widget_type: + case "symbol" | "action_button": + open_display = _get_action_group(widget_elem) + if open_display is None: + continue + + # Use file, name, and macro elements + file_elem = open_display.file + name_elem = widget_elem.name.text + macro_dict = self._get_macros(open_display) + + case "embedded": + file_elem = widget_elem.file + name_elem = widget_elem.name.text + macro_dict = self._get_macros(widget_elem) + + case "navtabs": + tabs = _get_nav_tabs(widget_elem) + if tabs is None: + continue + + for tab in tabs: + name_elem = tab.name.text + file_elem = tab.file + macro_dict = self._get_macros(tab) + + # Extract file path from file_elem + # Keep raw string to preserve urls + file_text = file_elem.text.strip() if file_elem.text else "" + file_path = Path(file_text) + + # If file is already a .bob file, skip it + if not file_path.suffix == ".bob": + continue + + display_name = _get_display_name( + name_elem, current_component_name, file_path + ) + + child_node = _next_file_crawl( + file_path, + dest_path, + name_elem, + current_component_name, + display_name, + macro_dict, + ) + + child_node.macros = macro_dict + # TODO: make this work for only list[JsonMap] + assert isinstance(current_node.children, list) + # TODO: fix typing + current_node.children.append(child_node) + + # We have already done the logic, so skip to the next widget + continue + + case _: + continue + + # Extract file path from file_elem + # Keep raw string to preserve urls + file_text = file_elem.text.strip() if file_elem.text else "" + file_path = Path(file_text) + + # If file is already a .bob file, skip it + if not file_path.suffix == ".bob": + continue + + # Create valid displayName + display_name = _get_display_name( + name_elem, current_component_name, file_path + ) + + child_node = _next_file_crawl( + file_path, + dest_path, + name_elem, + current_component_name, + display_name, + macro_dict, + ) + + if widget_type == "embedded": + for embedded_child in child_node.children: + embedded_child.macros = {**embedded_child.macros, **macro_dict} + embedded_child.display_name = display_name + embedded_child.exists = "IOC" in macro_dict or ( + "https://" in str(embedded_child.file) + ) + current_node.children.append(embedded_child) + + else: + child_node.macros = macro_dict + # TODO: make this work for only list[JsonMap] + assert isinstance(current_node.children, list) + # TODO: fix typing + current_node.children.append(child_node) + + except etree.ParseError as e: + current_node.error = f"XML parse error: {e}" + except Exception as e: + current_node.error = str(e) + + self._fix_names_json_map(current_node) + + return current_node + + def _get_component_label( + self, + name_elem: str | None, + current_component_name: str | None, + display_name: str | None, + ) -> str | None: + """ + Get display name from the label or child labels if they exist, otherwise return + name_elem or existing display_name if name_elem is None. + """ + component = self.techui_yaml.components + if name_elem is not None: + if name_elem in component.keys() and component[name_elem].label is not None: + display_name = component[name_elem].label + elif ( + current_component_name is not None + and (current_component_name in component.keys()) + and (component[current_component_name].child_labels is not None) + ): + child_labels = component[current_component_name].child_labels + if child_labels is not None: + # Because name_elem is initially grabbed from + # the .bob file, the generated .bobfile might have + # already propagated the child label from techui.yaml + if name_elem in child_labels.values(): + display_name = name_elem + # In the case of screens not regenerated, such as validated screens, + # the name text will not be updated to the childlabel,so we check + # keys solely for generating the json_map from the top level .bob. + elif name_elem in child_labels: + display_name = child_labels[name_elem] + return display_name + + def _get_macros(self, element: ObjectifiedElement): + if hasattr(element, "macros"): + macros = element.macros.getchildren() + if macros is not None: + return { + str(macro.tag): macro.text + for macro in macros + if macro.text is not None + } + return {} + + def _parse_display_name(self, name: str | None, file_path: Path) -> str | None: + """Parse display name from tag or file_path""" + + if name: + # Return name tag text as displayName + return name + + elif file_path.name: + # Use tail without file ext as displayName + return file_path.name[: -sum(len(suffix) for suffix in file_path.suffixes)] + + else: + # Populate displayName with null + return None + + def _fix_names_json_map( + self, + node: JsonMap, + ) -> None: + """Recursively fix duplicate display names in children""" + if not node.children: + return + + # group by display_name + name_groups: defaultdict[str | None, list] = defaultdict(list) + for child in node.children: + name_groups[child.display_name].append(child) + + # fix duplicates by appending identifiers + for name, children in name_groups.items(): + if name and len(children) > 1: + # append pv names when present + + for child in children: + if "P" in child.macros: + child.display_name = f"{name} ({child.macros['P']})" + + # append NO PV NAME and enumeration when there is no pv name + no_pv_children = [c for c in children if "P" not in c.macros] + for i, child in enumerate(no_pv_children, 1): + child.display_name = f"{name} (NO PV NAME {i})" + + # recursively fix children + for child in node.children: + self._fix_names_json_map(child) + + def write_json_map( + self, + ): + """ + Maps the valid entries from the ioc.yaml file + to the required screen in *-mapping.yaml + """ + if not self.bob_path.exists(): + raise FileNotFoundError( + f"Cannot generate json map for {self.bob_path}. Has it been generated?" + ) + + map = self.generate_json_map(self.bob_path, self._write_directory) + with open(self._write_directory.joinpath("JsonMap.json"), "w") as f: + f.write( + json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)) + + "\n" + ) + + +# Function to convert the JsonMap objects into dictionaries, +# while ignoring default values +def _serialise_json_map(map: JsonMap) -> dict[str, Any]: + def _check_default(key: str, value: Any): + # Is a default factory used? (e.g. list, dict, ...) + if not isinstance( + JsonMap.__dataclass_fields__[key].default_factory, _MISSING_TYPE + ): + # If so, check if value is the same as default factory + default = JsonMap.__dataclass_fields__[key].default_factory() + else: + # If not, check if value is the default value + default = JsonMap.__dataclass_fields__[key].default + return value == default + + d = {} + + # Loop over everything in the json map object's dictionary + for key, val in map.__dict__.items(): + # If children has nested JsonMap object, serialise that too + if key == "children" and len(val) > 0: + val = [_serialise_json_map(v) for v in val] + + # only include any items if they are not the default value + if _check_default(key, val): + continue + + d[key] = val + + # Rename display_name to displayName for JSON camel case convention + if "display_name" in d: + d["displayName"] = d.pop("display_name") + + return d + + +# File and desc are under the "actions", +# so the corresponding tag needs to be found +def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None: + try: + actions = element.actions + assert actions is not None + for action in actions.iterchildren("action"): + if action.get("type", default=None) == "open_display": + return action + return None + except AttributeError: + # TODO: Find better way of handling there being no "actions" group + # TODO: Do widgets always have a name attr, or _can_ it be empty?? + name = element.name + + parent_name = p.name if (p := element.getparent()) is not None else None + + logger_.error( + f"Actions group not found in component [bold]{name}[/bold] on " + f"[bold]{parent_name}[/bold]" + ) + + +def _get_nav_tabs(element: ObjectifiedElement) -> list[ObjectifiedElement] | None: + try: + element_tabs = element.tabs + assert element_tabs is not None + + tabs = list(element_tabs.iterchildren("tab")) + + return tabs + + except AttributeError: + # TODO: Find better way of handling there being no "tabs" group + # TODO: Do widgets always have a name attr, or _can_ it be empty?? + name = element.name + + parent_name = p.name if (p := element.getparent()) is not None else None + + logger_.error( + f"Tabs group not found in component [bold]{name}[/bold] on " + f"[bold]{parent_name}[/bold]" + ) + + +@app.callback(invoke_without_command=True) +def generate_jsonmap( + bob_path: Annotated[ + Path, + typer.Argument(help="Top level bobfile to generate json mapping from."), + ], + loglevel: Annotated[ + str, + typer.Option( + "--log-level", + "-l", + help="Set log level to INFO, DEBUG, WARNING, ERROR or CRITICAL", + case_sensitive=False, + callback=log_level, + ), + ] = "INFO", +) -> None: + """Default function called from cmd line tool.""" + jg = JsonMapGenerator(bob_path=bob_path) + jg.write_json_map() + logger_.info( + f"Json map generated for {jg.techui_yaml.beamline.location} (from index.bob)" + ) + + +if __name__ == "__main__": + app() diff --git a/tests/conftest.py b/tests/conftest.py index b0125ef2..a4f2706a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,9 @@ from lxml.objectify import fromstring from techui_builder.autofill import Autofiller -from techui_builder.builder import Builder, JsonMap +from techui_builder.builder import Builder from techui_builder.generate import Generator +from techui_builder.generate_jsonmap import JsonMap, JsonMapGenerator from techui_builder.models import Component from techui_builder.validator import Validator @@ -44,6 +45,13 @@ def components(builder_with_test_files: Builder): return builder_with_test_files.conf.components +@pytest.fixture +def json_map_generator(): + return JsonMapGenerator( + Path(__file__).parent.joinpath(Path("t01-services/synoptic/index.bob")) + ) + + @pytest.fixture def test_files(): screen_path = Path("tests/test_files/test_bob.bob").absolute() @@ -59,6 +67,16 @@ def example_json_map_root(): return test_map_base +@pytest.fixture +def json_map_generator_with_test_files(): + return JsonMapGenerator( + bob_path=Path("tests/test_files/test_bob.bob").absolute(), + techui=Path(__file__).parent.joinpath( + Path("t01-services/synoptic/techui.yaml") + ), + ) + + @pytest.fixture def example_json_map(example_json_map_root): # Create test json map with child json map diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index 81af83fd..920bc311 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -32,8 +32,8 @@ def test_autofiller_autofill_bob(autofiller): assert mock_widget.find("run_actions_on_mouse_click") == "true" -@patch("techui_builder.builder.objectify.deannotate") -@patch("techui_builder.builder.etree.ElementTree") +@patch("techui_builder.generate_jsonmap.objectify.deannotate") +@patch("techui_builder.generate_jsonmap.etree.ElementTree") def test_autofiller_write_bob(mock_tree, mock_deannotate, autofiller): autofiller.tree = mock_tree diff --git a/tests/test_builder.py b/tests/test_builder.py index aee743ed..14ebfe44 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,21 +1,12 @@ import logging -import os from io import StringIO from pathlib import Path -from unittest.mock import MagicMock, Mock, mock_open, patch +from unittest.mock import Mock, mock_open, patch import pytest -from lxml import objectify from phoebusgen.widget import ActionButton, Group from softioc.builder import ClearRecords, records -from techui_builder.builder import ( - JsonMap, - _get_action_group, # type: ignore - _get_nav_tabs, # type: ignore - _serialise_json_map, # type: ignore -) - @pytest.mark.parametrize( "attr, expected", @@ -258,341 +249,3 @@ def test_create_screens_extra_p_does_not_exist(builder_with_setup, caplog): for log_output in caplog.records: assert "Extra prefix BAD-PV" in log_output.message - - -def test_write_json_map_no_synoptic(builder): - with pytest.raises(FileNotFoundError): - builder.write_json_map(synoptic=Path("bad-synoptic.bob")) - - -def test_write_json_map(builder): - test_map = JsonMap( - str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None - ) - - # We don't want cover _generate_json_map in this test - builder._generate_json_map = Mock(return_value=test_map) - - # Make sure opis/ dir exists - if not Path.exists(builder._write_directory): - os.mkdir(builder._write_directory) - - # We don't want to access the _serialise_json_map function in this test - with patch("techui_builder.builder._serialise_json_map") as mock_serialise_json_map: - mock_serialise_json_map.return_value = {"test": "test"} - - builder.write_json_map( - synoptic=builder._write_directory.joinpath("index.bob"), - dest=builder._write_directory, - ) - - dest_path = builder._write_directory.joinpath("JsonMap.json") - assert Path.exists(dest_path) - - if Path.exists(dest_path): - os.remove(dest_path) - - -# We don't want to access the _get_action_group function in this test -@patch("techui_builder.builder._get_action_group") -def test_generate_json_map( - mock_get_action_group, - builder_with_test_files, - example_json_map, - test_files, -): - screen_path, dest_path = test_files - - mock_xml = objectify.Element("action") - mock_xml["file"] = "test_child_bob.bob" - mock_get_action_group.return_value = mock_xml - builder_with_test_files._get_component_label = Mock( - side_effect=["Display", "Detector"] - ) - - test_json_map = builder_with_test_files._generate_json_map( - screen_path.absolute(), dest_path - ) - - assert test_json_map == example_json_map - - -# TODO: write this test -def test_generate_json_map_embedded_screen(builder_with_test_files, example_json_map): - builder_with_test_files._get_component_label = Mock( - side_effect=[ - "Display", - "Detector", - "Embedded Display", - "Embedded Display", - "Embedded Display", - ] - ) - - screen_path = Path("tests/test_files/test_bob_embedded.bob").absolute() - dest_path = Path("tests/test_files/") - - example_json_map.file = "test_bob_embedded.bob" - example_json_map.children.append( - JsonMap( - "$(IOC)/pmacAxis.pvi.bob", - display_name="Embedded Display", - exists=False, - macros={"M": "$(M)", "P": "$(P)"}, - ) - ) - - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - - assert test_json_map == example_json_map - - -def test_generate_json_map_nav_tabs(builder_with_test_files, example_json_map_root): - builder_with_test_files._get_component_label = Mock( - side_effect=["Display", "Tab1", "Tab2"] - ) - - screen_path = Path("tests/test_files/test_bob_navtabs.bob").absolute() - dest_path = Path("tests/test_files/") - - example_json_map_root.file = "test_bob_navtabs.bob" - example_json_map_root.children.extend( - [ - JsonMap(display_name="Tab1", file="tab1.bob", exists=False), - JsonMap(display_name="Tab2", file="tab2.bob", exists=False), - ] - ) - - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - - assert test_json_map == example_json_map_root - - -def test_parse_display_name_with_name(builder): - """Test parse display name when tag is present""" - display_name = builder._parse_display_name( - "", Path("/path/to/filename.pvi.bob") - ) - assert display_name == "" - - -def test_parse_display_name_from_filepath(builder): - """Test parse display name when only filepath is present""" - display_name = builder._parse_display_name(None, Path("/path/to/filename.pvi.bob")) - assert display_name == "filename" - - -def test_parse_display_name_returns_none(builder): - """Test parse display ensures JSON displayName will return null otherwise""" - display_name = builder._parse_display_name(None, Path("")) - - assert display_name is None - - -def test_fix_names_json_map_recursive(builder, example_display_names_json): - """Test duplicate names are enumerated correctly for all children""" - - test_display_names_json = JsonMap( - str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None - ) - - test_display_names_json_det1 = JsonMap( - "test_child_bob.bob", "Detector", macros={"P": "PV-DET-01"}, exists=False - ) - test_display_names_json_det2 = JsonMap( - "test_child_bob.bob", "Detector", macros={"P": "PV-DET-02"}, exists=False - ) - test_display_names_json_det3 = JsonMap( - "test_child_bob.bob", "Detector", macros={"P": "PV-DET-03"}, exists=False - ) - test_display_names_json_det4 = JsonMap( - "test_child_bob.bob", "Detector", macros={"R": "NON-P-MACRO"}, exists=False - ) - test_display_names_json_dev1 = JsonMap( - "test_child_bob.bob", "Device", macros={"P": "PV-DEV-01"}, exists=False - ) - test_display_names_json_dev2 = JsonMap( - "test_child_bob.bob", "Device", macros={"P": "PV-DEV-02"}, exists=False - ) - test_display_names_json = JsonMap("test_bob.bob", "Beamline") - - test_display_names_json_dev1.children.append(test_display_names_json_det1) - test_display_names_json_dev1.children.append(test_display_names_json_det2) - test_display_names_json_dev2.children.append(test_display_names_json_det3) - test_display_names_json_dev2.children.append(test_display_names_json_det4) - test_display_names_json.children.append(test_display_names_json_dev1) - test_display_names_json.children.append(test_display_names_json_dev2) - - builder._fix_names_json_map(test_display_names_json) - - assert test_display_names_json == example_display_names_json - - -# We don't want to access the _get_action_group function in this test -@patch("techui_builder.builder._get_action_group") -def test_generate_json_map_get_macros( - mock_get_action_group, - builder_with_test_files, - example_json_map, - test_files, -): - screen_path, dest_path = test_files - - # Set a custom macro to test against - example_json_map.children[0].macros = {"macro": "value"} - - mock_xml = objectify.Element("action") - mock_xml["file"] = "test_child_bob.bob" - macros = objectify.SubElement(mock_xml, "macros") - # Set a macro to test - macros["macro"] = "value" - builder_with_test_files._get_component_label = Mock( - side_effect=["Display", "Detector"] - ) - mock_get_action_group.return_value = mock_xml - - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - assert test_json_map == example_json_map - - -def test_generate_json_map_xml_parse_error(builder_with_test_files, test_files): - screen_path = Path("tests/test_files/test_bob_bad.bob").absolute() - _, dest_path = test_files - - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - - assert test_json_map.error.startswith("XML parse error:") - - -@patch("techui_builder.builder._get_action_group") -def test_generate_json_map_other_exception( - mock_get_action_group, - builder_with_test_files, - test_files, -): - screen_path, dest_path = test_files - - mock_get_action_group.side_effect = Exception("Some exception") - builder_with_test_files._get_component_label = Mock( - side_effect=["Display", "Detector"] - ) - - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - - assert test_json_map.error != "" - - -def test_serialise_json_map(example_json_map): - json_ = _serialise_json_map(example_json_map) # type: ignore - - assert json_ == { - "file": "test_bob.bob", - "children": [ - {"file": "test_child_bob.bob", "displayName": "Detector", "exists": False} - ], - "displayName": "Display", - } - - -def test_get_action_group(): - test_bob = objectify.parse("tests/test_files/test_bob.bob") - - widget = test_bob.find(".//widget") - assert widget is not None - - action_group = _get_action_group(widget) - assert action_group is not None - - -def test_get_action_group_no_action_elements(): - test_bob = objectify.parse("tests/test_files/test_bob.bob") - - widget = test_bob.find(".//widget") - assert widget is not None - - # Clear the actions element - widget.actions = objectify.ObjectifiedElement() - - action_group = _get_action_group(widget) - assert action_group is None - - -def test_get_action_group_no_actions_group(caplog): - # Use a blank xml element - widget = objectify.ObjectifiedElement() - # TODO: Do widgets always have a name attr, or _can_ it be empty?? - widget.name = "Test" - - with caplog.at_level(logging.ERROR): - _get_action_group(widget) - - for log_output in caplog.records: - assert "Actions group not found" in log_output.message - - -def test_get_component_label(builder_with_test_files): - display_name = builder_with_test_files._get_component_label( - "motor", - None, - None, - ) - assert display_name == "Motor Stage" - - -def test_get_component_label_child_labels(builder_with_test_files): - display_name = builder_with_test_files._get_component_label( - "X", - current_component_name="motor", - display_name="X", - ) - assert display_name == "X1" - - -def test_get_component_label_child_labels_with_name_already_pregenerated( - builder_with_test_files, -): - display_name = builder_with_test_files._get_component_label( - "X1", - current_component_name="motor", - display_name="X", - ) - assert display_name == "X1" - - -def test_get_component_label_with_name_elem_invalid( - builder_with_test_files, -): - display_name = builder_with_test_files._get_component_label( - "invalid_name", - current_component_name=None, - display_name="new_name", - ) - assert display_name == "new_name" - - -def test_get_component_label_with_current_component_name_invalid( - builder_with_test_files, -): - display_name = builder_with_test_files._get_component_label( - "invalid_name", - current_component_name="invalid_name", - display_name="new_name", - ) - assert display_name == "new_name" - - -def test_get_nav_tabs(example_navtabs_widget): - tabs_widget = _get_nav_tabs(example_navtabs_widget) - - assert isinstance(tabs_widget, list) - - -def test_get_nav_tabs_no_tabs_group(caplog): - mock_navtabs = MagicMock(spec=objectify.ObjectifiedElement) - mock_navtabs.name = "no_tabs" - - with caplog.at_level(logging.ERROR): - _get_nav_tabs(mock_navtabs) - - for log_output in caplog.records: - assert "Tabs group not found" in log_output.message diff --git a/tests/test_cli.py b/tests/test_cli.py index 1986be71..c6e96abb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import logging +import os from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -140,3 +141,38 @@ def test_main(mock_builder, mock_autofiller, mock_find_dirs, mock_find_bob): mock_find_dirs.assert_called_once() mock_find_bob.assert_called_once() + + +def test_main_json_map_no_bob_generation(caplog): + runner.invoke(app, ["--generate-jsonmap"]) + for log_output in caplog.records: + assert ( + " Option '--generate-jsonmap' requires an argument." in log_output.message + ) + + +def test_main_json_map_wrong_file(caplog): + result = runner.invoke(app, ["--generate-jsonmap", "map.json"]) + assert ( + "Invalid value: Json map generation requires a bob file as input." + in result.output + ) + + +def test_main_json_map_generation(caplog): + runner.invoke( + app, + [ + "--generate-jsonmap", + "tests/t01-services/synoptic/index.bob", + ], + ) + if Path.exists(Path("tests/t01-services/synoptic/JsonMap.json")): + os.remove("tests/t01-services/synoptic/JsonMap.json") + for log_output in caplog.records: + assert "Json map generated for (from" in log_output.message + + +def test_main_without_techui_yaml(caplog): + result = runner.invoke(app) + assert "Techui.yaml file must be provided as an argument." in result.output diff --git a/tests/test_generate_jsonmap.py b/tests/test_generate_jsonmap.py new file mode 100644 index 00000000..2a543fd3 --- /dev/null +++ b/tests/test_generate_jsonmap.py @@ -0,0 +1,399 @@ +import logging +import os +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest +from lxml import objectify +from typer.testing import CliRunner + +from techui_builder.generate_jsonmap import ( + JsonMap, + _get_action_group, + _get_nav_tabs, # type: ignore + _serialise_json_map, + app, + log_level, +) + +runner = CliRunner() + + +@patch("techui_builder.generate_jsonmap.Logger") +def test_log_level(mock_logger): + log_level("INFO") + mock_logger.assert_called_once() + + +def test_write_json_map_no_synoptic(json_map_generator): + with pytest.raises(FileNotFoundError): + json_map_generator.bob_path = Path("Synoptic") + json_map_generator.write_json_map() + + +def test_app(): + result = runner.invoke(app, ["tests/t01-services/synoptic/techui.yaml"]) + if Path.exists(Path("tests/t01-services/synoptic/JsonMap.json")): + os.remove("tests/t01-services/synoptic/JsonMap.json") + assert result.exit_code == 0 + + +@patch("techui_builder.generate_jsonmap.yaml.safe_load") +def test_json_map_generator_techui_exception(mock_safe_load, json_map_generator): + mock_safe_load.side_effect = Exception("YAML load error") + with pytest.raises(Exception) as excinfo: + json_map_generator.__init__(bob_path=Path("tests/test_files/test_bob.bob")) + assert "No such file or directory" in str(excinfo.value) + + +def test_write_json_map(json_map_generator): + test_map = JsonMap( + str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None + ) + + # We don't want cover _generate_json_map in this test + json_map_generator.generate_json_map = Mock(return_value=test_map) + + # Make sure opis/ dir exists + if not Path.exists(json_map_generator._write_directory): + os.mkdir(json_map_generator._write_directory) + + # We don't want to access the _serialise_json_map function in this test + with patch( + "techui_builder.generate_jsonmap._serialise_json_map" + ) as mock_serialise_json_map: + mock_serialise_json_map.return_value = {"test": "test"} + + json_map_generator.write_json_map() + + dest_path = json_map_generator._write_directory.joinpath("JsonMap.json") + assert Path.exists(dest_path) + + if Path.exists(dest_path): + os.remove(dest_path) + + +# We don't want to access the _get_action_group function in this test +@patch("techui_builder.generate_jsonmap._get_action_group") +def test_generate_json_map( + mock_get_action_group, + json_map_generator_with_test_files, + example_json_map, +): + json_map_generator_with_test_files.bob_path = Path( + "tests/test_files/test_bob.bob" + ).absolute() + + mock_xml = objectify.Element("action") + mock_xml["file"] = "test_child_bob.bob" + mock_get_action_group.return_value = mock_xml + json_map_generator_with_test_files._get_component_label = Mock( + side_effect=["Display", "Detector"] + ) + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + + assert test_json_map == example_json_map + + +def test_generate_json_map_embedded_screen( + json_map_generator_with_test_files, example_json_map +): + json_map_generator_with_test_files._get_component_label = Mock( + side_effect=[ + "Display", + "Detector", + "Embedded Display", + "Embedded Display", + "Embedded Display", + ] + ) + + json_map_generator_with_test_files.bob_path = Path( + "tests/test_files/test_bob_embedded.bob" + ).absolute() + + example_json_map.file = "test_bob_embedded.bob" + example_json_map.children.append( + JsonMap( + "$(IOC)/pmacAxis.pvi.bob", + display_name="Embedded Display", + exists=False, + macros={"M": "$(M)", "P": "$(P)"}, + ) + ) + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + assert test_json_map == example_json_map + + +def test_generate_json_map_nav_tabs( + json_map_generator_with_test_files, example_json_map_root +): + json_map_generator_with_test_files._get_component_label = Mock( + side_effect=["Display", "Tab1", "Tab2"] + ) + + json_map_generator_with_test_files.bob_path = Path( + "tests/test_files/test_bob_navtabs.bob" + ).absolute() + + example_json_map_root.file = "test_bob_navtabs.bob" + example_json_map_root.children.extend( + [ + JsonMap(display_name="Tab1", file="tab1.bob", exists=False), + JsonMap(display_name="Tab2", file="tab2.bob", exists=False), + ] + ) + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + + assert test_json_map == example_json_map_root + + +def test_parse_display_name_with_name(json_map_generator): + """Test parse display name when tag is present""" + display_name = json_map_generator._parse_display_name( + "", Path("/path/to/filename.pvi.bob") + ) + assert display_name == "" + + +def test_parse_display_name_from_filepath(json_map_generator): + """Test parse display name when only filepath is present""" + display_name = json_map_generator._parse_display_name( + None, Path("/path/to/filename.pvi.bob") + ) + assert display_name == "filename" + + +def test_parse_display_name_returns_none(json_map_generator): + """Test parse display ensures JSON displayName will return null otherwise""" + display_name = json_map_generator._parse_display_name(None, Path("")) + + assert display_name is None + + +def test_fix_names_json_map_recursive(json_map_generator, example_display_names_json): + """Test duplicate names are enumerated correctly for all children""" + + test_display_names_json = JsonMap( + str(Path(__file__).parent.joinpath("test_files/test_bob.bob")), None + ) + + test_display_names_json_det1 = JsonMap( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-01"}, exists=False + ) + test_display_names_json_det2 = JsonMap( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-02"}, exists=False + ) + test_display_names_json_det3 = JsonMap( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-03"}, exists=False + ) + test_display_names_json_det4 = JsonMap( + "test_child_bob.bob", "Detector", macros={"R": "NON-P-MACRO"}, exists=False + ) + test_display_names_json_dev1 = JsonMap( + "test_child_bob.bob", "Device", macros={"P": "PV-DEV-01"}, exists=False + ) + test_display_names_json_dev2 = JsonMap( + "test_child_bob.bob", "Device", macros={"P": "PV-DEV-02"}, exists=False + ) + test_display_names_json = JsonMap("test_bob.bob", "Beamline") + + test_display_names_json_dev1.children.append(test_display_names_json_det1) + test_display_names_json_dev1.children.append(test_display_names_json_det2) + test_display_names_json_dev2.children.append(test_display_names_json_det3) + test_display_names_json_dev2.children.append(test_display_names_json_det4) + test_display_names_json.children.append(test_display_names_json_dev1) + test_display_names_json.children.append(test_display_names_json_dev2) + + json_map_generator._fix_names_json_map(test_display_names_json) + + assert test_display_names_json == example_display_names_json + + +# We don't want to access the _get_action_group function in this test +@patch("techui_builder.generate_jsonmap._get_action_group") +def test_generate_json_map_get_macros( + mock_get_action_group, + json_map_generator_with_test_files, + example_json_map, +): + # Set a custom macro to test against + example_json_map.children[0].macros = {"macro": "value"} + + mock_xml = objectify.Element("action") + mock_xml["file"] = "test_child_bob.bob" + macros = objectify.SubElement(mock_xml, "macros") + # Set a macro to test + macros["macro"] = "value" + json_map_generator_with_test_files._get_component_label = Mock( + side_effect=["Display", "Detector"] + ) + mock_get_action_group.return_value = mock_xml + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + assert test_json_map == example_json_map + + +def test_generate_json_map_xml_parse_error( + json_map_generator_with_test_files, +): + json_map_generator_with_test_files.bob_path = Path( + "tests/test_files/test_bob_bad.bob" + ).absolute() + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + + assert test_json_map.error.startswith("XML parse error:") + + +@patch("techui_builder.generate_jsonmap._get_action_group") +def test_generate_json_map_other_exception( + mock_get_action_group, + json_map_generator_with_test_files, + test_files, +): + mock_get_action_group.side_effect = Exception("Some exception") + json_map_generator_with_test_files._get_component_label = Mock( + side_effect=["Display", "Detector"] + ) + + test_json_map = json_map_generator_with_test_files.generate_json_map( + json_map_generator_with_test_files.bob_path, + json_map_generator_with_test_files._write_directory, + ) + + assert test_json_map.error != "" + + +def test_serialise_json_map(example_json_map): + json_ = _serialise_json_map(example_json_map) # type: ignore + + assert json_ == { + "file": "test_bob.bob", + "children": [ + {"file": "test_child_bob.bob", "displayName": "Detector", "exists": False} + ], + "displayName": "Display", + } + + +def test_get_action_group(): + test_bob = objectify.parse("tests/test_files/test_bob.bob") + + widget = test_bob.find(".//widget") + assert widget is not None + + action_group = _get_action_group(widget) + assert action_group is not None + + +def test_get_action_group_no_action_elements(): + test_bob = objectify.parse("tests/test_files/test_bob.bob") + + widget = test_bob.find(".//widget") + assert widget is not None + + # Clear the actions element + widget.actions = objectify.ObjectifiedElement() + + action_group = _get_action_group(widget) + assert action_group is None + + +def test_get_action_group_no_actions_group(caplog): + # Use a blank xml element + widget = objectify.ObjectifiedElement() + # TODO: Do widgets always have a name attr, or _can_ it be empty?? + widget.name = "Test" + + with caplog.at_level(logging.ERROR): + _get_action_group(widget) + + for log_output in caplog.records: + assert "Actions group not found" in log_output.message + + +def test_get_component_label(json_map_generator_with_test_files): + display_name = json_map_generator_with_test_files._get_component_label( + "motor", + None, + None, + ) + assert display_name == "Motor Stage" + + +def test_get_component_label_child_labels(json_map_generator_with_test_files): + display_name = json_map_generator_with_test_files._get_component_label( + "X", + current_component_name="motor", + display_name="X", + ) + assert display_name == "X1" + + +def test_get_component_label_child_labels_with_name_already_pregenerated( + json_map_generator_with_test_files, +): + display_name = json_map_generator_with_test_files._get_component_label( + "X1", + current_component_name="motor", + display_name="X", + ) + assert display_name == "X1" + + +def test_get_component_label_with_name_elem_invalid( + json_map_generator_with_test_files, +): + display_name = json_map_generator_with_test_files._get_component_label( + "invalid_name", + current_component_name=None, + display_name="new_name", + ) + assert display_name == "new_name" + + +def test_get_component_label_with_current_component_name_invalid( + json_map_generator_with_test_files, +): + display_name = json_map_generator_with_test_files._get_component_label( + "invalid_name", + current_component_name="invalid_name", + display_name="new_name", + ) + assert display_name == "new_name" + + +def test_get_nav_tabs(example_navtabs_widget): + tabs_widget = _get_nav_tabs(example_navtabs_widget) + + assert isinstance(tabs_widget, list) + + +def test_get_nav_tabs_no_tabs_group(caplog): + mock_navtabs = MagicMock(spec=objectify.ObjectifiedElement) + mock_navtabs.name = "no_tabs" + + with caplog.at_level(logging.ERROR): + _get_nav_tabs(mock_navtabs) + + for log_output in caplog.records: + assert "Tabs group not found" in log_output.message