diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 00ac384..35da856 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -22,6 +22,7 @@ @dataclass class JsonMap: file: str + display_name: str | None exists: bool = True duplicate: bool = False children: list["JsonMap"] = field(default_factory=list) @@ -242,37 +243,27 @@ def create_screens(self): ioc.yaml files in services" ) - def _generate_json_map( - self, screen_path: Path, dest_path: Path, visited: set[Path] | None = None - ) -> JsonMap: - def _get_macros(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 {} - - if visited is None: - visited = set() - - current_node = JsonMap(str(screen_path.relative_to(self._write_directory))) + def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: + """Recursively generate JSON map from .bob file tree""" + + # Create initial node at top of .bob file + current_node = JsonMap( + str(screen_path.relative_to(self._write_directory)), + display_name=None, + ) abs_path = screen_path.absolute() - dest_path = dest_path - if abs_path in visited: - current_node.exists = True - current_node.duplicate = True - return current_node - visited.add(abs_path) 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 + ) + # Find all elements widgets = [ w @@ -292,32 +283,38 @@ def _get_macros(element: ObjectifiedElement): 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) - macro_dict = _get_macros(open_display) # case "embedded": # file_elem = widget_elem.file # macro_dict = _get_macros(widget_elem) + case _: continue # Extract file path from file_elem file_path = Path(file_elem.text.strip() if file_elem.text else "") + # If file is already a .bob file, skip it if not file_path.suffix == ".bob": continue + # Create valid displayName + display_name = self._parse_display_name(name_elem, file_path) + # TODO: misleading var name? next_file_path = dest_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, dest_path, visited - ) + child_node = self._generate_json_map(next_file_path, dest_path) else: - child_node = JsonMap(str(file_path), exists=False) + child_node = JsonMap(str(file_path), display_name, exists=False) child_node.macros = macro_dict # TODO: make this work for only list[JsonMap] @@ -330,8 +327,61 @@ def _get_macros(element: ObjectifiedElement): except Exception as e: current_node.error = str(e) + self._fix_duplicate_names(current_node) + return current_node + 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_duplicate_names(self, node: JsonMap) -> None: + """Recursively fix duplicate display names in children""" + if not node.children: + return + + # Count occurrences of each display_name + name_counts: defaultdict[str | None, int] = defaultdict(int) + for child in node.children: + if child.display_name: + name_counts[child.display_name] += 1 + + # Track which number we're on for each duplicate name + name_indices: defaultdict[str | None, int] = defaultdict(int) + + # Update display names for duplicates + for child in node.children: + if child.display_name and name_counts[child.display_name] > 1: + name_indices[child.display_name] += 1 + child.display_name = ( + f"{child.display_name} {name_indices[child.display_name]}" + ) + + # Recursively fix children + self._fix_duplicate_names(child) + def write_json_map( self, synoptic: Path = Path("example/t01-services/synoptic/index.bob"), @@ -383,6 +433,10 @@ def _check_default(key: str, value: Any): 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 diff --git a/tests/conftest.py b/tests/conftest.py index d68d62c..a3aa61d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,13 +49,32 @@ def test_files(): @pytest.fixture def example_json_map(): # Create test json map with child json map - test_map_child = JsonMap("test_child_bob.bob", exists=False) - test_map = JsonMap("test_bob.bob") + test_map_child = JsonMap("test_child_bob.bob", "Detector", exists=False) + test_map = JsonMap("test_bob.bob", "Display") test_map.children.append(test_map_child) return test_map +@pytest.fixture +def example_display_names_json(): + # Create test json map with correct display names + test_map_det1 = JsonMap("test_child_bob.bob", "Detector 1", exists=False) + test_map_det2 = JsonMap("test_child_bob.bob", "Detector 2", exists=False) + test_map_dev1 = JsonMap("test_child_bob.bob", "Device 1", exists=False) + test_map_dev2 = JsonMap("test_child_bob.bob", "Device 2", exists=False) + test_map = JsonMap("test_bob.bob", "Beamline") + + test_map_dev1.children.append(test_map_det1) + test_map_dev1.children.append(test_map_det2) + test_map_dev2.children.append(test_map_det1) + test_map_dev2.children.append(test_map_det2) + test_map.children.append(test_map_dev1) + test_map.children.append(test_map_dev2) + + return test_map + + @pytest.fixture def generator(): synoptic_dir = Path(__file__).parent.joinpath(Path("t01-services/synoptic")) diff --git a/tests/test_builder.py b/tests/test_builder.py index 4aba205..4ac5cbf 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -258,7 +258,9 @@ def test_write_json_map_no_synoptic(builder): def test_write_json_map(builder): - test_map = JsonMap(str(Path(__file__).parent.joinpath("test_files/test_bob.bob"))) + 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) @@ -312,6 +314,56 @@ def test_generate_json_map(builder_with_test_files, example_json_map, test_files # assert test_json_map == example_json_map +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_duplicate_names_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", exists=False + ) + test_display_names_json_det2 = JsonMap( + "test_child_bob.bob", "Detector", exists=False + ) + test_display_names_json_dev1 = JsonMap("test_child_bob.bob", "Device", exists=False) + test_display_names_json_dev2 = JsonMap("test_child_bob.bob", "Device", 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_det1) + test_display_names_json_dev2.children.append(test_display_names_json_det2) + test_display_names_json.children.append(test_display_names_json_dev1) + test_display_names_json.children.append(test_display_names_json_dev2) + + builder._fix_duplicate_names(test_display_names_json) + + assert test_display_names_json == example_display_names_json + + def test_generate_json_map_get_macros( builder_with_test_files, example_json_map, test_files ): @@ -336,24 +388,6 @@ def test_generate_json_map_get_macros( assert test_json_map == example_json_map -def test_generate_json_map_visited_node( - builder_with_test_files, example_json_map, test_files -): - screen_path, dest_path = test_files - - visited = {screen_path} - # Clear children as they will never be read - example_json_map.children = [] - # Need to set this to true too - example_json_map.duplicate = True - - test_json_map = builder_with_test_files._generate_json_map( - screen_path, dest_path, visited - ) - - 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 @@ -381,7 +415,10 @@ def test_serialise_json_map(example_json_map): assert json_ == { "file": "test_bob.bob", - "children": [{"file": "test_child_bob.bob", "exists": False}], + "children": [ + {"file": "test_child_bob.bob", "displayName": "Detector", "exists": False} + ], + "displayName": "Display", }