diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py index 8888c20b..4c672555 100644 --- a/capellambse_context_diagrams/_elkjs.py +++ b/capellambse_context_diagrams/_elkjs.py @@ -110,6 +110,16 @@ class ELKInputData(BaseELKModel): default_factory=list ) + def get_children_by_id(self, search_id: str) -> ELKInputChild | None: + """Recursively search for a child with the given id.""" + for child in self.children: + if child.id == search_id: + return child + + if found_child := child.get_children_by_id(search_id): + return found_child + return None + class ELKInputChild(ELKInputData): """Children of either `ELKInputData` or `ELKInputChild`.""" diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index 5f320c6e..57602a88 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -33,8 +33,6 @@ cabc.Iterable[common.GenericElement], ] -STYLECLASS_PREFIX = "__Derived" - class ContextProcessor: def __init__( @@ -52,7 +50,7 @@ def __init__( self.made_boxes = {self.centerbox.id: self.centerbox} self.boxes_to_delete = {self.centerbox.id} self.edges: list[fa.AbstractExchange] = [] - if self.diagram.display_parent_relation: + if self.diagram._display_parent_relation: self.diagram_target_owners = generic.get_all_owners( self.diagram.target ) @@ -60,13 +58,13 @@ def __init__( def process_context(self): if ( - self.diagram.display_parent_relation + self.diagram._display_parent_relation and getattr(self.diagram.target, "owner", None) is not None and not isinstance(self.diagram.target.owner, generic.PackageTypes) ): box = self._make_box( self.diagram.target.owner, - no_symbol=self.diagram.display_symbols_as_boxes, + no_symbol=self.diagram._display_symbols_as_boxes, layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) box.children = [self.centerbox] @@ -78,7 +76,7 @@ def process_context(self): } self._process_ports(stack_heights) - if self.diagram.display_parent_relation and self.diagram.target.owner: + if self.diagram._display_parent_relation and self.diagram.target.owner: current = self.diagram.target.owner while ( current @@ -96,7 +94,7 @@ def process_context(self): del self.global_boxes[uuid] self.data.children.extend(self.global_boxes.values()) - if self.diagram.display_parent_relation: + if self.diagram._display_parent_relation: owner_boxes: dict[str, _elkjs.ELKInputChild] = { uuid: box for uuid, box in self.made_boxes.items() @@ -124,7 +122,7 @@ def _process_exchanges(self) -> tuple[ if is_hierarchical := exchanges.is_hierarchical( ex, self.centerbox ): - if not self.diagram.display_parent_relation: + if not self.diagram._display_parent_relation: continue self.centerbox.labels[0].layoutOptions = ( makers.DEFAULT_LABEL_LAYOUT_OPTIONS @@ -167,11 +165,11 @@ def _process_ports(self, stack_heights: dict[str, float | int]) -> None: box = self._make_box( port, height=height, - no_symbol=self.diagram.display_symbols_as_boxes, + no_symbol=self.diagram._display_symbols_as_boxes, ) box.ports = [makers.make_port(j.uuid) for j in local_ports] - if self.diagram.display_parent_relation: + if self.diagram._display_parent_relation: current = port while ( current @@ -205,7 +203,7 @@ def _make_owner_box( if not (parent_box := self.global_boxes.get(obj.owner.uuid)): parent_box = self._make_box( obj.owner, - no_symbol=diagram.display_symbols_as_boxes, + no_symbol=diagram._display_symbols_as_boxes, layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) assert (obj_box := self.global_boxes.get(obj.uuid)) @@ -227,7 +225,7 @@ def collector( processor = ContextProcessor(diagram, data, params=params) processor.process_context() derivator = DERIVATORS.get(type(diagram.target)) - if diagram.display_derived_interfaces and derivator is not None: + if diagram._display_derived_interfaces and derivator is not None: derivator( diagram, data, @@ -403,13 +401,13 @@ def derive_from_functions( for i, (uuid, derived_component) in enumerate(components.items(), 1): box = makers.make_box( derived_component, - no_symbol=diagram.display_symbols_as_boxes, + no_symbol=diagram._display_symbols_as_boxes, ) class_ = type(derived_comp).__name__ - box.id = f"{STYLECLASS_PREFIX}-{class_}:{uuid}" + box.id = f"{makers.STYLECLASS_PREFIX}-{class_}:{uuid}" data.children.append(box) - source_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{i}" - target_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{-i}" + source_id = f"{makers.STYLECLASS_PREFIX}-CP_INOUT:{i}" + target_id = f"{makers.STYLECLASS_PREFIX}-CP_INOUT:{-i}" box.ports.append(makers.make_port(source_id)) centerbox.ports.append(makers.make_port(target_id)) if i % 2 == 0: @@ -417,7 +415,7 @@ def derive_from_functions( data.edges.append( _elkjs.ELKInputEdge( - id=f"{STYLECLASS_PREFIX}-ComponentExchange:{i}", + id=f"{makers.STYLECLASS_PREFIX}-ComponentExchange:{i}", sources=[source_id], targets=[target_id], ) diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py index a3910685..a97d9e5c 100644 --- a/capellambse_context_diagrams/collectors/exchanges.py +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -24,20 +24,20 @@ class ExchangeCollector(metaclass=abc.ABCMeta): intermap: dict[str, DT] = { DT.OAB: ("source", "target", "allocated_interactions", "activities"), DT.SAB: ( - "source.parent", - "target.parent", + "source.owner", + "target.owner", "allocated_functional_exchanges", "allocated_functions", ), DT.LAB: ( - "source.parent", - "target.parent", + "source.owner", + "target.owner", "allocated_functional_exchanges", "allocated_functions", ), DT.PAB: ( - "source.parent", - "target.parent", + "source.owner", + "target.owner", "allocated_functional_exchanges", "allocated_functions", ), @@ -62,104 +62,14 @@ def __init__( self.get_alloc_fex = operator.attrgetter(alloc_fex) self.get_alloc_functions = operator.attrgetter(fncs) - def get_functions_and_exchanges( - self, comp: common.GenericElement, interface: common.GenericElement - ) -> tuple[ - list[common.GenericElement], - dict[str, common.GenericElement], - dict[str, common.GenericElement], - ]: - """Return `Function`s, incoming and outgoing - `FunctionalExchange`s for given `Component` and `interface`. - """ - functions, incomings, outgoings = [], {}, {} - alloc_functions = self.get_alloc_functions(comp) - for fex in self.get_alloc_fex(interface): - source = self.get_source(fex) - if source in alloc_functions: - if fex.uuid not in outgoings: - outgoings[fex.uuid] = fex - if source not in functions: - functions.append(source) - - target = self.get_target(fex) - if target in alloc_functions: - if fex.uuid not in incomings: - incomings[fex.uuid] = fex - if target not in functions: - functions.append(target) - - return functions, incomings, outgoings - - def collect_context( - self, comp: common.GenericElement, interface: common.GenericElement - ) -> tuple[ - dict[str, t.Any], - dict[str, common.GenericElement], - dict[str, common.GenericElement], - ]: - functions, incomings, outgoings = self.get_functions_and_exchanges( - comp, interface - ) - components = [] - for cmp in comp.components: - fncs, _, _ = self.get_functions_and_exchanges(cmp, interface) - functions.extend(fncs) - if fncs: - c, incs, outs = self.collect_context(cmp, interface) - components.append(c) - incomings |= incs - outgoings |= outs - - start = { - "element": comp, - "functions": functions, - "components": components, - } - if self.diagram.hide_functions: - start["functions"] = [] - incomings = {} - outgoings = {} - - return start, incomings, outgoings - + @abc.abstractmethod def make_ports_and_update_children_size( self, data: _elkjs.ELKInputChild, exchanges: t.Sequence[_elkjs.ELKInputEdge], ) -> None: - """Adjust size of functions and make ports.""" - stack_height: int | float = -makers.NEIGHBOR_VMARGIN - for child in data.children: - inputs, outputs = [], [] - obj = self.obj._model.by_uuid(child.id) - if isinstance(obj, cs.Component): - self.make_ports_and_update_children_size(child, exchanges) - return - port_ids = {p.uuid for p in obj.inputs + obj.outputs} - for ex in exchanges: - source, target = ex.sources[0], ex.targets[0] - if source in port_ids: - outputs.append(source) - elif target in port_ids: - inputs.append(target) - - if generic.DIAGRAM_TYPE_TO_CONNECTOR_NAMES[self.diagram.type]: - child.ports = [ - makers.make_port(i) for i in set(inputs + outputs) - ] - - childnum = max(len(inputs), len(outputs)) - height = max( - child.height + 2 * makers.LABEL_VPAD, - makers.PORT_PADDING - + (makers.PORT_SIZE + makers.PORT_PADDING) * childnum, - ) - child.height = height - stack_height += makers.NEIGHBOR_VMARGIN + height - - if stack_height > 0: - data.height = stack_height + """Populate the elkdata container.""" + raise NotImplementedError @abc.abstractmethod def collect(self) -> None: @@ -203,15 +113,26 @@ def __init__( data: _elkjs.ELKInputData, params: dict[str, t.Any], ) -> None: - self.left = None - self.right = None self.incoming_edges = {} self.outgoing_edges = {} super().__init__(diagram, data, params) + self._functional_exchanges: common.ElementList[ + common.GenericElement + ] = self.get_alloc_fex(diagram.target) + self._derived_functional_exchanges: dict[ + str, common.GenericElement + ] = {} + self._ex_validity: dict[ + str, dict[str, common.GenericElement | None] + ] = { + fex.uuid: {"source": None, "target": None} + for fex in self._functional_exchanges + } + self.get_left_and_right() - if diagram.include_interface: + if diagram._include_interface: self.add_interface() def get_left_and_right(self) -> None: @@ -226,7 +147,7 @@ def get_capella_order( def make_boxes(cntxt: dict[str, t.Any]) -> _elkjs.ELKInputChild | None: comp = cntxt["element"] functions = cntxt["functions"] - if self.diagram.hide_functions: + if self.diagram._hide_functions: functions = [] components = cntxt["components"] @@ -254,13 +175,15 @@ def make_boxes(cntxt: dict[str, t.Any]) -> _elkjs.ELKInputChild | None: try: comp = self.get_source(self.obj) - left_context, incs, outs = self.collect_context(comp, self.obj) + left_context, incs, outs = self.collect_context(comp) + _comp = self.get_target(self.obj) + right_context, _, _ = self.collect_context(_comp) + self.remove_dangling_functional_exchanges(incs, outs) + inc_port_ids = set(ex.target.uuid for ex in incs.values()) out_port_ids = set(ex.source.uuid for ex in outs.values()) port_spread = len(out_port_ids) - len(inc_port_ids) - _comp = self.get_target(self.obj) - right_context, _, _ = self.collect_context(_comp, self.obj) _inc_port_ids = set(ex.target.uuid for ex in outs.values()) _out_port_ids = set(ex.source.uuid for ex in incs.values()) _port_spread = len(_out_port_ids) - len(_inc_port_ids) @@ -288,6 +211,99 @@ def make_boxes(cntxt: dict[str, t.Any]) -> _elkjs.ELKInputChild | None: except AttributeError as error: logger.exception("Interface collection failed: \n%r", str(error)) + def collect_context(self, comp: common.GenericElement) -> tuple[ + dict[str, t.Any], + dict[str, common.GenericElement], + dict[str, common.GenericElement], + ]: + functions, incomings, outgoings = self.get_functions_and_exchanges( + comp + ) + components = [] + for cmp in comp.components: + fncs, _, _ = self.get_functions_and_exchanges(cmp) + functions.extend(fncs) + if fncs: + c, incs, outs = self.collect_context(cmp) + components.append(c) + incomings |= incs + outgoings |= outs + + start = { + "element": comp, + "functions": functions, + "components": components, + } + if self.diagram._hide_functions: + start["functions"] = [] + incomings = {} + outgoings = {} + + return start, incomings, outgoings + + def get_functions_and_exchanges( + self, comp: common.GenericElement + ) -> tuple[ + list[common.GenericElement], + dict[str, common.GenericElement], + dict[str, common.GenericElement], + ]: + """Return `Function`s, incoming and outgoing + `FunctionalExchange`s for given `Component` and `interface`. + """ + functions, incomings, outgoings = [], {}, {} + alloc_functions = self.get_alloc_functions(comp) + fexes = alloc_functions.map("inputs.exchanges") + alloc_functions.map( + "outputs.exchanges" + ) + ex_template = {"source": None, "target": None} + for fex in fexes: + if fex not in self._functional_exchanges: + # fex not allocated to interface + if not self.diagram._display_derived_exchanges: + continue + + self._derived_functional_exchanges[fex.uuid] = fex + + if (source := self.get_source(fex)) in alloc_functions: + self._ex_validity.setdefault(fex.uuid, ex_template.copy())[ + "source" + ] = source + outgoings[fex.uuid] = fex + if source not in functions: + functions.append(source) + + if (target := self.get_target(fex)) in alloc_functions: + self._ex_validity.setdefault(fex.uuid, ex_template.copy())[ + "target" + ] = target + incomings[fex.uuid] = fex + if target not in functions: + functions.append(target) + + return functions, incomings, outgoings + + def remove_dangling_functional_exchanges( + self, + incoming: dict[str, common.GenericElement], + outgoing: dict[str, common.GenericElement], + ) -> tuple[ + dict[str, common.GenericElement], dict[str, common.GenericElement] + ]: + for uuid in self._ex_validity: + if not self.found_source_and_target(uuid): + incoming.pop(uuid, None) + outgoing.pop(uuid, None) + + fex = self.obj._model.by_uuid(uuid) + self.diagram.dangling_functional_exchanges.append(fex) + + return incoming, outgoing + + def found_source_and_target(self, uuid: str) -> bool: + fex = self._ex_validity[uuid] + return None not in (fex["source"], fex["target"]) + def add_interface(self) -> None: ex_data = generic.ExchangeData( self.obj, @@ -298,10 +314,6 @@ def add_interface(self) -> None: ) src, tgt = generic.exchange_data_collector(ex_data) assert self.right is not None - if self.get_source(self.obj).uuid == self.right.id: - self.data.edges[-1].sources = [tgt.uuid] - self.data.edges[-1].targets = [src.uuid] - assert self.left is not None self.left.ports.append(makers.make_port(src.uuid)) self.right.ports.append(makers.make_port(tgt.uuid)) @@ -318,6 +330,11 @@ def collect(self) -> None: is_hierarchical=False, ) src, tgt = generic.exchange_data_collector(ex_data) + if ex.uuid in self._derived_functional_exchanges: + class_ = type(ex).__name__ + self.data.edges[-1].id = ( + f"{makers.STYLECLASS_PREFIX}-{class_}:{ex.uuid}" + ) if ex in self.incoming_edges.values(): self.data.edges[-1].sources = [tgt.uuid] @@ -331,7 +348,69 @@ def collect(self) -> None: except AttributeError: pass + def make_ports_and_update_children_size( + self, + data: _elkjs.ELKInputChild, + exchanges: t.Sequence[_elkjs.ELKInputEdge], + ) -> None: + """Adjust size of functions and make ports.""" + stack_height: int | float = -makers.NEIGHBOR_VMARGIN + to_remove: list[_elkjs.ELKInputChild] = [] + for child in data.children: + inputs, outputs = [], [] + obj = self.obj._model.by_uuid(child.id) + if isinstance(obj, cs.Component): + self.make_ports_and_update_children_size(child, exchanges) + return + + port_ids = {p.uuid for p in obj.inputs + obj.outputs} + for ex in exchanges: + source, target = ex.sources[0], ex.targets[0] + if source in port_ids: + outputs.append(source) + elif target in port_ids: + inputs.append(target) + + if generic.DIAGRAM_TYPE_TO_CONNECTOR_NAMES[self.diagram.type]: + child.ports = [ + makers.make_port(i) for i in set(inputs + outputs) + ] + if not inputs + outputs: + to_remove.append(child) + + childnum = max(len(inputs), len(outputs)) + height = max( + child.height + 2 * makers.LABEL_VPAD, + makers.PORT_PADDING + + (makers.PORT_SIZE + makers.PORT_PADDING) * childnum, + ) + child.height = height + stack_height += makers.NEIGHBOR_VMARGIN + height + + if stack_height > 0: + data.height = stack_height + + for child in to_remove: + data.children.remove(child) + + if not data.children: + try: + parent_obj = self.obj._model.by_uuid(data.id) + assert isinstance(parent_obj, cs.Component) + assert parent_obj not in (self.left, self.right) + assert parent_obj.owner is not None + parent_owner = self.data.get_children_by_id( + parent_obj.owner.uuid + ) + assert parent_owner is not None + parent_owner.children.remove(data) + except (KeyError, AssertionError): + pass + + +# pylint: disable +@t.no_type_check class FunctionalContextCollector(ExchangeCollector): def __init__( self, @@ -352,8 +431,8 @@ def collect(self) -> None: else: comp = self.get_source(interface) - functions, inc, outs = self.get_functions_and_exchanges( - self.obj, interface + functions, inc, outs = self.get_functions_and_exchanges( # type: ignore + interface ) if comp.uuid not in made_children: children = [makers.make_box(c) for c in functions] @@ -385,6 +464,17 @@ def collect(self) -> None: ) ) + def get_functions_and_exchanges( # type: ignore + self, _: common.GenericElement + ) -> tuple[ + list[common.GenericElement], + dict[str, common.GenericElement], + dict[str, common.GenericElement], + ]: ... + + +# pylint: enable + def is_hierarchical( ex: common.GenericElement, diff --git a/capellambse_context_diagrams/collectors/generic.py b/capellambse_context_diagrams/collectors/generic.py index 4e935d64..dc98581e 100644 --- a/capellambse_context_diagrams/collectors/generic.py +++ b/capellambse_context_diagrams/collectors/generic.py @@ -64,7 +64,7 @@ def collector( diagram.target, width=width, no_symbol=no_symbol, - slim_width=diagram.slim_center_box, + slim_width=diagram._slim_center_box, ) ] return data diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py index 258a7c23..d05184a3 100644 --- a/capellambse_context_diagrams/collectors/makers.py +++ b/capellambse_context_diagrams/collectors/makers.py @@ -70,6 +70,8 @@ } """Layout options for a symbol label.""" +STYLECLASS_PREFIX = "__Derived" + def make_diagram(diagram: context.ContextDiagram) -> _elkjs.ELKInputData: """Return basic skeleton for ``ContextDiagram``s.""" diff --git a/capellambse_context_diagrams/collectors/portless.py b/capellambse_context_diagrams/collectors/portless.py index ab876676..7e06b857 100644 --- a/capellambse_context_diagrams/collectors/portless.py +++ b/capellambse_context_diagrams/collectors/portless.py @@ -44,10 +44,10 @@ def collector( contexts = context_collector(connections, diagram.target) global_boxes = {centerbox.id: centerbox} made_boxes = {centerbox.id: centerbox} - if diagram.display_parent_relation and diagram.target.owner is not None: + if diagram._display_parent_relation and diagram.target.owner is not None: box = makers.make_box( diagram.target.owner, - no_symbol=diagram.display_symbols_as_boxes, + no_symbol=diagram._display_symbols_as_boxes, layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) box.children = [centerbox] @@ -63,7 +63,7 @@ def collector( var_height = generic.MARKER_PADDING + ( generic.MARKER_SIZE + generic.MARKER_PADDING ) * len(exchanges) - if not diagram.display_symbols_as_boxes and makers.is_symbol( + if not diagram._display_symbols_as_boxes and makers.is_symbol( diagram.target ): height = makers.MIN_SYMBOL_HEIGHT + var_height @@ -78,16 +78,16 @@ def collector( box = makers.make_box( i, height=height, - no_symbol=diagram.display_symbols_as_boxes, + no_symbol=diagram._display_symbols_as_boxes, ) global_boxes[i.uuid] = box made_boxes[i.uuid] = box - if diagram.display_parent_relation and i.owner is not None: + if diagram._display_parent_relation and i.owner is not None: if not (parent_box := global_boxes.get(i.owner.uuid)): parent_box = makers.make_box( i.owner, - no_symbol=diagram.display_symbols_as_boxes, + no_symbol=diagram._display_symbols_as_boxes, ) global_boxes[i.owner.uuid] = parent_box made_boxes[i.owner.uuid] = parent_box @@ -101,7 +101,7 @@ def collector( del global_boxes[centerbox.id] data.children.extend(global_boxes.values()) - if diagram.display_parent_relation: + if diagram._display_parent_relation: owner_boxes: dict[str, _elkjs.ELKInputChild] = { uuid: box for uuid, box in made_boxes.items() if box.children } @@ -109,7 +109,7 @@ def collector( generic.move_edges(owner_boxes, connections, data) centerbox.height = max(centerbox.height, *stack_heights.values()) - if not diagram.display_symbols_as_boxes and makers.is_symbol( + if not diagram._display_symbols_as_boxes and makers.is_symbol( diagram.target ): data.layoutOptions["spacing.labelNode"] = 5.0 diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 8661a37f..1ead23df 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -15,6 +15,7 @@ from capellambse import diagram as cdiagram from capellambse import helpers from capellambse.model import common, diagram, modeltypes +from capellambse.model.crosslayer import fa from . import _elkjs, filters, serializers, styling from .collectors import ( @@ -93,7 +94,9 @@ def _get( pass new_diagram = diagram_class( - self._dgcls, obj, **self._default_render_params + self._dgcls, + obj, + default_render_parameters=self._default_render_params, ) new_diagram.filters.add(filters.NO_UUID) cache[diagram_id] = new_diagram @@ -223,21 +226,6 @@ class ContextDiagram(diagram.AbstractDiagram): Dictionary with the `ElkChildType` in str format as keys and `styling.Styler` functions as values. An example is given by: [`styling.BLUE_ACTOR_FNCS`][capellambse_context_diagrams.styling.BLUE_ACTOR_FNCS] - display_symbols_as_boxes - Display objects that are normally displayed as symbol as a - simple box instead, with the symbol being the box' icon. This - avoids the object of interest to become one giant, oversized - symbol in the middle of the diagram, and instead keeps the - symbol small and only enlarges the surrounding box. - display_parent_relation - Display objects with a parent relationship to the object of - interest as the parent box. - display_derived_interfaces - Display derived objects collected from additional collectors - beside the main collector for building the context. - slim_center_box - Minimal width for the center box, containing just the icon and - the label. This is False if hierarchy was identified. serializer The serializer builds a `diagram.Diagram` via [`serializers.DiagramSerializer.make_diagram`][capellambse_context_diagrams.serializers.DiagramSerializer.make_diagram] @@ -248,18 +236,37 @@ class ContextDiagram(diagram.AbstractDiagram): A list of filter names that are applied during collection of context. Currently this is only done in [`collectors.exchange_data_collector`][capellambse_context_diagrams.collectors.generic.exchange_data_collector]. + + Notes + ----- + * display_symbols_as_boxes — Display objects that are normally + displayed as symbol as a simple box instead, with the symbol + being the box' icon. This avoids the object of interest to + become one giant, oversized symbol in the middle of the diagram, + and instead keeps the symbol small and only enlarges the + surrounding box. + * display_parent_relation — Display objects with a parent + relationship to the object of interest as the parent box. + * display_derived_interfaces — Display derived objects collected + from additional collectors beside the main collector for building + the context. + * slim_center_box — Minimal width for the center box, containing + just the icon and the label. This is False if hierarchy was + identified. """ + _display_symbols_as_boxes: bool + _display_parent_relation: bool + _display_derived_interfaces: bool + _slim_center_box: bool + def __init__( self, class_: str, obj: common.GenericElement, *, render_styles: dict[str, styling.Styler] | None = None, - display_symbols_as_boxes: bool = False, - display_parent_relation: bool = False, - display_derived_interfaces: bool = False, - slim_center_box: bool = True, + default_render_parameters: dict[str, t.Any], ) -> None: super().__init__(obj._model) self.target = obj @@ -268,10 +275,12 @@ def __init__( self.render_styles = render_styles or {} self.serializer = serializers.DiagramSerializer(self) self.__filters: cabc.MutableSet[str] = self.FilterSet(self) - self.display_symbols_as_boxes = display_symbols_as_boxes - self.display_parent_relation = display_parent_relation - self.display_derived_interfaces = display_derived_interfaces - self.slim_center_box = slim_center_box + self._default_render_parameters = { + "display_symbols_as_boxes": False, + "display_parent_relation": False, + "display_derived_interfaces": False, + "slim_center_box": True, + } | default_render_parameters if standard_filter := STANDARD_FILTERS.get(class_): self.filters.add(standard_filter) @@ -329,32 +338,15 @@ def __iter__(self) -> cabc.Iterator[str]: def __len__(self) -> int: return self._set.__len__() - def render(self, fmt: str | None, /, **params) -> t.Any: - """Render the diagram in the given format.""" - rparams = params.copy() - for attr, value in params.items(): - attribute = getattr(self, attr, "NOT_FOUND") - if attribute not in {"NOT_FOUND", value}: - self.invalidate_cache() - - setattr(self, attr, value) - del rparams[attr] - return super().render(fmt, **rparams) - def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: + params = self._default_render_parameters | params transparent_background = params.pop("transparent_background", False) - for param_name in [ - "display_parent_relation", - "display_derived_interfaces", - "display_symbols_as_boxes", - "slim_center_box", - ]: - if (override := params.pop(param_name, None)) is not None: - setattr(self, param_name, override) + for param_name in self._default_render_parameters: + setattr(self, f"_{param_name}", params.pop(param_name)) data = params.get("elkdata") or get_elkdata(self, params) if has_single_child(data): - self.display_derived_interfaces = True + self._display_derived_interfaces = True data = get_elkdata(self, params) layout = try_to_layout(data) @@ -377,31 +369,68 @@ def filters(self, value: cabc.Iterable[str]) -> None: class InterfaceContextDiagram(ContextDiagram): """An automatically generated Context Diagram exclusively for ``ComponentExchange``s. + + Attributes + ---------- + dangling_functional_exchanges: list[fa.AbstractExchange] + A list of ``dangling`` functional exchanges for which either the + source or target function were not allocated to a Component, + part of the context. + + Notes + ----- + The following render parameters are available: + + * include_interface — Boolean flag to enable inclusion of the + context diagram target: The interface ComponentExchange. + * hide_functions — Boolean flag to enable white box view: Only + displaying Components or Entities. + * display_derived_exchanges — Boolean flag to enable inclusion of + functional exchanges that are not allocated to the interface but + connect allocated functions of collected components. + + In addition to all other render parameters of + [`ContextDiagram`][capellambse_context_diagrams.context.ContextDiagram]. """ + _include_interface: bool + _hide_functions: bool + _display_derived_exchanges: bool + def __init__( self, class_: str, obj: common.GenericElement, - include_interface: bool = False, - hide_functions: bool = False, - **kw, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], ) -> None: - self.include_interface = include_interface - self.hide_functions = hide_functions - super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) + default_render_parameters = { + "include_interface": False, + "hide_functions": False, + "display_derived_exchanges": False, + "display_symbols_as_boxes": True, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) + + self.dangling_functional_exchanges: list[fa.AbstractExchange] = [] @property def name(self) -> str: # type: ignore return f"Interface Context of {self.target.name}" def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: - for param_name in ("include_interface", "hide_functions"): - if override := params.pop(param_name, False): - setattr(self, param_name, override) + params = self._default_render_parameters | params + for param_name in self._default_render_parameters: + setattr(self, f"_{param_name}", params.pop(param_name)) - if self.hide_functions: - self.include_interface = True + if self._hide_functions: + self._include_interface = True params["elkdata"] = exchanges.get_elkdata_for_exchanges( self, exchanges.InterfaceContextCollector, params @@ -420,7 +449,7 @@ def name(self) -> str: # type: ignore def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: params["elkdata"] = exchanges.get_elkdata_for_exchanges( - self, exchanges.FunctionalContextCollector, params + self, exchanges.FunctionalContextCollector, params # type: ignore ) return super()._create_diagram(params) @@ -431,8 +460,25 @@ class ClassTreeDiagram(ContextDiagram): This diagram is exclusively for ``Class``es. """ - def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None: - super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) + _display_symbols_as_boxes: bool + + def __init__( + self, + class_: str, + obj: common.GenericElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "display_symbols_as_boxes": True, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) @property def uuid(self) -> str: # type: ignore @@ -445,9 +491,16 @@ def name(self) -> str: # type: ignore return f"Tree view of {self.target.name}" def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: - params.setdefault("algorithm", params.get("algorithm", "layered")) + params = { + **self._default_render_parameters, + "algorithm": "layered", + "edgeRouting": "POLYLINE", + **params, + } + for param_name in self._default_render_parameters: + setattr(self, f"_{param_name}", params.pop(param_name)) + params.setdefault("elk.direction", params.pop("direction", "DOWN")) - params.setdefault("edgeRouting", params.get("edgeRouting", "POLYLINE")) params.setdefault( "nodeSize.constraints", params.pop("nodeSizeConstraints", "NODE_LABELS"), @@ -526,8 +579,25 @@ class RealizationViewDiagram(ContextDiagram): ``Entity`` and ``Components`` of all layers. """ - def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None: - super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) + _display_symbols_as_boxes: bool + + def __init__( + self, + class_: str, + obj: common.GenericElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "display_symbols_as_boxes": True, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) @property def uuid(self) -> str: # type: ignore @@ -540,13 +610,19 @@ def name(self) -> str: # type: ignore return f"Realization view of {self.target.name}" def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: - params.setdefault("depth", params.get("depth", 1)) - params.setdefault( - "search_direction", params.get("search_direction", "ALL") - ) - params.setdefault("show_owners", True) - params.setdefault("layer_sizing", "WIDTH") + params = { + **self._default_render_parameters, + "depth": 1, + "search_direction": "ALL", + "show_owners": True, + "layer_sizing": "WIDTH", + **params, + } + for param_name in self._default_render_parameters: + setattr(self, f"_{param_name}", params.pop(param_name)) + data, edges = realization_view.collector(self, params) + layout = try_to_layout(data) adjust_layer_sizing(data, layout, params["layer_sizing"]) layout = try_to_layout(data) @@ -595,8 +671,25 @@ def _add_layer_labels(self, layout: _elkjs.ELKOutputData) -> None: class DataFlowViewDiagram(ContextDiagram): """An automatically generated DataFlowViewDiagram.""" - def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None: - super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) + _display_symbols_as_boxes: bool + + def __init__( + self, + class_: str, + obj: common.GenericElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "display_symbols_as_boxes": True, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) @property def uuid(self) -> str: # type: ignore diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index a5f9c806..fb3b571c 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -61,6 +61,7 @@ def __init__(self, elk_diagram: context.ContextDiagram) -> None: self._diagram = elk_diagram self._cache: dict[str, diagram.Box | diagram.Edge] = {} self._edges: dict[str, EdgeContext] = {} + self._junctions: dict[str, EdgeContext] = {} def make_diagram( self, @@ -91,6 +92,9 @@ def make_diagram( for edge, ref, parent in self._edges.values(): self.deserialize_child(edge, ref, parent) + for junction, ref, parent in self._junctions.values(): + self.deserialize_child(junction, ref, parent) + self.diagram.calculate_viewport() self.order_children() self._edges.clear() @@ -148,7 +152,7 @@ class type that stores all previously named classes. is_port or has_symbol_cls and not self._diagram.target.uuid == uuid - and not self._diagram.display_symbols_as_boxes + and not self._diagram._display_symbols_as_boxes ] assert not isinstance( @@ -242,7 +246,7 @@ class type that stores all previously named classes. element = parent elif child.type == "junction": - uuid = child.id.rsplit("_", maxsplit=1)[0] + uuid = uuid.rsplit("_", maxsplit=1)[0] pos = diagram.Vector2D(child.position.x, child.position.y) if self._is_hierarchical(uuid): # FIXME should this use `parent` instead? @@ -257,6 +261,7 @@ class type that stores all previously named classes. context=getattr(child, "context", {}), ) self.diagram.add_element(element) + self._cache[uuid] = element else: logger.warning("Received unknown type %s", child.type) return @@ -264,6 +269,8 @@ class type that stores all previously named classes. for i in getattr(child, "children", []): if i.type == "edge": self._edges.setdefault(i.id, (i, ref, parent)) + elif i.type == "junction": + self._junctions.setdefault(i.id, (i, ref, parent)) else: self.deserialize_child(i, ref, element) diff --git a/docs/assets/images/Context of Left.svg b/docs/assets/images/Context of Left.svg index 1e6ec85c..5129e11a 100644 --- a/docs/assets/images/Context of Left.svg +++ b/docs/assets/images/Context of Left.svg @@ -3,4 +3,124 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -HogwartsLeftRightUpperLeft to rightUpper to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hogwarts + + + + + + Left + + + + + + Right + + + + + + Upper + + + + + + Left to right + + + + + + Upper to Left + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/Interface Context of Interface.svg b/docs/assets/images/Interface Context of Interface.svg index 20d877c6..6a5d05be 100644 --- a/docs/assets/images/Interface Context of Interface.svg +++ b/docs/assets/images/Interface Context of Interface.svg @@ -3,4 +3,332 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -LFLC 1LFNC 1LA 1LFNC 2LC 20RFNC 1LC 1RFNC 2LC 1RFNC 3InterfaceFE 2FE 3FE 4FE 1 + + + + + + + + + + + + + + + + + + + + LF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LC 1 + + + + + + LFNC 1 + + + + + + LA 1 + + + + + + LFNC 2 + + + + + + LC 20 + + + + + + RFNC 1 Part 1 + + + + + + RFNC 1 Part 2 + + + + + + LC 1 + + + + + + RFNC 2 + + + + + + LC 1 + + + + + + RFNC 3 + + + + + + Interface + + + + + + FE 2 + + + + + + FE 3 + + + + + + FE 6 + + + + + + FE 5 + + + + + + FE 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/extras/styling.md b/docs/extras/styling.md index 8f336d7e..439711be 100644 --- a/docs/extras/styling.md +++ b/docs/extras/styling.md @@ -36,9 +36,9 @@ py-capellambse. # No symbol rendering There are some ModelObjects that are displayed as symbols in a diagram (e.g. -Capabilities or Missions). The `.display_symbols_as_boxes` attribute gives you +Capabilities or Missions). The `.display_symbols_as_boxes` parameter gives you the control to render these as boxes such that the symbol is displayed as an -icon beside the box-label. Per default this attribute is set to `True`. +icon beside the box-label. Per default it is set to `True`. ??? example "Box-only style for Context diagram of Middle OperationalCapability [OCB]" @@ -46,12 +46,11 @@ icon beside the box-label. Per default this attribute is set to `True`. from capellambse import aird diag = model.by_uuid("da08ddb6-92ba-4c3b-956a-017424dbfe85").context_diagram - diag.display_symbols_as_boxes = True # per default - diag.render("svgdiagram").save(pretty=True) + diag.render("svgdiagram", display_symbols_as_boxes=False).save(pretty=True) ``` produces
- +
Context of Middle OperationalCapability [OCB] no-symbols
@@ -61,12 +60,11 @@ icon beside the box-label. Per default this attribute is set to `True`. from capellambse import aird diag = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06").context_diagram - diag.display_symbols_as_boxes = True # per default - diag.render("svgdiagram").save(pretty=True) + diag.render("svgdiagram", display_symbols_as_boxes=False).save(pretty=True) ``` produces
- +
Context of Capability Capability [MCB] no-symbols
diff --git a/docs/gen_images.py b/docs/gen_images.py index 2eb2e2c5..f9338b76 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -33,10 +33,7 @@ } interface_context_diagram_uuids: dict[str, tuple[str, dict[str, t.Any]]] = { "Left to right": ("3ef23099-ce9a-4f7d-812f-935f47e7938d", {}), - "Interface": ( - "2f8ed849-fbda-4902-82ec-cbf8104ae686", - {"include_interface": True}, - ), + "Interface": ("2f8ed849-fbda-4902-82ec-cbf8104ae686", {}), } hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb" diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids @@ -55,13 +52,13 @@ def generate_index_images() -> None: print(diag.render("svg", **render_params), file=fd) # type: ignore[arg-type] -def generate_no_symbol_images() -> None: +def generate_symbol_images() -> None: for name in ("Capability", "Middle"): uuid, _ = general_context_diagram_uuids[name] diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram - diag.display_symbols_as_boxes = True + diag._display_symbols_as_boxes = True diag.invalidate_cache() - filepath = f"{str(dest / diag.name)} no_symbols.svg" + filepath = f"{str(dest / diag.name)} symbols.svg" with mkdocs_gen_files.open(filepath, "w") as fd: print(diag.render("svg", transparent_background=False), file=fd) @@ -187,9 +184,29 @@ def generate_interface_with_hide_functions_image(): print(diag.render("svg", **params), file=fd) +def generate_interface_with_hide_interface_image(): + uuid = interface_context_diagram_uuids["Interface"][0] + diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram + params = {"include_interface": False} + with mkdocs_gen_files.open( + f"{str(dest / diag.name)}-hide-interface.svg", "w" + ) as fd: + print(diag.render("svg", **params), file=fd) + + +def generate_interface_with_display_derived_exchanges_image(): + uuid = "86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28" + diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram + params = {"display_derived_exchanges": True} + with mkdocs_gen_files.open( + f"{str(dest / diag.name)}-derived-exchanges.svg", "w" + ) as fd: + print(diag.render("svg", **params), file=fd) + + generate_index_images() generate_hierarchy_image() -generate_no_symbol_images() +generate_symbol_images() wizard_uuid = general_context_diagram_uuids["educate Wizards"][0] generate_no_edgelabel_image(wizard_uuid) @@ -212,4 +229,6 @@ def generate_interface_with_hide_functions_image(): generate_realization_view_images() generate_data_flow_image() generate_derived_image() -generate_interface_with_hide_functions_image() +# generate_interface_with_hide_functions_image() +generate_interface_with_hide_interface_image() +generate_interface_with_display_derived_exchanges_image() diff --git a/docs/interface.md b/docs/interface.md index dafe286e..da419fa1 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -20,22 +20,22 @@ diag.render("svgdiagram").save(pretty=True)
-
Interface context diagram of Left to right LogicalComponentExchange with type [LAB]
+
Interface context diagram of `Left to right` Logical ComponentExchange with type [LAB]
-## Include the interface itself in the context -??? example "Include the interface in the Interface Context" +## Exclude the interface itself in the context +??? example "Exclude the interface in the Interface Context" ``` py import capellambse model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("fbb7f735-3c1f-48de-9791-179d35ca7b98").context_diagram - diag.render("svgdiagram", include_interface=True).save(pretty=True) + diag.render("svgdiagram", include_interface=False).save(pretty=True) ```
- -
Interface context diagram of Interface LogicalComponentExchange with type [LAB]
+ +
Interface context diagram of `Interface` Logical ComponentExchange with type [LAB]
## Hide functional model elements from the context @@ -50,7 +50,22 @@ diag.render("svgdiagram").save(pretty=True) ```
-
Interface context diagram of Interface LogicalComponentExchange with type [LAB]
+
Interface context diagram of `Interface` Logical ComponentExchange with type [LAB]
-!!! warning "Interface context only supported for the LogicalComponentExchanges" +## Display derived functional exchanges in the context +??? example "Display derived functional exchanges in the Interface Context" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("fbb7f735-3c1f-48de-9791-179d35ca7b98").context_diagram + diag.render("svgdiagram", display_derived_exchanges=True).save(pretty=True) + ``` +
+ +
Interface context diagram of `afflecks` System ComponentExchange with type [SAB]
+
+ +!!! warning "Interface context only supported for System and Logical ComponentExchanges" diff --git a/pyproject.toml b/pyproject.toml index 45f41615..0436e419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -169,6 +169,7 @@ addopts = """ --strict-config --strict-markers --import-mode=importlib + --tb=short """ testpaths = ["tests"] xfail_strict = true diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index 772afcb7..83077280 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -94,7 +94,7 @@ - +
@@ -134,7 +134,7 @@ - +
@@ -150,6 +150,10 @@ + + + + @@ -4603,7 +4607,7 @@ - + @@ -4672,7 +4676,7 @@ - + @@ -4788,7 +4792,7 @@ - + @@ -11967,13 +11971,13 @@ - + - + @@ -11992,7 +11996,7 @@ - + @@ -12003,10 +12007,21 @@ - + + + + + + + + + + + + - + @@ -12054,13 +12069,13 @@ - + - + @@ -12101,27 +12116,49 @@ - - - + + + - - - + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - + + + - - + + - - + + + + + + @@ -12138,7 +12175,7 @@ - + @@ -12165,11 +12202,11 @@ - + - + @@ -12205,22 +12242,6 @@ - - - - - - - - - - - - - - - - @@ -12301,35 +12322,19 @@ - - - - - - - - - - - - - - - - - + - + - + - + @@ -12349,6 +12354,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -12358,7 +12475,7 @@ - + @@ -12427,7 +12544,7 @@ - + @@ -12436,6 +12553,15 @@ + + + + + + + + + @@ -12446,7 +12572,7 @@ - + @@ -12532,20 +12658,42 @@ - + - + - - + + - - + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + italic + @@ -12592,17 +12740,6 @@ - - - - - - - - - - - @@ -12648,15 +12785,6 @@ - - - - - - - - - @@ -12675,6 +12803,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -14761,4 +14958,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + bold + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + bold + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella index 35ebbc06..94637d48 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -3194,6 +3194,8 @@ The predator is far away id="64736cbe-2a66-41af-b300-5174ebeb1da9" name="FOP 1"/> + @@ -3211,6 +3213,16 @@ The predator is far away id="fbfb2b20-b711-4211-9b75-25e38390cdbc" name="RFNC 1"> + + + + + + @@ -3360,6 +3372,12 @@ The predator is far away + + + + @@ -4236,6 +4260,9 @@ The predator is far away + @@ -4247,7 +4274,10 @@ The predator is far away + @@ -4266,6 +4296,12 @@ The predator is far away + + diff --git a/tests/test_interface_diagrams.py b/tests/test_interface_diagrams.py index 986d0406..d8a608d2 100644 --- a/tests/test_interface_diagrams.py +++ b/tests/test_interface_diagrams.py @@ -5,13 +5,14 @@ import pytest TEST_INTERFACE_UUID = "2f8ed849-fbda-4902-82ec-cbf8104ae686" +TEST_SA_INTERFACE_UUID = "86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28" @pytest.mark.parametrize( "uuid", [ # pytest.param("3c9764aa-4981-44ef-8463-87a053016635", id="OA"), - pytest.param("86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28", id="SA"), + pytest.param(TEST_SA_INTERFACE_UUID, id="SA"), pytest.param("3ef23099-ce9a-4f7d-812f-935f47e7938d", id="LA"), ], ) @@ -25,7 +26,7 @@ def test_interface_diagrams_get_rendered( assert diag.nodes -def test_interface_diagrams_with_nested_components( +def test_interface_diagrams_with_nested_components_and_functions( model: capellambse.MelodyModel, ) -> None: obj = model.by_uuid(TEST_INTERFACE_UUID) @@ -40,9 +41,16 @@ def test_interface_diagram_with_included_interface( ) -> None: obj = model.by_uuid(TEST_INTERFACE_UUID) - diag = obj.context_diagram.render(None, include_interface=True) + obj.context_diagram.render("svgdiagram", hide_functions=True).save( + pretty=True + ) + obj.context_diagram.render("svgdiagram", include_interface=False).save( + pretty=True + ) + diag = obj.context_diagram.render(None, include_interface=False) - assert diag[TEST_INTERFACE_UUID] + with pytest.raises(KeyError): + diag[TEST_INTERFACE_UUID] # pylint: disable=pointless-statement def test_interface_diagram_with_hide_functions( @@ -51,9 +59,6 @@ def test_interface_diagram_with_hide_functions( obj = model.by_uuid(TEST_INTERFACE_UUID) diag = obj.context_diagram.render(None, hide_functions=True) - obj.context_diagram.render("svgdiagram", hide_functions=True).save( - pretty=True - ) for uuid in ( "fbfb2b20-b711-4211-9b75-25e38390cdbc", # LogicalFunction @@ -61,3 +66,18 @@ def test_interface_diagram_with_hide_functions( ): with pytest.raises(KeyError): diag[uuid] # pylint: disable=pointless-statement + + +def test_interface_diagram_with_derived_exchanges( + model: capellambse.MelodyModel, +) -> None: + obj = model.by_uuid(TEST_SA_INTERFACE_UUID) + expected_derived_exchanges = ( + "d69dcf31-a7d4-40c5-8dd4-b4747aa3ece7", + "7a61fcb7-aae5-4698-86de-8b0d70d8c09b", + ) + + diag = obj.context_diagram.render(None, display_derived_exchanges=True) + + for uuid in expected_derived_exchanges: + assert diag[f"__Derived-FunctionalExchange:{uuid}"]