From 56b74546218bf5a4897e8837bdeeb4d92cc29b89 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 25 Sep 2026 08:23:57 +0000 Subject: [PATCH 1/5] Refactored how the suffix and label are determined in generate.py --- src/techui_builder/generate.py | 20 +++++++++----------- tests/conftest.py | 8 ++++---- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index b1dee7f9..11806565 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -159,7 +159,9 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]: :2 ] component_name = suffix.removeprefix(":").removesuffix(":") - suffix_key = next(k for k, v in component.macros.items() if v == suffix) + suffix_key = next( + (k for k, v in component.macros.items() if v == suffix), component_name + ) except (IndexError, ValueError): prefix = component.prefix component_name = component.type @@ -210,19 +212,15 @@ def _allocate_widget( # For Gui Components with multiple components embedded, we add a suffix field # to the components, and adjust the name and suffix accordingly try: - if screen_mapping["suffixes"] is not None: - suffix_dict: dict[str, str] = screen_mapping["suffixes"] - for suffix_key, suffix in suffix_dict.items(): - component.macros[suffix_key] = suffix + if screen_mapping.get("screen_macros"): + for macro_key, macro_val in screen_mapping["screen_macros"].items(): + component.macros[macro_key] = macro_val # If no child label was specified... - if self.label_flag is False: + if not self.label_flag and "label" not in component.macros: # TODO: think of a better fallback component name for this - component_name = ( - list(suffix_dict.values())[0] - .removeprefix(":") - .removesuffix(":") - ) + first_val = list(screen_mapping["screen_macros"].values())[0] + component_name = first_val.removeprefix(":").removesuffix(":") component.macros["label"] = component_name except KeyError: pass diff --git a/tests/conftest.py b/tests/conftest.py index d96898b4..3cb21605 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,28 +75,28 @@ def techui_support(): screens=[ { "file": "ADAravis/NDPluginStats.pvi.bob", - "suffixes": { + "screen_macros": { "R": ":STAT:", }, "type": "related", }, { "file": "ADAravis/NDPluginPva.pvi.bob", - "suffixes": { + "screen_macros": { "R": ":PVA:", }, "type": "related", }, { "file": "ADAravis/NDPluginROIStat.pvi.bob", - "suffixes": { + "screen_macros": { "R": ":ROISTAT:", }, "type": "related", }, { "file": "ADAravis/NDFileHDF5.pvi.bob", - "suffixes": { + "screen_macros": { "R": ":HDF5:", }, "type": "related", From 836efc5ece70b10e234046532a65847927ef7cf3 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 25 Sep 2026 09:30:00 +0000 Subject: [PATCH 2/5] Various fixes and tweaks for the new screen_macros, and add a new BaseModel for the screen dict in techui-support yaml entities --- src/techui_builder/generate.py | 45 ++++++++++++------- src/techui_builder/models.py | 22 ++++++++- src/techui_builder/utils.py | 9 ++++ tests/conftest.py | 82 +++++++++++++++++++--------------- tests/test_generate.py | 6 +-- 5 files changed, 105 insertions(+), 59 deletions(-) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index 11806565..fbb3f456 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -2,7 +2,6 @@ import os import re from collections import defaultdict -from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -12,7 +11,7 @@ from phoebusgen import widget as pwidget from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group -from techui_builder.models import Component, Entity, TechUiSupport +from techui_builder.models import Component, Entity, SupportEntityScreen, TechUiSupport logger_ = logging.getLogger(__name__) @@ -160,12 +159,13 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]: ] component_name = suffix.removeprefix(":").removesuffix(":") suffix_key = next( - (k for k, v in component.macros.items() if v == suffix), component_name + (k for k, v in component.macros.items() if v == suffix), None ) except (IndexError, ValueError): prefix = component.prefix component_name = component.type - suffix_key = suffix = "" + suffix_key = None + suffix = "" # Try to get name from child labels if they exist, # if not, just use the name as it is. @@ -177,27 +177,27 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]: prefix_key = next(k for k, v in component.macros.items() if v == prefix) new_macros[prefix_key] = prefix - if suffix_key != "": + if suffix_key is not None: new_macros[suffix_key] = suffix new_macros["label"] = component_name return component_name, new_macros def _allocate_widget( - self, screen_mapping: Mapping, component: Entity + self, screen_mapping: SupportEntityScreen, component: Entity ) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]: component_name, updated_macros = self._update_macros(component) component.macros.update(updated_macros) # Get relative path to screen - file = Template(screen_mapping["file"]).render(component.macros) + file = Template(screen_mapping.file).render(component.macros) if file.startswith("$(IOC)"): screen_path = support_screen_path = file.replace( "$(IOC)", f"{self.beamline_url}/{component.service_name}" ) # Only works with related displays as # embedded displays need to access the file to get dimensions - assert screen_mapping["type"] == "related", ( + assert screen_mapping.type == "related", ( "Only related displays can have remote screens" ) else: @@ -209,23 +209,34 @@ def _allocate_widget( self.synoptic_dir, walk_up=True ) - # For Gui Components with multiple components embedded, we add a suffix field - # to the components, and adjust the name and suffix accordingly + # For Gui Components with multiple components embedded, we add macro overrides + # via screen_macros and adjust the name/label accordingly try: - if screen_mapping.get("screen_macros"): - for macro_key, macro_val in screen_mapping["screen_macros"].items(): + if screen_mapping.screen_macros: + for macro_key, macro_val in screen_mapping.screen_macros.items(): component.macros[macro_key] = macro_val # If no child label was specified... if not self.label_flag and "label" not in component.macros: - # TODO: think of a better fallback component name for this - first_val = list(screen_mapping["screen_macros"].values())[0] - component_name = first_val.removeprefix(":").removesuffix(":") - component.macros["label"] = component_name + # Prefer an explicit "label" or "suffix" key in screen_macros, + # otherwise fall back to the first value (by insertion order). + fallback_keys = ("label", "suffix") + first_val = None + for key in fallback_keys: + if key in screen_mapping.screen_macros: + first_val = screen_mapping.screen_macros[key] + break + if first_val is None: + first_val = next( + iter(screen_mapping.screen_macros.values()), None + ) + if first_val is not None: + component_name = first_val.removeprefix(":").removesuffix(":") + component.macros["label"] = component_name except KeyError: pass - if screen_mapping["type"] == "embedded": + if screen_mapping.type == "embedded": height, width = self._get_screen_dimensions(str(screen_path)) new_widget = pwidget.EmbeddedDisplay( component_name, diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 2cfc27cc..9b5dc131 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -308,6 +308,24 @@ class Entity(BaseModel): ] +class SupportEntityScreen(BaseModel): + """One screen entry for a support module.""" + + file: Annotated[str, Field(description="Path to the .bob or .pvi.bob screen file")] + type: Annotated[str, Field(description="Screen type, e.g. 'embedded' or 'related'")] + screen_macros: Annotated[ + dict[str, str] | None, + Field( + description="Screen-specific macro overrides", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "type": "object", + "additionalProperties": {"type": "string"}, + }, + ), + ] = None + + class SupportEntity(BaseModel): """ Table of variables from corresponding support module in techui-support.yaml file @@ -319,8 +337,8 @@ class SupportEntity(BaseModel): Field(description="Macros for the matching screen (can be empty)"), ] screens: Annotated[ - list[dict[str, str | dict[str, str]]], - Field(description="Dictionary of available screens for the support module"), + list[SupportEntityScreen], + Field(description="List of available screens for the support module"), ] diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index 8e67e459..cb52b032 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -6,6 +6,15 @@ logger_ = logging.getLogger(__name__) +__all__ = [ + "WidgetType", + "get_widgets", + "read_bob", + "_get_action_group", + "_get_macros", + "_get_nav_tabs", +] + class WidgetType(StrEnum): """Widget types in a .bob file.""" diff --git a/tests/conftest.py b/tests/conftest.py index 3cb21605..78fbfb5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from techui_builder.generate import Generator from techui_builder.generate_jsonmap import JsonMapGenerator from techui_builder.jsonmap.nodes import ScreenNode -from techui_builder.models import Component, SupportEntity +from techui_builder.models import Component, SupportEntity, SupportEntityScreen from techui_builder.status import GenerateStatusPvs from techui_builder.validator import Validator @@ -48,63 +48,71 @@ def builder(tmp_t01_services): def techui_support(): ts = MagicMock() ts.support_modules = { - "pmac.GeoBrick": SupportEntity(prefix="{{ P }}", macros=["P"], screens=[{}]), - "pmac.autohome": SupportEntity(prefix="{{ P }}", macros=["P"], screens=[{}]), + "pmac.GeoBrick": SupportEntity( + prefix="{{ P }}", + macros=["P"], + screens=[SupportEntityScreen(file="", type="")], + ), + "pmac.autohome": SupportEntity( + prefix="{{ P }}", + macros=["P"], + screens=[SupportEntityScreen(file="", type="")], + ), "pmac.dls_pmac_asyn_motor": SupportEntity( - prefix="{{ P }}{{ M }}", macros=["P", "M"], screens=[{}] + prefix="{{ P }}{{ M }}", + macros=["P", "M"], + screens=[SupportEntityScreen(file="", type="")], ), "ADAravis.aravisCamera": SupportEntity( prefix="{{ P }}{{ R }}", macros=["P", "R"], screens=[ - {"file": "ADAravis/ADAravis_summary.bob", "type": "embedded"}, - {"file": "ADAravis/ADAravis_detail.bob", "type": "related"}, + SupportEntityScreen( + file="ADAravis/ADAravis_summary.bob", type="embedded" + ), + SupportEntityScreen( + file="ADAravis/ADAravis_detail.bob", type="related" + ), ], ), "ADUVC.UVC": SupportEntity( prefix="{{ P }}{{ R }}", macros=["P", "R"], screens=[ - {"file": "ADUVC/ADUVC_summary.bob", "type": "embedded"}, - {"file": "$(IOC)/ADUVC.pvi.bob", "type": "related"}, + SupportEntityScreen(file="ADUVC/ADUVC_summary.bob", type="embedded"), + SupportEntityScreen(file="$(IOC)/ADUVC.pvi.bob", type="related"), ], ), "detectorPlugins.detectorPlugins": SupportEntity( prefix="{{ P }}{{ R }}", macros=["P", "R"], screens=[ - { - "file": "ADAravis/NDPluginStats.pvi.bob", - "screen_macros": { - "R": ":STAT:", - }, - "type": "related", - }, - { - "file": "ADAravis/NDPluginPva.pvi.bob", - "screen_macros": { - "R": ":PVA:", - }, - "type": "related", - }, - { - "file": "ADAravis/NDPluginROIStat.pvi.bob", - "screen_macros": { - "R": ":ROISTAT:", - }, - "type": "related", - }, - { - "file": "ADAravis/NDFileHDF5.pvi.bob", - "screen_macros": { - "R": ":HDF5:", - }, - "type": "related", - }, + SupportEntityScreen( + file="ADAravis/NDPluginStats.pvi.bob", + type="related", + screen_macros={"R": ":STAT:"}, + ), + SupportEntityScreen( + file="ADAravis/NDPluginPva.pvi.bob", + type="related", + screen_macros={"R": ":PVA:"}, + ), + SupportEntityScreen( + file="ADAravis/NDPluginROIStat.pvi.bob", + type="related", + screen_macros={"R": ":ROISTAT:"}, + ), + SupportEntityScreen( + file="ADAravis/NDFileHDF5.pvi.bob", + type="related", + screen_macros={"R": ":HDF5:"}, + ), ], ), "fastcs.TemperatureController": SupportEntity( - prefix="{{ name }}", macros=["name"], screens=[{}] + prefix="{{ name }}", + macros=["name"], + screens=[SupportEntityScreen(file="", type="")], ), } diff --git a/tests/test_generate.py b/tests/test_generate.py index 6b497b9a..a30cdd23 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -222,7 +222,7 @@ def test_generator_allocate_widget(generator, tmp_test_files): scrn_mappings = generator.techui_support.support_modules[ "ADAravis.aravisCamera" ].screens - scrn_mapping = next((x for x in scrn_mappings if x["type"] == "embedded"), None) + scrn_mapping = next((x for x in scrn_mappings if x.type == "embedded"), None) component = Entity( service_name="bl01t-di-ioc-01", @@ -246,7 +246,7 @@ def test_generator_allocate_widget_with_remote_screens(generator, tmp_test_files ) scrn_mappings = generator.techui_support.support_modules["ADUVC.UVC"].screens - scrn_mapping = next((x for x in scrn_mappings if x["type"] == "related"), None) + scrn_mapping = next((x for x in scrn_mappings if x.type == "related"), None) component = Entity( service_name="bl01t-di-ioc-01", @@ -271,7 +271,7 @@ def test_generator_allocate_widget_with_custom_suffix(generator, tmp_test_files) scrn_mappings = generator.techui_support.support_modules[ "detectorPlugins.detectorPlugins" ].screens - scrn_mapping = next((x for x in scrn_mappings if x["type"] == "related"), None) + scrn_mapping = next((x for x in scrn_mappings if x.type == "related"), None) component = Entity( service_name="bl01t-di-ioc-01", From 8ec32d43618cb4d4dd4399366af23d12a490ab94 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 29 Sep 2026 10:07:25 +0000 Subject: [PATCH 3/5] Edit how the Entity registry is generated and searched through. Also added new 'base_prefix' computed value for Entity to just return the PV prefix without any colons or suffixes --- src/techui_builder/builder.py | 90 +++++++++++++++++++---------------- src/techui_builder/models.py | 6 +++ tests/test_builder.py | 23 +++------ 3 files changed, 61 insertions(+), 58 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 18d84909..7702594d 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -1,7 +1,6 @@ import logging import os import re -from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path @@ -34,9 +33,7 @@ class Builder: techui: Path = field(default=Path("techui.yaml")) - entities: defaultdict[str, list[Entity]] = field( - default_factory=lambda: defaultdict(list), init=False - ) + entities: dict[str, Entity] = field(default_factory=dict, init=False) _services_dir: Path = field(init=False, repr=False) _write_directory: Path = field(init=False, repr=False) @@ -180,10 +177,21 @@ def _extract_entities(self, service_name: str, service_yaml: Path): macros=macros, ) - pv_root = prefix.split(":", maxsplit=1)[0] - self.entities[pv_root].append(new_entity) + self.entities[prefix] = new_entity break + def get_entities_for_prefix(self, prefix: str) -> list[Entity]: + """Return entities matching a full PV prefix, or all entities under a root.""" + results = [] + if prefix in self.entities: + # Exact match (no suffix) — also catches root entity in loop below + results.append(self.entities[prefix]) + # Children with suffixes (e.g. BL19I-MO-DCM-01:MOT1) + for entity in self.entities.values(): + if entity.base_prefix == prefix and entity is not self.entities.get(prefix): + results.append(entity) + return results + def _generate_screen(self, screen_name: str): self.generator.build_screen(screen_name) self.generator.write_screen(screen_name, self._write_directory) @@ -210,41 +218,39 @@ def create_screens(self): for component_name, component in self.conf.components.items(): screen_entities: list[Entity] = [] - # ONLY IF there is a matching component and entity, generate a screen - if component.prefix in self.entities.keys(): - # Populate child labels for any entities - # with the same prefix as the component - for entity in self.entities[component.prefix]: - entity.child_labels = component.child_labels - - screen_entities.extend(self.entities[component.prefix]) - - if component.extras is not None: - # If component has any extras, add them to the entries to generate - for extra_p in component.extras: - if extra_p not in self.entities.keys(): - logger_.error( - f"Extra prefix {extra_p} for {component_name} does not" - " exist." - ) - continue - screen_entities.extend(self.entities[extra_p]) - - # This is used by both generate and validate, - # so called beforehand for tidiness - self.generator.build_widgets(component_name, screen_entities) - self.generator.build_groups(component_name, self.conf.components) - - screens_to_validate = list(self.validator.validate.keys()) - - if component_name in screens_to_validate: - self._validate_screen(component_name) - else: - self._generate_screen(component_name) - - else: + matched = self.get_entities_for_prefix(component.prefix) + if not matched: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " - f"set in the component [bold]{component_name}[/bold] does not match" - " any P field in the ioc.yaml files in services" + f"{self.techui.name}: prefix [bold]{component.prefix}[/bold] " + f"for component [bold]{component_name}[/bold] matched no entities." ) + continue + + # Populate child labels for any entities + for entity in matched: + entity.child_labels = component.child_labels + + screen_entities.extend(matched) + + if component.extras is not None: + for extra_p in component.extras: + extra_matched = self.get_entities_for_prefix(extra_p) + if not extra_matched: + logger_.error( + f"Extra prefix {extra_p} for {component_name} matched no" + " entities." + ) + continue + screen_entities.extend(extra_matched) + + # This is used by both generate and validate, + # so called beforehand for tidiness + self.generator.build_widgets(component_name, screen_entities) + self.generator.build_groups(component_name, self.conf.components) + + screens_to_validate = list(self.validator.validate.keys()) + + if component_name in screens_to_validate: + self._validate_screen(component_name) + else: + self._generate_screen(component_name) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 9b5dc131..5895f084 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -307,6 +307,12 @@ class Entity(BaseModel): Field(description="Macros for the matching screen (can be empty)"), ] + @computed_field(repr=False, return_type=str) + @property + def base_prefix(self) -> str: + """PV prefix, excluding any colons or suffix, e.g. 'BL01T-MO-MOTOR-01'.""" + return self.prefix.split(":", maxsplit=1)[0] + class SupportEntityScreen(BaseModel): """One screen entry for a support module.""" diff --git a/tests/test_builder.py b/tests/test_builder.py index bc9766f1..f105ab3f 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -79,25 +79,22 @@ def test_missing_service(builder, caplog: pytest.LogCaptureFixture): @pytest.mark.parametrize( - "index, type, desc, pv, macros", + "type, desc, pv, macros", [ - (0, "pmac.GeoBrick", None, "BL01T-MO-BRICK-01", {"P": "BL01T-MO-BRICK-01"}), + ("pmac.GeoBrick", None, "BL01T-MO-BRICK-01", {"P": "BL01T-MO-BRICK-01"}), ( - 0, "pmac.autohome", None, "BL01T-MO-MOTOR-01", {"P": "BL01T-MO-MOTOR-01"}, ), ( - 1, "pmac.dls_pmac_asyn_motor", None, "BL01T-MO-MOTOR-01:X", {"P": "BL01T-MO-MOTOR-01", "M": ":X"}, ), ( - 2, "pmac.dls_pmac_asyn_motor", None, "BL01T-MO-MOTOR-01:A", @@ -105,19 +102,16 @@ def test_missing_service(builder, caplog: pytest.LogCaptureFixture): ), ], ) -def test_gb_extract_entities_ioc_yaml( - builder, techui_support, index, type, desc, pv, macros -): +def test_gb_extract_entities_ioc_yaml(builder, techui_support, type, desc, pv, macros): # We don't want to use builder_with_setup as that calls _extract_services() # and in turn that calls _extract_entities() builder.techui_support = techui_support - prefix = pv.split(":", maxsplit=1)[0] builder._extract_entities( "bl01t-mo-motor-01", builder._services_dir / "bl01t-mo-motor-01/config/ioc.yaml", ) - entity = builder.entities[prefix][index] + entity = builder.entities[pv][0] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv @@ -125,10 +119,9 @@ def test_gb_extract_entities_ioc_yaml( @pytest.mark.parametrize( - "index, type, desc, pv, macros", + "type, desc, pv, macros", [ ( - 0, "fastcs.TemperatureController", None, "BL01T-EA-TEMP-01", @@ -137,19 +130,17 @@ def test_gb_extract_entities_ioc_yaml( ], ) def test_gb_extract_entities_fastcs_yaml( - builder, techui_support, index, type, desc, pv, macros + builder, techui_support, type, desc, pv, macros ): # We don't want to use builder_with_setup as that calls _extract_services() # and in turn that calls _extract_entities() builder.techui_support = techui_support - prefix = pv.split(":", maxsplit=1)[0] - builder._extract_entities( "bl01t-ea-temp-01", builder._services_dir / "bl01t-ea-temp-01/config/fastcs.yaml", ) - entity = builder.entities[prefix][index] + entity = builder.entities[pv][0] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv From 8a94f1eadc0a4b4eeb0f1c7cbfecf5cf147812a4 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 29 Sep 2026 14:57:40 +0000 Subject: [PATCH 4/5] Added tests --- tests/conftest.py | 34 +++++++++++++++++++++++++++++++++ tests/test_builder.py | 44 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 78fbfb5f..a069459b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,6 +131,40 @@ def builder_with_setup(builder: Builder, techui_support): return builder +@pytest.fixture +def builder_with_entities(builder: Builder): + """Builder with a pre-populated entity registry for testing lookups.""" + from techui_builder.models import Entity + + builder.entities = { + "BL01T-MO-DCM-01": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.GeoBrick", + prefix="BL01T-MO-DCM-01", + macros={"P": "BL01T-MO-DCM-01"}, + ), + "BL01T-MO-DCM-01:BRAGG": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.dls_pmac_asyn_motor", + prefix="BL01T-MO-DCM-01:BRAGG", + macros={"P": "BL01T-MO-DCM-01", "M": ":BRAGG"}, + ), + "BL01T-MO-DCM-01:GAP": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.dls_pmac_asyn_motor", + prefix="BL01T-MO-DCM-01:GAP", + macros={"P": "BL01T-MO-DCM-01", "M": ":GAP"}, + ), + "BL01T-MO-STEP-13": Entity( + service_name="bl01t-mo-step-13", + type="pmac.GeoBrick", + prefix="BL01T-MO-STEP-13", + macros={"P": "BL01T-MO-STEP-13"}, + ), + } + return builder + + @pytest.fixture def builder_with_test_files(builder: Builder, tmp_test_files): builder._write_directory = tmp_test_files diff --git a/tests/test_builder.py b/tests/test_builder.py index f105ab3f..02a9d41f 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -111,7 +111,7 @@ def test_gb_extract_entities_ioc_yaml(builder, techui_support, type, desc, pv, m "bl01t-mo-motor-01", builder._services_dir / "bl01t-mo-motor-01/config/ioc.yaml", ) - entity = builder.entities[pv][0] + entity = builder.entities[pv] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv @@ -140,7 +140,7 @@ def test_gb_extract_entities_fastcs_yaml( "bl01t-ea-temp-01", builder._services_dir / "bl01t-ea-temp-01/config/fastcs.yaml", ) - entity = builder.entities[pv][0] + entity = builder.entities[pv] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv @@ -257,3 +257,43 @@ def test_create_screens_extra_p_does_not_exist( for log_output in caplog.records: assert "Extra prefix BAD-PV" in log_output.message + + +@pytest.mark.parametrize( + "prefix", + [ + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-STEP-13", + ], +) +def test_get_entities_for_prefix_single(builder_with_entities, prefix): + """Single PV lookup returns exactly one entity.""" + result = builder_with_entities.get_entities_for_prefix(prefix) + assert {r.prefix for r in result} == {prefix} + + +def test_get_entities_for_prefix_root_with_children(builder_with_entities): + """Root prefix lookup collects all matching children.""" + result = builder_with_entities.get_entities_for_prefix("BL01T-MO-DCM-01") + expected = { + "BL01T-MO-DCM-01", + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-DCM-01:GAP", + } + assert {r.prefix for r in result} == expected + + +def test_get_entities_for_prefix_no_match(builder_with_entities): + """Lookup of a non-existent prefix returns empty list.""" + result = builder_with_entities.get_entities_for_prefix("BL01T-NONEXISTENT-01") + assert result == [] + + +def test_get_entities_for_prefix_root_without_root_entity(builder_with_entities): + """Root prefix lookup collects children even when no root entity exists.""" + del builder_with_entities.entities["BL01T-MO-DCM-01"] + result = builder_with_entities.get_entities_for_prefix("BL01T-MO-DCM-01") + assert {r.prefix for r in result} == { + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-DCM-01:GAP", + } From 5f7aa9162bed2fa4217a4510982d98fa5687fc5b Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 2 Oct 2026 13:55:47 +0100 Subject: [PATCH 5/5] Refactor label check in generate.py Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/techui_builder/generate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index fbb3f456..42277c05 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -217,7 +217,7 @@ def _allocate_widget( component.macros[macro_key] = macro_val # If no child label was specified... - if not self.label_flag and "label" not in component.macros: + if not self.label_flag: # Prefer an explicit "label" or "suffix" key in screen_macros, # otherwise fall back to the first value (by insertion order). fallback_keys = ("label", "suffix")