diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index 8cd60481..80e74a50 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -8,9 +8,8 @@ from lxml.etree import Element, SubElement, tostring from lxml.objectify import ObjectifiedElement, fromstring -from techui_builder.generate_jsonmap import _get_action_group from techui_builder.models import Component -from techui_builder.utils import read_bob +from techui_builder.utils import _get_action_group, read_bob logger_ = logging.getLogger(__name__) diff --git a/src/techui_builder/generate_jsonmap.py b/src/techui_builder/generate_jsonmap.py index ee907209..3fc3c936 100644 --- a/src/techui_builder/generate_jsonmap.py +++ b/src/techui_builder/generate_jsonmap.py @@ -1,24 +1,21 @@ +"""Typer app and JsonMapGenerator for generating JsonMap.json.""" + import json import logging -import re -from collections import defaultdict -from dataclasses import _MISSING_TYPE, dataclass, field +from dataclasses import dataclass, field from pathlib import Path -from typing import Annotated, Any +from typing import Annotated 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 Component, TechUi -from techui_builder.utils import _get_action_group, _get_macros, _get_nav_tabs +from techui_builder.jsonmap.crawl import CrawlContext, crawl +from techui_builder.jsonmap.nodes import ScreenNode, serialise_node +from techui_builder.models import TechUi logger_ = logging.getLogger(__name__) -_PVI_FILE_RE = re.compile(r"^(?:\$\(IOC\))\/([a-zA-Z]+[.a-zA-Z]+)$") - def log_level(level: str): Logger(level) @@ -42,27 +39,13 @@ def log_level(level: str): ) -@dataclass -class JsonMap: - """Dataclass to handle the structure of a JsonMap element.""" - - 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: - """Helper class containing functions to generate a JsonMap file.""" + """Helper class containing functions to generate a JsonMap.json file.""" bob_path: Path = field(default=Path("index.bob")) techui: Path = field(default=Path("techui.yaml")) output: Path | None = field(default=None) - _current_service_name: str = field(default="", init=False, repr=False) def __post_init__(self): # Determine the directory to write the json map file to. @@ -92,363 +75,34 @@ def generate_json_map( dest_path: Path, current_component_name: str | None = None, name_elem: str | None = None, - ) -> JsonMap: + ) -> ScreenNode: """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 regenerated - 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 _child_file_crawl( - file_path_text: str, - destination_path: Path, - name_element: str | None, - component_name: str | None, - display_name: str | None, - macro_dictionary: dict[str, Any], - ): - """ - Function to determine if the child node file exists, and if it - does, recursively generate a JsonMap element for it and it's children. - - If it can't be found, a minimal JsonMap element is returned. - """ - child_file_path = destination_path / file_path_text - - match = _PVI_FILE_RE.fullmatch(file_path_text) - # The file path is a PVI screen, so attempt to find that screen - if match: - file_name = match.group(1) - child_file_path = ( - destination_path / f"../{self._current_service_name}/{file_name}" - ) - - # Crawl the next file - if child_file_path.is_file(): - # TODO: investigate non-recursive approaches? - child_node = self.generate_json_map( - child_file_path, - destination_path, - current_component_name=component_name, - name_elem=name_element, - ) - else: - child_node = JsonMap( - file_path_text, - display_name, - exists=("IOC" in macro_dictionary or ("https:/" in file_path_text)), - ) - - return child_node - - # ------------------------------------------ - - # Create initial node at top of .bob file - current_node = JsonMap( - str( - screen_path.resolve().relative_to( - self._parent_path.resolve(), walk_up=True - ) - ), - display_name=None, + ctx = CrawlContext( + components=self.techui_yaml.components, + synoptic_dir=self._parent_path, + link_base_dir=dest_path, + component_name=current_component_name, + service_name="", ) - - # Get Current Component - if ( - current_component_name is None - and screen_path.stem in self.techui_yaml.components - ): - current_component_name = screen_path.stem - # We know from the if statement that it exists - _current_component = self.techui_yaml.components.get(current_component_name) - assert isinstance(_current_component, Component) - # TODO: How to find the screens if PV prefix is not the service name??? - self._current_service_name = _current_component.prefix.lower() - - 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 = _get_macros(open_display) - - case "embedded": - file_elem = widget_elem.file - name_elem = widget_elem.name.text - macro_dict = _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 = _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 = _child_file_crawl( - file_text, - 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 = _child_file_crawl( - file_text, - 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 child_label,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 _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) + return crawl(screen_path, ctx, link_name=name_elem) def write_json_map( self, ): - """ - Maps the valid entries from the ioc.yaml or fastcs.yaml file - to the required screen in *-support.yaml - """ + """Crawl the screen tree from bob_path and write it to JsonMap.json.""" 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._parent_path) + json_map = self.generate_json_map(self.bob_path, self._parent_path) with open(self._write_directory / "JsonMap.json", "w") as f: f.write( - json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)) + json.dumps(json_map, indent=4, default=lambda o: serialise_node(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 - - @app.callback(invoke_without_command=True) def generate_jsonmap( bob_path: Annotated[ diff --git a/src/techui_builder/jsonmap/__init__.py b/src/techui_builder/jsonmap/__init__.py new file mode 100644 index 00000000..d0c1ecf1 --- /dev/null +++ b/src/techui_builder/jsonmap/__init__.py @@ -0,0 +1 @@ +"""Generate a JSON map of the Phoebus .bob screen tree for a beamline.""" diff --git a/src/techui_builder/jsonmap/crawl.py b/src/techui_builder/jsonmap/crawl.py new file mode 100644 index 00000000..3ca47672 --- /dev/null +++ b/src/techui_builder/jsonmap/crawl.py @@ -0,0 +1,131 @@ +"""Recursively crawl a tree of .bob screens into a tree of ScreenNodes.""" + +from collections.abc import Mapping +from dataclasses import dataclass, replace +from pathlib import Path + +from lxml import etree, objectify +from lxml.objectify import ObjectifiedElement + +from techui_builder.jsonmap.links import ( + WidgetLink, + WidgetType, + assumed_exists, + extract_links, + find_local_screen, +) +from techui_builder.jsonmap.naming import ( + find_techui_label, + fix_duplicate_names, + name_or_file_stem, +) +from techui_builder.jsonmap.nodes import ScreenNode +from techui_builder.models import Component + + +@dataclass +class CrawlContext: + """State passed down the recursion.""" + + components: Mapping[str, Component] + synoptic_dir: Path # ScreenNode.file is relative to this + link_base_dir: Path # link files are resolved against this; never changes + component_name: str | None + service_name: str + + def with_screen_component(self, screen_path: Path) -> "CrawlContext": + """A copy for the screen's component, if it is one and none is set yet.""" + if self.component_name is not None or screen_path.stem not in self.components: + return self + + component_name = screen_path.stem + # We know from the if statement that it exists + component = self.components.get(component_name) + assert isinstance(component, Component) + # TODO: How to find the screens if PV prefix is not the service name??? + return replace( + self, component_name=component_name, service_name=component.prefix.lower() + ) + + +def crawl_link( + link: WidgetLink, display_name: str | None, ctx: CrawlContext +) -> ScreenNode: + """ + Determine if the child node file exists, and if it does, recursively + generate a ScreenNode for it and its children. + + If it can't be found, a leaf ScreenNode is returned. + """ + local_path = find_local_screen(link.file, ctx.link_base_dir, ctx.service_name) + + # Crawl the next file + if local_path is not None: + # TODO: investigate non-recursive approaches? + return crawl(local_path, ctx, link_name=link.name) + + return ScreenNode( + link.file, + display_name, + exists=assumed_exists(link.file, link.macros), + ) + + +def crawl( + screen_path: Path, ctx: CrawlContext, link_name: str | None = None +) -> ScreenNode: + """Crawl a .bob screen and the screens it links to into a ScreenNode.""" + + # Create initial node at top of .bob file + current_node = ScreenNode( + str( + screen_path.resolve().relative_to(ctx.synoptic_dir.resolve(), walk_up=True) + ), + display_name=None, + ) + + ctx = ctx.with_screen_component(screen_path) + + try: + # Create xml tree from .bob file + tree = objectify.parse(screen_path.absolute()) + root: ObjectifiedElement = tree.getroot() + + # Label for the linking widget, else the screen's own , else file stem + own_name = name_or_file_stem(root.name.text, screen_path) + label = find_techui_label(ctx.components, ctx.component_name, link_name) + current_node.display_name = label if label is not None else own_name + + for link in extract_links(root): + # Label, else widget , else file stem + label = find_techui_label(ctx.components, ctx.component_name, link.name) + display_name = name_or_file_stem( + label if label is not None else link.name, Path(link.file) + ) + + child_node = crawl_link(link, display_name, ctx) + + if link.type == WidgetType.EMBEDDED: + for embedded_child in child_node.children: + embedded_child.macros = {**embedded_child.macros, **link.macros} + embedded_child.display_name = display_name + embedded_child.exists = "IOC" in link.macros or ( + "https://" in str(embedded_child.file) + ) + current_node.children.append(embedded_child) + + else: + child_node.macros = link.macros + # TODO: make this work for only list[ScreenNode] + 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) + + fix_duplicate_names(current_node) + + return current_node diff --git a/src/techui_builder/jsonmap/links.py b/src/techui_builder/jsonmap/links.py new file mode 100644 index 00000000..54c087e6 --- /dev/null +++ b/src/techui_builder/jsonmap/links.py @@ -0,0 +1,124 @@ +"""Links from a .bob screen to other screens.""" + +import re +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from lxml.objectify import ObjectifiedElement + +from techui_builder.utils import _get_action_group, _get_macros, _get_nav_tabs + +PVI_FILE_RE = re.compile(r"^(?:\$\(IOC\))\/([a-zA-Z]+[.a-zA-Z]+)$") + + +class WidgetType(StrEnum): + """Widget types in a .bob file that can link to other screens.""" + + SYMBOL = "symbol" + ACTION_BUTTON = "action_button" + EMBEDDED = "embedded" + NAVTABS = "navtabs" + + +@dataclass +class WidgetLink: + """A link from a .bob screen to another screen.""" + + file: str # raw, stripped text + name: str | None # widget (or tab ) + type: WidgetType + macros: dict[str, str] + + +def extract_file_text(file_elem: ObjectifiedElement) -> str: + """The stripped text of a element.""" + # Keep raw string to preserve urls + return file_elem.text.strip() if file_elem.text else "" + + +def is_bob(file: str) -> bool: + """Whether the file is a .bob screen.""" + return Path(file).suffix == ".bob" + + +def extract_links(root: ObjectifiedElement) -> Iterator[WidgetLink]: + """Yield links to .bob screens from widgets, in document order.""" + # Find all elements + widgets = [ + w + for w in root.findall(".//widget") + if w.get("type", default=None) in WidgetType + ] + + # A generator, so an error in a widget is raised after earlier links are crawled + for widget_elem in widgets: + widget_type = WidgetType(widget_elem.get("type")) + + match widget_type: + case WidgetType.SYMBOL | WidgetType.ACTION_BUTTON: + # Only the first open_display action; skip widgets without one + 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 = widget_elem.name.text + macros = _get_macros(open_display) + + case WidgetType.EMBEDDED: + file_elem = widget_elem.file + name = widget_elem.name.text + macros = _get_macros(widget_elem) + + case WidgetType.NAVTABS: + # One link per tab, in tab order + tabs = _get_nav_tabs(widget_elem) + if tabs is None: + continue + + for tab in tabs: + name = tab.name.text + file_elem = tab.file + macros = _get_macros(tab) + + file = extract_file_text(file_elem) + # Skip links that are not .bob screens + if not is_bob(file): + continue + + yield WidgetLink(file, name, widget_type, macros) + + continue + + file = extract_file_text(file_elem) + # Skip links that are not .bob screens + if not is_bob(file): + continue + + yield WidgetLink(file, name, widget_type, macros) + + +def resolve_link_path(file: str, base_dir: Path, service_name: str) -> Path: + """Resolve a link's file to a local path.""" + + match = PVI_FILE_RE.fullmatch(file) + # The file path is a PVI screen, so attempt to find that screen + if match: + file_name = match.group(1) + return base_dir / f"../{service_name}/{file_name}" + + return base_dir / file + + +def find_local_screen(file: str, base_dir: Path, service_name: str) -> Path | None: + """Resolve a link's file, returning the path if it can be crawled locally.""" + path = resolve_link_path(file, base_dir, service_name) + return path if path.is_file() else None + + +def assumed_exists(file: str, macros: Mapping[str, str]) -> bool: + """Whether a link's file that could not be found locally is assumed to exist.""" + return "IOC" in macros or ("https:/" in file) diff --git a/src/techui_builder/jsonmap/naming.py b/src/techui_builder/jsonmap/naming.py new file mode 100644 index 00000000..4640a854 --- /dev/null +++ b/src/techui_builder/jsonmap/naming.py @@ -0,0 +1,84 @@ +"""Display-name rules for ScreenNodes and duplicate-name fixing.""" + +from collections import defaultdict +from collections.abc import Mapping +from pathlib import Path + +from techui_builder.jsonmap.nodes import ScreenNode +from techui_builder.models import Component + + +def find_techui_label( + components: Mapping[str, Component], + component_name: str | None, + name: str | None, +) -> str | None: + """The techui.yaml label or child label for a widget name, or None.""" + if name is None: + return None + + if name in components and components[name].label is not None: + return components[name].label + + if component_name is None or component_name not in components: + return None + + child_labels = components[component_name].child_labels + if child_labels is None: + return None + + # Because name is initially grabbed from the .bob file, the generated + # .bob file might have already propagated the child label from techui.yaml + if name in child_labels.values(): + return name + # In the case of screens not regenerated, such as validated screens, the name + # text will not be updated to the child_label, so we check keys solely for + # generating the json_map from the top level .bob. + if name in child_labels: + return child_labels[name] + + return None + + +def name_or_file_stem(name: str | None, file_path: Path) -> str | None: + """The name if it is non-empty, else the file name without suffixes, else None.""" + + 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_duplicate_names(node: ScreenNode) -> 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: + fix_duplicate_names(child) diff --git a/src/techui_builder/jsonmap/nodes.py b/src/techui_builder/jsonmap/nodes.py new file mode 100644 index 00000000..849f4f3c --- /dev/null +++ b/src/techui_builder/jsonmap/nodes.py @@ -0,0 +1,42 @@ +"""Data model for the JsonMap screen tree and its JSON serialisation.""" + +from dataclasses import MISSING, Field, dataclass, field, fields +from typing import Any + + +@dataclass +class ScreenNode: + """One screen in the JsonMap tree, with the screens it links to as children.""" + + file: str + display_name: str | None + exists: bool = True + duplicate: bool = False + children: list["ScreenNode"] = field(default_factory=list) + macros: dict[str, str] = field(default_factory=dict) + error: str = "" + + +def field_default(f: Field[Any]) -> Any: + """The default value of a dataclass field, or MISSING if it has none.""" + if f.default_factory is not MISSING: + return f.default_factory() + return f.default + + +def serialise_node(node: ScreenNode) -> dict[str, Any]: + """Convert a ScreenNode and its children to dicts, omitting default values.""" + d = {} + for f in fields(node): + value = getattr(node, f.name) + if value == field_default(f): + continue + if f.name == "children": + value = [serialise_node(child) for child in value] + d[f.name] = value + + # Rename display_name to displayName for JSON camel case convention + if "display_name" in d: + d["displayName"] = d.pop("display_name") + + return d diff --git a/tests/conftest.py b/tests/conftest.py index 28eb8175..fc936ec8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,8 @@ from techui_builder.autofill import Autofiller from techui_builder.builder import Builder from techui_builder.generate import Generator -from techui_builder.generate_jsonmap import JsonMap, JsonMapGenerator +from techui_builder.generate_jsonmap import JsonMapGenerator +from techui_builder.jsonmap.nodes import ScreenNode from techui_builder.models import Component, SupportEntity from techui_builder.status import GenerateStatusPvs from techui_builder.validator import Validator @@ -161,7 +162,7 @@ def test_files(tmp_test_files): @pytest.fixture def example_json_map_root(): - test_map_base = JsonMap("test_bob.bob", "Display") + test_map_base = ScreenNode("test_bob.bob", "Display") return test_map_base @@ -178,7 +179,7 @@ def json_map_generator_with_test_files(tmp_t01_services, tmp_test_files): @pytest.fixture def example_json_map(example_json_map_root): # Create test json map with child json map - test_map_child = JsonMap("test_child_bob.bob", "Detector", exists=False) + test_map_child = ScreenNode("test_child_bob.bob", "Detector", exists=False) example_json_map_root.children.append(test_map_child) @@ -188,43 +189,43 @@ def example_json_map(example_json_map_root): @pytest.fixture def example_display_names_json(): # Create test json map with correct display names - test_map_det1 = JsonMap( + test_map_det1 = ScreenNode( "test_child_bob.bob", "Detector (PV-DET-01)", macros={"P": "PV-DET-01"}, exists=False, ) - test_map_det2 = JsonMap( + test_map_det2 = ScreenNode( "test_child_bob.bob", "Detector (PV-DET-02)", macros={"P": "PV-DET-02"}, exists=False, ) - test_map_det3 = JsonMap( + test_map_det3 = ScreenNode( "test_child_bob.bob", "Detector (PV-DET-03)", macros={"P": "PV-DET-03"}, exists=False, ) - test_map_det4 = JsonMap( + test_map_det4 = ScreenNode( "test_child_bob.bob", "Detector (NO PV NAME 1)", macros={"R": "NON-P-MACRO"}, exists=False, ) - test_map_dev1 = JsonMap( + test_map_dev1 = ScreenNode( "test_child_bob.bob", "Device (PV-DEV-01)", macros={"P": "PV-DEV-01"}, exists=False, ) - test_map_dev2 = JsonMap( + test_map_dev2 = ScreenNode( "test_child_bob.bob", "Device (PV-DEV-02)", macros={"P": "PV-DEV-02"}, exists=False, ) - test_map = JsonMap("test_bob.bob", "Beamline") + test_map = ScreenNode("test_bob.bob", "Beamline") test_map_dev1.children.append(test_map_det1) test_map_dev1.children.append(test_map_det2) @@ -238,13 +239,13 @@ def example_display_names_json(): @pytest.fixture def example_json_map_pvi_screens(): - jsonmap = JsonMap( + jsonmap = ScreenNode( file="motor1.bob", display_name="motor1", exists=True, duplicate=False, children=[ - JsonMap( + ScreenNode( file="../bl01t-mo-motor-01/pmacAxis.pvi.bob", display_name="X1", exists=True, @@ -258,7 +259,7 @@ def example_json_map_pvi_screens(): }, error="", ), - JsonMap( + ScreenNode( file="../bl01t-mo-motor-01/pmacAxis.pvi.bob", display_name="A", exists=True, @@ -272,7 +273,7 @@ def example_json_map_pvi_screens(): }, error="", ), - JsonMap( + ScreenNode( file="techui-support/bob/pmac/pmacController.bob", display_name="pmacController", exists=True, diff --git a/tests/jsonmap/__init__.py b/tests/jsonmap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/jsonmap/test_crawl.py b/tests/jsonmap/test_crawl.py new file mode 100644 index 00000000..3c89504d --- /dev/null +++ b/tests/jsonmap/test_crawl.py @@ -0,0 +1,72 @@ +from pathlib import Path + +import pytest + +from techui_builder.jsonmap.crawl import CrawlContext, crawl, crawl_link +from techui_builder.jsonmap.links import WidgetLink, WidgetType +from techui_builder.jsonmap.nodes import ScreenNode + + +@pytest.fixture +def t01_ctx(json_map_generator) -> CrawlContext: + synoptic = json_map_generator.bob_path.parent + return CrawlContext( + components=json_map_generator.techui_yaml.components, + synoptic_dir=synoptic, + link_base_dir=synoptic, + component_name=None, + service_name="", + ) + + +def test_crawl(t01_ctx): + json_map = crawl(t01_ctx.synoptic_dir / "index.bob", t01_ctx) + + assert json_map.file == "index.bob" + assert json_map.display_name == "Display" + assert json_map.error == "" + assert [(c.file, c.display_name, c.exists) for c in json_map.children] == [ + ("dcam1.bob", "Diagnostic Camera 1", True), + ("test.bob", "Diode 1", False), + ("temp1.bob", "Temperaure Controller 1", False), + ("motor1.bob", "Motor Stage", True), + ] + + +def test_crawl_component_screen(t01_ctx): + motor = crawl(t01_ctx.synoptic_dir / "motor1.bob", t01_ctx, link_name="motor1") + + assert motor.display_name == "Motor Stage" + assert [(c.file, c.display_name) for c in motor.children] == [ + ("../bl01t-mo-motor-01/pmacAxis.pvi.bob", "X1"), + ("../bl01t-mo-motor-01/pmacAxis.pvi.bob", "A"), + ("techui-support/bob/pmac/pmacController.bob", "pmacController"), + ] + + +def test_crawl_link(t01_ctx): + found = crawl_link( + WidgetLink("motor1.bob", "motor1", WidgetType.ACTION_BUTTON, {}), + "Motor Stage", + t01_ctx, + ) + missing = crawl_link( + WidgetLink("missing.bob", "Missing", WidgetType.ACTION_BUTTON, {}), + "Missing", + t01_ctx, + ) + + assert found.file == "motor1.bob" + assert len(found.children) == 3 + assert missing == ScreenNode("missing.bob", "Missing", exists=False) + + +def test_with_screen_component(t01_ctx): + motor_ctx = t01_ctx.with_screen_component(Path("motor1.bob")) + + assert motor_ctx.component_name == "motor1" + assert motor_ctx.service_name == "bl01t-mo-motor-01" + # Not a component + assert t01_ctx.with_screen_component(Path("index.bob")) is t01_ctx + # Already inside a component + assert motor_ctx.with_screen_component(Path("dcam1.bob")) is motor_ctx diff --git a/tests/jsonmap/test_links.py b/tests/jsonmap/test_links.py new file mode 100644 index 00000000..47d3d46b --- /dev/null +++ b/tests/jsonmap/test_links.py @@ -0,0 +1,88 @@ +from pathlib import Path + +from lxml import objectify + +from techui_builder.jsonmap.links import ( + WidgetLink, + WidgetType, + assumed_exists, + extract_links, + find_local_screen, + resolve_link_path, +) + + +def button(name: str, *actions: str, widget_type="action_button") -> str: + return ( + f'{name}' + f"{''.join(actions)}" + ) + + +def open_display(file: str, macros: str = "") -> str: + return ( + f'{file}' + f"{macros}" + ) + + +def test_extract_links(): + root = objectify.fromstring( + 'Display' + 'Group' + + button( + "Button", + 'PV', + open_display(" screen.bob\n", "

BL01T-EA-TEST-01

"), + open_display("second.bob"), + ) + + "
" + + button("Symbol", open_display("symbol.bob"), widget_type="symbol") + + 'Decoration' + + button("Legacy", open_display("legacy.opi")) + + button("Empty", open_display("")) + + 'Embedembed.bob' + ":X" + 'Tabs' + "Tab1tab1.bob" + "Tab2tab2.opi" + "" + "
" + ) + + assert list(extract_links(root)) == [ + WidgetLink( + "screen.bob", + "Button", + WidgetType.ACTION_BUTTON, + {"P": "BL01T-EA-TEST-01"}, + ), + WidgetLink("symbol.bob", "Symbol", WidgetType.SYMBOL, {}), + WidgetLink("embed.bob", "Embed", WidgetType.EMBEDDED, {"M": ":X"}), + WidgetLink("tab1.bob", "Tab1", WidgetType.NAVTABS, {}), + ] + + +def test_resolve_link_path(): + dest = Path("/beamline/synoptic") + svc = "bl01t-mo-motor-01" + + assert resolve_link_path("sub/screen.bob", dest, svc) == dest / "sub/screen.bob" + assert ( + resolve_link_path("$(IOC)/Simple.pvi.bob", dest, svc) + == dest / f"../{svc}/Simple.pvi.bob" + ) + + +def test_find_local_screen(tmp_path: Path): + (tmp_path / "screen.bob").touch() + + assert find_local_screen("screen.bob", tmp_path, "") == tmp_path / "screen.bob" + for file in ["missing.bob", "", "https://example.invalid/x/screen.bob"]: + assert find_local_screen(file, tmp_path, "") is None + + +def test_assumed_exists(): + assert assumed_exists("$(IOC)/x.pvi.bob", {"IOC": "https://example.invalid"}) + assert assumed_exists("https://example.invalid/x/screen.bob", {}) + assert not assumed_exists("missing.bob", {"P": "BL01T-MO-MOTOR-01"}) diff --git a/tests/jsonmap/test_naming.py b/tests/jsonmap/test_naming.py new file mode 100644 index 00000000..cf897360 --- /dev/null +++ b/tests/jsonmap/test_naming.py @@ -0,0 +1,115 @@ +from pathlib import Path + +from techui_builder.jsonmap.naming import ( + find_techui_label, + fix_duplicate_names, + name_or_file_stem, +) +from techui_builder.jsonmap.nodes import ScreenNode + + +def test_name_or_file_stem_with_name(): + """Test parse display name when tag is present""" + display_name = name_or_file_stem("", Path("/path/to/filename.pvi.bob")) + assert display_name == "" + + +def test_name_or_file_stem_from_filepath(): + """Test parse display name when only filepath is present""" + display_name = name_or_file_stem(None, Path("/path/to/filename.pvi.bob")) + assert display_name == "filename" + + +def test_name_or_file_stem_returns_none(): + """Test parse display ensures JSON displayName will return null otherwise""" + display_name = name_or_file_stem(None, Path("")) + + assert display_name is None + + +def test_fix_duplicate_names_recursive(example_display_names_json, tmp_test_files): + """Test duplicate names are enumerated correctly for all children""" + + test_display_names_json = ScreenNode(str(tmp_test_files / "test_bob.bob"), None) + + test_display_names_json_det1 = ScreenNode( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-01"}, exists=False + ) + test_display_names_json_det2 = ScreenNode( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-02"}, exists=False + ) + test_display_names_json_det3 = ScreenNode( + "test_child_bob.bob", "Detector", macros={"P": "PV-DET-03"}, exists=False + ) + test_display_names_json_det4 = ScreenNode( + "test_child_bob.bob", "Detector", macros={"R": "NON-P-MACRO"}, exists=False + ) + test_display_names_json_dev1 = ScreenNode( + "test_child_bob.bob", "Device", macros={"P": "PV-DEV-01"}, exists=False + ) + test_display_names_json_dev2 = ScreenNode( + "test_child_bob.bob", "Device", macros={"P": "PV-DEV-02"}, exists=False + ) + test_display_names_json = ScreenNode("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) + + fix_duplicate_names(test_display_names_json) + + assert test_display_names_json == example_display_names_json + + +def test_find_techui_label(json_map_generator_with_test_files): + display_name = find_techui_label( + json_map_generator_with_test_files.techui_yaml.components, + None, + "motor1", + ) + assert display_name == "Motor Stage" + + +def test_find_techui_label_child_labels(json_map_generator_with_test_files): + display_name = find_techui_label( + json_map_generator_with_test_files.techui_yaml.components, + component_name="motor1", + name="X", + ) + assert display_name == "X1" + + +def test_find_techui_label_child_labels_with_name_already_pregenerated( + json_map_generator_with_test_files, +): + display_name = find_techui_label( + json_map_generator_with_test_files.techui_yaml.components, + component_name="motor1", + name="X1", + ) + assert display_name == "X1" + + +def test_find_techui_label_with_name_elem_invalid( + json_map_generator_with_test_files, +): + display_name = find_techui_label( + json_map_generator_with_test_files.techui_yaml.components, + component_name=None, + name="invalid_name", + ) + assert display_name is None + + +def test_find_techui_label_with_current_component_name_invalid( + json_map_generator_with_test_files, +): + display_name = find_techui_label( + json_map_generator_with_test_files.techui_yaml.components, + component_name="invalid_name", + name="invalid_name", + ) + assert display_name is None diff --git a/tests/jsonmap/test_nodes.py b/tests/jsonmap/test_nodes.py new file mode 100644 index 00000000..b6560c7c --- /dev/null +++ b/tests/jsonmap/test_nodes.py @@ -0,0 +1,29 @@ +from dataclasses import MISSING, fields + +from techui_builder.jsonmap.nodes import ScreenNode, field_default, serialise_node + + +def test_serialise_node(example_json_map): + json_ = serialise_node(example_json_map) + + assert json_ == { + "file": "test_bob.bob", + "children": [ + {"file": "test_child_bob.bob", "displayName": "Detector", "exists": False} + ], + "displayName": "Display", + } + + +def test_field_default(): + defaults = {f.name: field_default(f) for f in fields(ScreenNode)} + + assert defaults == { + "file": MISSING, + "display_name": MISSING, + "exists": True, + "duplicate": False, + "children": [], + "macros": {}, + "error": "", + } diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index 46d62745..c015e077 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -31,8 +31,8 @@ def test_autofiller_autofill_bob(autofiller): assert mock_widget.find("run_actions_on_mouse_click") == "true" -@patch("techui_builder.generate_jsonmap.objectify.deannotate") -@patch("techui_builder.generate_jsonmap.etree.ElementTree") +@patch("techui_builder.autofill.objectify.deannotate") +@patch("lxml.etree.ElementTree") def test_autofiller_write_bob( mock_tree: MagicMock, mock_deannotate: MagicMock, autofiller, tmp_test_files ): diff --git a/tests/test_generate_jsonmap.py b/tests/test_generate_jsonmap.py index 6c508891..b64ef3e0 100644 --- a/tests/test_generate_jsonmap.py +++ b/tests/test_generate_jsonmap.py @@ -1,4 +1,3 @@ -import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -6,14 +5,8 @@ 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, -) +from techui_builder.generate_jsonmap import app, log_level +from techui_builder.jsonmap.nodes import ScreenNode runner = CliRunner() @@ -55,16 +48,14 @@ def test_json_map_generator_techui_exception( def test_write_json_map(json_map_generator, tmp_test_files): - test_map = JsonMap(str(tmp_test_files / "test_bob.bob"), None) + test_map = ScreenNode(str(tmp_test_files / "test_bob.bob"), None) - # We don't want cover _generate_json_map in this test + # We don't want to cover generate_json_map in this test json_map_generator.generate_json_map = Mock(return_value=test_map) - # 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"} + # We don't want to access the serialise_node function in this test + with patch("techui_builder.generate_jsonmap.serialise_node") as mock_serialise_node: + mock_serialise_node.return_value = {"test": "test"} json_map_generator.write_json_map() @@ -73,23 +64,7 @@ def test_write_json_map(json_map_generator, tmp_test_files): assert Path.exists(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: MagicMock, - json_map_generator_with_test_files, - example_json_map, -): - 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._parse_display_name = Mock( - side_effect=["Display", "Detector"] - ) - json_map_generator_with_test_files._get_component_label = Mock( - side_effect=["Display", "Detector"] - ) - +def test_generate_json_map(json_map_generator_with_test_files, example_json_map): 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, @@ -101,29 +76,15 @@ def test_generate_json_map( def test_generate_json_map_embedded_screen( json_map_generator_with_test_files, example_json_map, tmp_test_files ): - list_names = [ - "Display", - "Detector", - "Embedded Display", - "Embedded Display", - "Embedded Display", - ] - json_map_generator_with_test_files._parse_display_name = Mock( - side_effect=list_names - ) - json_map_generator_with_test_files._get_component_label = Mock( - side_effect=list_names - ) - json_map_generator_with_test_files.bob_path = ( tmp_test_files / "test_bob_embedded.bob" ) example_json_map.file = "test_bob_embedded.bob" example_json_map.children.append( - JsonMap( + ScreenNode( "$(IOC)/pmacAxis.pvi.bob", - display_name="Embedded Display", + display_name="Z", exists=False, macros={"M": ":EMBED", "P": "BL01T-MO-MOTOR-01", "label": "EMBED"}, ) @@ -139,13 +100,6 @@ def test_generate_json_map_embedded_screen( def test_generate_json_map_nav_tabs( json_map_generator_with_test_files, example_json_map_root, tmp_test_files ): - json_map_generator_with_test_files._parse_display_name = Mock( - side_effect=["Display", "Tab1", "Tab2"] - ) - json_map_generator_with_test_files._get_component_label = Mock( - side_effect=["Display", "Tab1", "Tab2"] - ) - json_map_generator_with_test_files.bob_path = ( tmp_test_files / "test_bob_navtabs.bob" ) @@ -153,8 +107,8 @@ def test_generate_json_map_nav_tabs( 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), + ScreenNode(display_name="Tab1", file="tab1.bob", exists=False), + ScreenNode(display_name="Tab2", file="tab2.bob", exists=False), ] ) @@ -177,70 +131,8 @@ def test_generate_json_map_child_file_crawl_pvi_screen( assert example_json_map_pvi_screens == jsonmap -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, tmp_test_files -): - """Test duplicate names are enumerated correctly for all children""" - - test_display_names_json = JsonMap(str(tmp_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") +@patch("techui_builder.jsonmap.links._get_action_group") def test_generate_json_map_get_macros( mock_get_action_group: MagicMock, json_map_generator_with_test_files, @@ -254,12 +146,6 @@ def test_generate_json_map_get_macros( macros = objectify.SubElement(mock_xml, "macros") # Set a macro to test macros["macro"] = "value" - json_map_generator_with_test_files._parse_display_name = Mock( - side_effect=["Display", "Detector"] - ) - 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( @@ -282,18 +168,12 @@ def test_generate_json_map_xml_parse_error( assert test_json_map.error.startswith("XML parse error:") -@patch("techui_builder.generate_jsonmap._get_action_group") +@patch("techui_builder.jsonmap.links._get_action_group") def test_generate_json_map_other_exception( mock_get_action_group: MagicMock, json_map_generator_with_test_files, ): mock_get_action_group.side_effect = Exception("Some exception") - json_map_generator_with_test_files._parse_display_name = Mock( - side_effect=["Display", "Detector"] - ) - 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, @@ -301,119 +181,3 @@ def test_generate_json_map_other_exception( ) 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(tmp_test_files): - test_bob = objectify.parse(tmp_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(tmp_test_files): - test_bob = objectify.parse(tmp_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: pytest.LogCaptureFixture): - # 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( - "motor1", - 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="motor1", - 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="motor1", - 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_xml_navtabs_widget): - tabs_widget = _get_nav_tabs(example_xml_navtabs_widget) - - assert isinstance(tabs_widget, list) - - -def test_get_nav_tabs_no_tabs_group(caplog: pytest.LogCaptureFixture): - 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_utils.py b/tests/test_utils.py index fb2565c9..5a13a244 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,18 @@ +import logging from unittest.mock import MagicMock, patch +import pytest +from lxml import objectify from lxml.etree import _ElementTree from lxml.objectify import Element, ObjectifiedElement -from techui_builder.utils import get_widgets, read_bob +from techui_builder.utils import ( + _get_action_group, + _get_macros, + _get_nav_tabs, + get_widgets, + read_bob, +) @patch("techui_builder.utils.get_widgets") @@ -24,3 +33,65 @@ def test_get_widgets(example_xml_symbol_widget): widgets = get_widgets(test_root) assert "motor" in widgets.keys() + + +def test_get_action_group(tmp_test_files): + test_bob = objectify.parse(tmp_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(tmp_test_files): + test_bob = objectify.parse(tmp_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: pytest.LogCaptureFixture): + # 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_nav_tabs(example_xml_navtabs_widget): + tabs_widget = _get_nav_tabs(example_xml_navtabs_widget) + + assert isinstance(tabs_widget, list) + + +def test_get_nav_tabs_no_tabs_group(caplog: pytest.LogCaptureFixture): + 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 + + +def test_get_macros(): + element = objectify.fromstring( + "

BL01T-EA-TEST-01

" + ) + + assert _get_macros(element) == {"P": "BL01T-EA-TEST-01"} + assert _get_macros(objectify.fromstring("")) == {}