From 428971ea7d9ad07ceb253bd94670bc0360f5c362 Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Mon, 2 Mar 2026 21:27:19 +0100 Subject: [PATCH 1/3] added first renderer --- src/archml/cli/main.py | 17 +- src/archml/views/backend/__init__.py | 4 + src/archml/views/backend/diagram.py | 299 +++++++++++++++++++ src/archml/views/diagram.py | 276 ------------------ tests/cli/test_main.py | 2 +- tests/views/backend/__init__.py | 2 + tests/views/backend/test_diagram.py | 300 +++++++++++++++++++ tests/views/test_diagram.py | 413 --------------------------- 8 files changed, 612 insertions(+), 701 deletions(-) create mode 100644 src/archml/views/backend/__init__.py create mode 100644 src/archml/views/backend/diagram.py delete mode 100644 src/archml/views/diagram.py create mode 100644 tests/views/backend/__init__.py create mode 100644 tests/views/backend/test_diagram.py delete mode 100644 tests/views/test_diagram.py diff --git a/src/archml/cli/main.py b/src/archml/cli/main.py index 6ab802d..399c770 100644 --- a/src/archml/cli/main.py +++ b/src/archml/cli/main.py @@ -289,8 +289,10 @@ def _cmd_check(args: argparse.Namespace) -> int: def _cmd_visualize(args: argparse.Namespace) -> int: """Handle the visualize subcommand.""" - from archml.views.diagram import build_diagram_data, render_diagram + from archml.views.backend.diagram import render_diagram + from archml.views.placement import compute_layout from archml.views.resolver import EntityNotFoundError, resolve_entity + from archml.views.topology import build_viz_diagram from archml.workspace.config import LocalPathImport directory = Path(args.directory).resolve() @@ -339,16 +341,9 @@ def _cmd_visualize(args: argparse.Namespace) -> int: return 1 output_path = Path(args.output) - data = build_diagram_data(entity) - - try: - render_diagram(data, output_path) - except ImportError: - print( - "Error: 'diagrams' is not installed. Run 'pip install diagrams' to enable visualization.", - file=sys.stderr, - ) - return 1 + viz_diagram = build_viz_diagram(entity) + layout_plan = compute_layout(viz_diagram) + render_diagram(viz_diagram, layout_plan, output_path) print(f"Diagram written to '{output_path}'.") return 0 diff --git a/src/archml/views/backend/__init__.py b/src/archml/views/backend/__init__.py new file mode 100644 index 0000000..3bbcfed --- /dev/null +++ b/src/archml/views/backend/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Rendering backends for ArchML visualization diagrams.""" diff --git a/src/archml/views/backend/diagram.py b/src/archml/views/backend/diagram.py new file mode 100644 index 0000000..dcd95e8 --- /dev/null +++ b/src/archml/views/backend/diagram.py @@ -0,0 +1,299 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""SVG diagram backend for ArchML architecture views. + +Renders a :class:`~archml.views.topology.VizDiagram` using the pre-computed +:class:`~archml.views.placement.LayoutPlan` geometry, producing a standalone +SVG file. + +All coordinates are taken directly from the layout plan — no additional layout +engine is involved. Each :class:`~archml.views.placement.NodeLayout` maps to +a styled ````/```` pair, each +:class:`~archml.views.placement.BoundaryLayout` to a labelled rectangle, and +each :class:`~archml.views.placement.EdgeRoute` to a ```` with an +arrowhead at the target end. + +The visual vocabulary mirrors the colour scheme used by the original +``diagrams``-based renderer: + +- Root boundary — light blue fill, bold-blue border, title label. +- Internal child nodes — green (component) or blue (system). +- External actor nodes — purple. +- Terminal nodes — amber. +- Edges — dark-grey polylines with a filled arrowhead and a midpoint label. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path + +from archml.views.placement import BoundaryLayout, LayoutPlan, NodeLayout +from archml.views.topology import NodeKind, VizDiagram, VizNode + +# ############### +# Public Interface +# ############### + + +def render_diagram( + diagram: VizDiagram, + plan: LayoutPlan, + output_path: Path, + *, + scale: float = 1.0, +) -> None: + """Render *diagram* to an SVG file at *output_path*. + + Uses the geometry recorded in *plan* to position every element. All + layout-unit coordinates are multiplied by *scale* to obtain SVG user units + (at 96 dpi, 1 layout unit ≈ 1 px by default). + + The output directory is created automatically if it does not exist. + + Args: + diagram: The topology to render, as produced by + :func:`~archml.views.topology.build_viz_diagram`. + plan: The pre-computed layout plan produced by + :func:`~archml.views.placement.compute_layout`. + output_path: Destination path for the SVG file. + scale: Multiplier applied to all layout-unit coordinates. + Defaults to ``1.0``. + """ + svg = _build_svg(diagram, plan, scale) + _write_svg(svg, output_path) + + +# ################ +# Implementation +# ################ + +# --- Colour palette (matches original diagrams-based renderer) --- +_FILL_COMPONENT = "#e8f4e8" +_STROKE_COMPONENT = "#448844" +_FILL_SYSTEM = "#ddeeff" +_STROKE_SYSTEM = "#4466aa" +_FILL_EXTERNAL = "#f0e8f0" +_STROKE_EXTERNAL = "#664488" +_FILL_TERMINAL = "#fff8e1" +_STROKE_TERMINAL = "#aa8833" +_FILL_BOUNDARY = "#eef4ff" +_STROKE_BOUNDARY = "#4466aa" +_EDGE_COLOUR = "#444444" +_TEXT_COLOUR = "#222222" + +_FONT_FAMILY = "sans-serif" +_FONT_SIZE = 11 +_CORNER_RADIUS = 6 +_STROKE_WIDTH = 1.5 +_BOUNDARY_STROKE_WIDTH = 2.0 +_BOUNDARY_LABEL_OFFSET = 14.0 # y offset of boundary title from top edge + + +def _node_colours(kind: NodeKind | None) -> tuple[str, str]: + """Return ``(fill, stroke)`` for a node kind.""" + if kind == "component": + return _FILL_COMPONENT, _STROKE_COMPONENT + if kind == "system": + return _FILL_SYSTEM, _STROKE_SYSTEM + if kind in ("external_component", "external_system"): + return _FILL_EXTERNAL, _STROKE_EXTERNAL + return _FILL_TERMINAL, _STROKE_TERMINAL + + +def _f(value: float, scale: float) -> str: + """Format *value* scaled to two decimal places as a string.""" + return f"{value * scale:.2f}" + + +def _build_svg(diagram: VizDiagram, plan: LayoutPlan, scale: float) -> ET.Element: + """Construct the complete SVG element tree from *diagram* and *plan*.""" + tw = plan.total_width * scale + th = plan.total_height * scale + + svg = ET.Element( + "svg", + { + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": f"0 0 {tw:.2f} {th:.2f}", + "width": f"{tw:.2f}", + "height": f"{th:.2f}", + }, + ) + + _add_defs(svg) + + # Root boundary + if diagram.root.id in plan.boundaries: + _render_boundary(svg, diagram.root.label, plan.boundaries[diagram.root.id], scale) + + # Build a metadata map: node_id → (label, kind) for all renderable nodes. + node_meta: dict[str, tuple[str, NodeKind | None]] = {} + for child in diagram.root.children: + if isinstance(child, VizNode): + node_meta[child.id] = (child.label, child.kind) + for node in diagram.peripheral_nodes: + node_meta[node.id] = (node.label, node.kind) + + # Render all positioned nodes. + for node_id, nl in plan.nodes.items(): + label, kind = node_meta.get(node_id, (node_id, None)) + _render_node(svg, label, nl, kind, scale) + + # Render edges. + for edge in diagram.edges: + route = plan.edge_routes.get(edge.id) + if route is not None: + _render_edge(svg, route.waypoints, edge.label, scale) + + return svg + + +def _add_defs(svg: ET.Element) -> None: + """Add a ```` block containing the arrowhead marker.""" + defs = ET.SubElement(svg, "defs") + marker = ET.SubElement( + defs, + "marker", + { + "id": "arrowhead", + "markerWidth": "8", + "markerHeight": "6", + "refX": "7", + "refY": "3", + "orient": "auto", + }, + ) + ET.SubElement(marker, "polygon", {"points": "0 0, 8 3, 0 6", "fill": _EDGE_COLOUR}) + + +def _render_boundary(svg: ET.Element, label: str, bl: BoundaryLayout, scale: float) -> None: + """Draw the root boundary rectangle with a title label.""" + r = str(_CORNER_RADIUS) + ET.SubElement( + svg, + "rect", + { + "x": _f(bl.x, scale), + "y": _f(bl.y, scale), + "width": _f(bl.width, scale), + "height": _f(bl.height, scale), + "rx": r, + "ry": r, + "fill": _FILL_BOUNDARY, + "stroke": _STROKE_BOUNDARY, + "stroke-width": str(_BOUNDARY_STROKE_WIDTH), + }, + ) + title = ET.SubElement( + svg, + "text", + { + "x": _f(bl.x + bl.width / 2, scale), + "y": _f(bl.y + _BOUNDARY_LABEL_OFFSET, scale), + "text-anchor": "middle", + "dominant-baseline": "middle", + "font-family": _FONT_FAMILY, + "font-size": str(int(_FONT_SIZE * scale * 1.1)), + "font-weight": "bold", + "fill": _STROKE_BOUNDARY, + }, + ) + title.text = label + + +def _render_node( + svg: ET.Element, + label: str, + nl: NodeLayout, + kind: NodeKind | None, + scale: float, +) -> None: + """Draw a node rectangle with a centred label.""" + fill, stroke = _node_colours(kind) + r = str(_CORNER_RADIUS) + ET.SubElement( + svg, + "rect", + { + "x": _f(nl.x, scale), + "y": _f(nl.y, scale), + "width": _f(nl.width, scale), + "height": _f(nl.height, scale), + "rx": r, + "ry": r, + "fill": fill, + "stroke": stroke, + "stroke-width": str(_STROKE_WIDTH), + }, + ) + text = ET.SubElement( + svg, + "text", + { + "x": _f(nl.x + nl.width / 2, scale), + "y": _f(nl.y + nl.height / 2, scale), + "text-anchor": "middle", + "dominant-baseline": "middle", + "font-family": _FONT_FAMILY, + "font-size": str(int(_FONT_SIZE * scale)), + "fill": _TEXT_COLOUR, + }, + ) + text.text = label + + +def _render_edge( + svg: ET.Element, + waypoints: list[tuple[float, float]], + label: str, + scale: float, +) -> None: + """Draw a polyline edge with an arrowhead and a midpoint label.""" + if len(waypoints) < 2: + return + + points_str = " ".join(f"{x * scale:.2f},{y * scale:.2f}" for x, y in waypoints) + ET.SubElement( + svg, + "polyline", + { + "points": points_str, + "fill": "none", + "stroke": _EDGE_COLOUR, + "stroke-width": "1.2", + "marker-end": "url(#arrowhead)", + }, + ) + + # Label at the midpoint of the edge. + mid = len(waypoints) // 2 + x1, y1 = waypoints[mid - 1] + x2, y2 = waypoints[mid] + mx = (x1 + x2) / 2 * scale + my = (y1 + y2) / 2 * scale - 4 * scale + + text = ET.SubElement( + svg, + "text", + { + "x": f"{mx:.2f}", + "y": f"{my:.2f}", + "text-anchor": "middle", + "font-family": _FONT_FAMILY, + "font-size": str(int(_FONT_SIZE * scale * 0.9)), + "fill": _EDGE_COLOUR, + }, + ) + text.text = label + + +def _write_svg(svg: ET.Element, output_path: Path) -> None: + """Write *svg* to *output_path* as a UTF-8 SVG file with XML declaration.""" + ET.indent(svg, space=" ") + output_path.parent.mkdir(parents=True, exist_ok=True) + tree = ET.ElementTree(svg) + with output_path.open("w", encoding="utf-8") as fh: + fh.write('\n') + tree.write(fh, encoding="unicode", xml_declaration=False) diff --git a/src/archml/views/diagram.py b/src/archml/views/diagram.py deleted file mode 100644 index ad3ac07..0000000 --- a/src/archml/views/diagram.py +++ /dev/null @@ -1,276 +0,0 @@ -# Copyright 2026 ArchML Contributors -# SPDX-License-Identifier: Apache-2.0 - -"""Diagram generation for ArchML architecture views. - -Builds a diagram representation from a resolved model entity and renders it -to an image file using the ``diagrams`` library (which delegates layout to -Graphviz). - -The diagram shows: -- The target entity as the outer container / title. -- All direct child components and systems as inner boxes inside a Cluster. -- The target entity's ``requires`` interfaces as incoming terminal nodes - (left side, arrows pointing into the entity). -- The target entity's ``provides`` interfaces as outgoing terminal nodes - (right side, arrows pointing out of the entity). -- Connections between child entities as labelled directed edges. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path - -from archml.model.entities import Component, InterfaceRef, System - -# ############### -# Public Interface -# ############### - - -@dataclass -class ChildBox: - """Represents a direct child component or system in the diagram. - - Attributes: - name: Short name of the child entity. - description: Optional human-readable description. - kind: ``"component"`` or ``"system"``. - """ - - name: str - description: str | None - kind: str # "component" | "system" - - -@dataclass -class InterfaceTerminal: - """Represents an external interface terminal (incoming or outgoing). - - Attributes: - name: Interface name (optionally with version suffix ``@vN``). - direction: ``"in"`` for ``requires``, ``"out"`` for ``provides``. - description: Optional description of the interface. - """ - - name: str - direction: str # "in" | "out" - description: str | None = None - - -@dataclass -class ConnectionData: - """Represents a directed connection between two child entities. - - Attributes: - source: Name of the source child entity. - target: Name of the target child entity. - label: Interface name used by the connection. - """ - - source: str - target: str - label: str - - -@dataclass -class DiagramData: - """Full description of a diagram to be rendered. - - Attributes: - title: Name of the target entity (used as the diagram title/box). - description: Optional human-readable description of the entity. - children: Direct child components and systems. - terminals: External interface terminals (in/out). - connections: Directed data-flow connections between children. - """ - - title: str - description: str | None - children: list[ChildBox] = field(default_factory=list) - terminals: list[InterfaceTerminal] = field(default_factory=list) - connections: list[ConnectionData] = field(default_factory=list) - - -def build_diagram_data(entity: Component | System) -> DiagramData: - """Build a :class:`DiagramData` description from a model entity. - - Collects direct children, external interface terminals, and connections - from *entity* without navigating deeper into the hierarchy. - - Args: - entity: The resolved component or system to visualize. - - Returns: - A :class:`DiagramData` instance describing the diagram. - """ - children: list[ChildBox] = [ - ChildBox(name=comp.name, description=comp.description, kind="component") for comp in entity.components - ] - if isinstance(entity, System): - children += [ChildBox(name=sys.name, description=sys.description, kind="system") for sys in entity.systems] - - terminals: list[InterfaceTerminal] = [ - InterfaceTerminal( - name=_iref_label(ref), - direction="in", - ) - for ref in entity.requires - ] + [ - InterfaceTerminal( - name=_iref_label(ref), - direction="out", - ) - for ref in entity.provides - ] - - connections: list[ConnectionData] = [ - ConnectionData( - source=conn.source.entity, - target=conn.target.entity, - label=_iref_label(conn.interface), - ) - for conn in entity.connections - ] - - return DiagramData( - title=entity.name, - description=entity.description, - children=children, - terminals=terminals, - connections=connections, - ) - - -def render_diagram(data: DiagramData, output_path: Path) -> None: - """Render *data* to an image file at *output_path* using the ``diagrams`` library. - - Custom styled nodes are used for the entity, its children, and its interface - terminals. Graphviz (via the ``diagrams`` library) handles layout automatically. - - For leaf entities (no children) a single entity node is placed between the - terminal nodes. For entities with children a ``Cluster`` groups the children - visually; requires terminals connect to the natural entry children and provides - terminals connect from the natural exit children. - - The output format is determined by the file extension of *output_path* - (e.g. ``.svg``, ``.png``). - - Args: - data: The diagram description to render. - output_path: Destination file path for the rendered image. - - Raises: - ImportError: If the ``diagrams`` package is not installed. - """ - try: - import diagrams as _diagrams - from diagrams import Cluster, Edge, Node - except ImportError as exc: - raise ImportError("'diagrams' is not installed. Run 'pip install diagrams' to enable visualization.") from exc - - # Custom node classes are defined locally to keep the diagrams import lazy - # (it is an optional dependency that requires Graphviz to be installed). - - class _TerminalNode(Node): - """Styled box for an interface terminal (requires or provides).""" - - _icon_dir = None - _icon = None - _height = 1.2 - _attr = { - "shape": "box", - "style": "rounded,filled", - "fillcolor": "#fff8e1", - "color": "#aa8833", - "penwidth": "1.5", - } - - class _EntityNode(Node): - """Styled box for a leaf entity (component or system with no children).""" - - _icon_dir = None - _icon = None - _height = 1.5 - _attr = { - "shape": "box", - "style": "rounded,filled", - "fillcolor": "#ddeeff", - "color": "#4466aa", - "penwidth": "2", - } - - class _ChildNode(Node): - """Styled box for a child component or system inside an entity cluster.""" - - _icon_dir = None - _icon = None - _height = 1.2 - _attr = { - "shape": "box", - "style": "rounded,filled", - "fillcolor": "#e8f4e8", - "color": "#448844", - "penwidth": "1.5", - } - - output_stem = str(output_path.parent / output_path.stem) - output_format = output_path.suffix.lstrip(".") or "svg" - - with _diagrams.Diagram(data.title, filename=output_stem, outformat=output_format, show=False, direction="LR"): - # --- Requires terminals (rendered left by Graphviz) --- - req_nodes = {t.name: _TerminalNode(t.name) for t in data.terminals if t.direction == "in"} - - # --- Entity representation --- - if data.children: - # A Cluster groups children inside a visible border labelled with - # the entity name; children are instantiated inside the context so - # Graphviz assigns them to the subgraph automatically. - with Cluster(data.title): - child_nodes = {child.name: _ChildNode(child.name) for child in data.children} - - # Internal connections between children (drawn after the Cluster so - # that cross-cluster edges are handled correctly by Graphviz). - for conn in data.connections: - if conn.source in child_nodes and conn.target in child_nodes: - child_nodes[conn.source] >> Edge(label=conn.label) >> child_nodes[conn.target] - - # Children with no incoming internal connection are natural entry - # points for requires terminals; those with no outgoing connection - # are natural exit points for provides terminals. - conn_targets = {c.target for c in data.connections if c.target in child_nodes} - conn_sources = {c.source for c in data.connections if c.source in child_nodes} - entry_nodes = [child_nodes[c.name] for c in data.children if c.name not in conn_targets] - exit_nodes = [child_nodes[c.name] for c in data.children if c.name not in conn_sources] - if not entry_nodes: - entry_nodes = list(child_nodes.values()) - if not exit_nodes: - exit_nodes = list(child_nodes.values()) - else: - # Leaf entity: a single styled node represents the whole entity. - entity_node = _EntityNode(data.title) - entry_nodes = [entity_node] - exit_nodes = [entity_node] - - # --- Provides terminals (rendered right by Graphviz) --- - prov_nodes = {t.name: _TerminalNode(t.name) for t in data.terminals if t.direction == "out"} - - # --- Terminal ↔ entity edges --- - for req_node in req_nodes.values(): - for entry in entry_nodes: - req_node >> Edge() >> entry - - for prov_node in prov_nodes.values(): - for exit_node in exit_nodes: - exit_node >> Edge() >> prov_node - - -# ################ -# Implementation -# ################ - - -def _iref_label(ref: InterfaceRef) -> str: - """Return a display label for an interface reference.""" - return f"{ref.name}@{ref.version}" if ref.version else ref.name diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 16fd4b4..5a9b0fd 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -655,7 +655,7 @@ def test_visualize_succeeds_with_mocked_renderer( (tmp_path / "arch.archml").write_text("component Worker {}\n") out_file = tmp_path / "diagram.png" monkeypatch.setattr(sys, "argv", ["archml", "visualize", "Worker", str(out_file), str(tmp_path)]) - with patch("archml.views.diagram.render_diagram") as mock_render, pytest.raises(SystemExit) as exc_info: + with patch("archml.views.backend.diagram.render_diagram") as mock_render, pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 mock_render.assert_called_once() diff --git a/tests/views/backend/__init__.py b/tests/views/backend/__init__.py new file mode 100644 index 0000000..bdbf63f --- /dev/null +++ b/tests/views/backend/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/views/backend/test_diagram.py b/tests/views/backend/test_diagram.py new file mode 100644 index 0000000..f2cbb34 --- /dev/null +++ b/tests/views/backend/test_diagram.py @@ -0,0 +1,300 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the SVG diagram rendering backend.""" + +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System +from archml.views.backend.diagram import render_diagram +from archml.views.placement import compute_layout +from archml.views.topology import build_viz_diagram + +# ############### +# Helpers +# ############### + +_SVG_NS = "http://www.w3.org/2000/svg" + + +def _iref(name: str, version: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version) + + +def _conn(source: str, target: str, interface: str) -> Connection: + return Connection( + source=ConnectionEndpoint(entity=source), + target=ConnectionEndpoint(entity=target), + interface=InterfaceRef(name=interface), + ) + + +def _render_and_parse(entity: Component | System, tmp_path: Path, **kwargs: object) -> ET.Element: + """Build a VizDiagram + LayoutPlan, render to SVG, and parse the result.""" + diagram = build_viz_diagram(entity) + plan = compute_layout(diagram) + out = tmp_path / "diagram.svg" + render_diagram(diagram, plan, out, **kwargs) + return ET.parse(str(out)).getroot() + + +def _text_content(root: ET.Element) -> list[str]: + """Collect all non-empty text values from the SVG element tree.""" + return [el.text for el in root.iter() if el.text and el.text.strip()] + + +# ############### +# File creation +# ############### + + +def test_render_creates_svg_file(tmp_path: Path) -> None: + """render_diagram writes a file at the specified output path.""" + comp = Component(name="Worker") + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "out.svg" + render_diagram(diagram, plan, out) + assert out.exists() + + +def test_render_creates_parent_directory(tmp_path: Path) -> None: + """render_diagram creates missing parent directories.""" + comp = Component(name="Worker") + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "nested" / "deep" / "diagram.svg" + render_diagram(diagram, plan, out) + assert out.exists() + + +def test_render_output_starts_with_xml_declaration(tmp_path: Path) -> None: + """Output file begins with an XML declaration.""" + comp = Component(name="Worker") + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "out.svg" + render_diagram(diagram, plan, out) + content = out.read_text(encoding="utf-8") + assert content.startswith(" None: + """Rendered SVG is well-formed XML.""" + comp = Component(name="SystemA", components=[Component(name="Alpha"), Component(name="Beta")]) + _render_and_parse(comp, tmp_path) # would raise ET.ParseError if invalid + + +# ############### +# SVG dimensions +# ############### + + +def test_render_svg_width_matches_plan(tmp_path: Path) -> None: + """SVG ``width`` attribute equals plan.total_width (with default scale=1.0).""" + comp = Component(name="Sys", components=[Component(name="A")]) + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "out.svg" + render_diagram(diagram, plan, out) + root = ET.parse(str(out)).getroot() + assert float(root.attrib["width"]) == pytest.approx(plan.total_width, rel=1e-3) + + +def test_render_svg_height_matches_plan(tmp_path: Path) -> None: + """SVG ``height`` attribute equals plan.total_height (with default scale=1.0).""" + comp = Component(name="Sys", components=[Component(name="A")]) + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "out.svg" + render_diagram(diagram, plan, out) + root = ET.parse(str(out)).getroot() + assert float(root.attrib["height"]) == pytest.approx(plan.total_height, rel=1e-3) + + +def test_render_scale_enlarges_svg_dimensions(tmp_path: Path) -> None: + """Applying scale > 1.0 produces a larger SVG than scale=1.0.""" + comp = Component(name="Sys", components=[Component(name="A")]) + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + + out1 = tmp_path / "s1.svg" + out2 = tmp_path / "s2.svg" + render_diagram(diagram, plan, out1, scale=1.0) + render_diagram(diagram, plan, out2, scale=2.0) + + root1 = ET.parse(str(out1)).getroot() + root2 = ET.parse(str(out2)).getroot() + assert float(root2.attrib["width"]) > float(root1.attrib["width"]) + assert float(root2.attrib["height"]) > float(root1.attrib["height"]) + + +# ############### +# Root boundary label +# ############### + + +def test_render_boundary_label_present(tmp_path: Path) -> None: + """The root entity name appears as text in the SVG.""" + comp = Component(name="OrderService", components=[Component(name="Processor")]) + root = _render_and_parse(comp, tmp_path) + assert "OrderService" in _text_content(root) + + +def test_render_boundary_label_for_system(tmp_path: Path) -> None: + """System name appears as text in the SVG boundary.""" + sys = System(name="ECommerce", components=[Component(name="Worker")]) + root = _render_and_parse(sys, tmp_path) + assert "ECommerce" in _text_content(root) + + +# ############### +# Child node labels +# ############### + + +def test_render_child_component_label_present(tmp_path: Path) -> None: + """Each child component name appears as a text node in the SVG.""" + comp = Component(name="Parent", components=[Component(name="Alpha"), Component(name="Beta")]) + root = _render_and_parse(comp, tmp_path) + texts = _text_content(root) + assert "Alpha" in texts + assert "Beta" in texts + + +def test_render_child_system_label_present(tmp_path: Path) -> None: + """Child systems within a system appear as text nodes.""" + sys = System( + name="Root", + systems=[System(name="SubSys")], + components=[Component(name="Worker")], + ) + root = _render_and_parse(sys, tmp_path) + texts = _text_content(root) + assert "SubSys" in texts + assert "Worker" in texts + + +def test_render_leaf_entity_produces_no_child_nodes(tmp_path: Path) -> None: + """A leaf entity (no children) results in no child node rect elements beyond the boundary.""" + comp = Component(name="Leaf") + diagram = build_viz_diagram(comp) + plan = compute_layout(diagram) + out = tmp_path / "out.svg" + render_diagram(diagram, plan, out) + root = ET.parse(str(out)).getroot() + # Leaf has no child nodes in plan.nodes (only the boundary if any). + assert len(plan.nodes) == 0 + # SVG still contains at least the boundary rect. + rects = list(root.iter(f"{{{_SVG_NS}}}rect")) + assert len(rects) >= 1 + + +# ############### +# Terminal / peripheral node labels +# ############### + + +def test_render_requires_terminal_label_present(tmp_path: Path) -> None: + """Requires interface terminal label appears in the SVG.""" + comp = Component( + name="Sys", + requires=[_iref("DataFeed")], + components=[Component(name="A")], + ) + root = _render_and_parse(comp, tmp_path) + assert "DataFeed" in _text_content(root) + + +def test_render_provides_terminal_label_present(tmp_path: Path) -> None: + """Provides interface terminal label appears in the SVG.""" + comp = Component( + name="Sys", + provides=[_iref("Result")], + components=[Component(name="A")], + ) + root = _render_and_parse(comp, tmp_path) + assert "Result" in _text_content(root) + + +def test_render_versioned_terminal_label(tmp_path: Path) -> None: + """Versioned interface label (e.g. ``API@v2``) appears in the SVG.""" + comp = Component( + name="Sys", + provides=[_iref("API", version="v2")], + components=[Component(name="A")], + ) + root = _render_and_parse(comp, tmp_path) + assert "API@v2" in _text_content(root) + + +# ############### +# Edge labels +# ############### + + +def test_render_edge_label_present(tmp_path: Path) -> None: + """Connection interface name appears as a text label in the SVG.""" + a = Component(name="A", requires=[_iref("PaymentRequest")]) + b = Component(name="B", provides=[_iref("PaymentRequest")]) + sys = System( + name="Root", + components=[a, b], + connections=[_conn("A", "B", "PaymentRequest")], + ) + root = _render_and_parse(sys, tmp_path) + assert "PaymentRequest" in _text_content(root) + + +def test_render_edge_polyline_present(tmp_path: Path) -> None: + """An edge between two children produces at least one ```` element.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + sys = System( + name="Root", + components=[a, b], + connections=[_conn("A", "B", "IFace")], + ) + root = _render_and_parse(sys, tmp_path) + polylines = list(root.iter(f"{{{_SVG_NS}}}polyline")) + assert len(polylines) >= 1 + + +def test_render_arrowhead_marker_defined(tmp_path: Path) -> None: + """The SVG ```` section contains an arrowhead marker.""" + comp = Component(name="Sys", components=[Component(name="A")]) + root = _render_and_parse(comp, tmp_path) + defs = root.find(f"{{{_SVG_NS}}}defs") + assert defs is not None + marker = defs.find(f"{{{_SVG_NS}}}marker") + assert marker is not None + assert marker.attrib.get("id") == "arrowhead" + + +# ############### +# Integration +# ############### + + +def test_render_ecommerce_system(tmp_path: Path) -> None: + """Full integration: multi-component system with connections renders without error.""" + sys = System( + name="ECommerce", + components=[ + Component(name="OrderService", requires=[_iref("PaymentRequest")]), + Component(name="PaymentService", provides=[_iref("PaymentRequest")]), + Component(name="NotificationService", requires=[_iref("OrderRequest")]), + ], + connections=[ + _conn("OrderService", "PaymentService", "PaymentRequest"), + ], + ) + root = _render_and_parse(sys, tmp_path) + texts = _text_content(root) + assert "ECommerce" in texts + assert "OrderService" in texts + assert "PaymentService" in texts + assert "PaymentRequest" in texts diff --git a/tests/views/test_diagram.py b/tests/views/test_diagram.py deleted file mode 100644 index 1169ac8..0000000 --- a/tests/views/test_diagram.py +++ /dev/null @@ -1,413 +0,0 @@ -# Copyright 2026 ArchML Contributors -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for diagram data builder and renderer.""" - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System -from archml.views.diagram import ( - ChildBox, - ConnectionData, - DiagramData, - InterfaceTerminal, - build_diagram_data, - render_diagram, -) - -# ############### -# Helpers -# ############### - - -def _iref(name: str, version: str | None = None) -> InterfaceRef: - return InterfaceRef(name=name, version=version) - - -def _conn(source: str, target: str, interface: str) -> Connection: - return Connection( - source=ConnectionEndpoint(entity=source), - target=ConnectionEndpoint(entity=target), - interface=InterfaceRef(name=interface), - ) - - -# ############### -# build_diagram_data — title and description -# ############### - - -def test_build_title_from_component() -> None: - """Diagram title is the component name.""" - comp = Component(name="Worker") - data = build_diagram_data(comp) - assert data.title == "Worker" - - -def test_build_title_from_system() -> None: - """Diagram title is the system name.""" - sys_a = System(name="SystemA") - data = build_diagram_data(sys_a) - assert data.title == "SystemA" - - -def test_build_description_propagated() -> None: - """Description is forwarded to DiagramData.""" - comp = Component(name="Worker", description="Does the work") - data = build_diagram_data(comp) - assert data.description == "Does the work" - - -def test_build_description_none_when_absent() -> None: - """Description is None when the entity has no description.""" - comp = Component(name="Worker") - data = build_diagram_data(comp) - assert data.description is None - - -# ############### -# build_diagram_data — children -# ############### - - -def test_build_children_from_component_with_sub_components() -> None: - """Sub-components are listed as ChildBox entries with kind='component'.""" - child_a = Component(name="Alpha", description="First") - child_b = Component(name="Beta") - parent = Component(name="Parent", components=[child_a, child_b]) - data = build_diagram_data(parent) - assert data.children == [ - ChildBox(name="Alpha", description="First", kind="component"), - ChildBox(name="Beta", description=None, kind="component"), - ] - - -def test_build_children_from_system_includes_components_and_subsystems() -> None: - """System children include both components and sub-systems.""" - comp = Component(name="Worker") - sub = System(name="SubSys") - parent = System(name="Root", components=[comp], systems=[sub]) - data = build_diagram_data(parent) - names_kinds = [(c.name, c.kind) for c in data.children] - assert ("Worker", "component") in names_kinds - assert ("SubSys", "system") in names_kinds - - -def test_build_no_children_for_leaf() -> None: - """A leaf entity (no sub-components or sub-systems) produces no children.""" - comp = Component(name="Leaf") - data = build_diagram_data(comp) - assert data.children == [] - - -# ############### -# build_diagram_data — terminals -# ############### - - -def test_build_requires_terminals() -> None: - """Requires interfaces appear as incoming terminals.""" - comp = Component(name="C", requires=[_iref("DataFeed")]) - data = build_diagram_data(comp) - assert InterfaceTerminal(name="DataFeed", direction="in") in data.terminals - - -def test_build_provides_terminals() -> None: - """Provides interfaces appear as outgoing terminals.""" - comp = Component(name="C", provides=[_iref("Result")]) - data = build_diagram_data(comp) - assert InterfaceTerminal(name="Result", direction="out") in data.terminals - - -def test_build_versioned_interface_terminal() -> None: - """Versioned interfaces include the version suffix in their label.""" - comp = Component(name="C", provides=[_iref("API", version="v2")]) - data = build_diagram_data(comp) - assert InterfaceTerminal(name="API@v2", direction="out") in data.terminals - - -def test_build_no_terminals_for_leaf() -> None: - """An entity with no requires or provides has no terminals.""" - comp = Component(name="Isolated") - data = build_diagram_data(comp) - assert data.terminals == [] - - -# ############### -# build_diagram_data — connections -# ############### - - -def test_build_connections() -> None: - """Connections are translated to ConnectionData entries.""" - child_a = Component(name="A") - child_b = Component(name="B") - conn = _conn("A", "B", "IFace") - parent = Component(name="Parent", components=[child_a, child_b], connections=[conn]) - data = build_diagram_data(parent) - assert ConnectionData(source="A", target="B", label="IFace") in data.connections - - -def test_build_no_connections_for_leaf() -> None: - """A leaf entity has no connections.""" - comp = Component(name="Leaf") - data = build_diagram_data(comp) - assert data.connections == [] - - -# ############### -# render_diagram — mock infrastructure -# ############### - - -class _NodeStub: - """Minimal stub for diagrams.Node that supports subclassing and tracks labels.""" - - _icon_dir = None - _icon = None - _height = 1.9 - _attr: dict[str, str] = {} - - # Collects labels of every instance created; reset via the autouse fixture. - created_labels: list[str] = [] - - def __init__(self, label: str = "", **attrs: object) -> None: - _NodeStub.created_labels.append(label) - - def __rshift__(self, other: object) -> object: - return other - - def __lshift__(self, other: object) -> object: - return other - - -@pytest.fixture(autouse=True) -def _reset_node_stub() -> None: - """Clear _NodeStub tracking before every test.""" - _NodeStub.created_labels.clear() - - -def _make_diagrams_mock() -> MagicMock: - """Return a mock diagrams module with a proper Node stub class.""" - mock = MagicMock() - mock.Node = _NodeStub - return mock - - -def _diagrams_patch(mock_diagrams: MagicMock) -> ...: # type: ignore[type-arg] - return patch.dict(sys.modules, {"diagrams": mock_diagrams}) - - -# ############### -# render_diagram — Diagram construction -# ############### - - -def test_render_diagram_calls_diagram_with_title(tmp_path: Path) -> None: - """render_diagram creates a Diagram context with the entity title.""" - mock = _make_diagrams_mock() - data = DiagramData(title="SystemA", description=None) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.png") - - mock.Diagram.assert_called_once_with( - "SystemA", - filename=str(tmp_path / "out"), - outformat="png", - show=False, - direction="LR", - ) - - -def test_render_diagram_svg_format_from_extension(tmp_path: Path) -> None: - """The output format is derived from the path extension.""" - mock = _make_diagrams_mock() - data = DiagramData(title="S", description=None) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "diagram.svg") - - _, kwargs = mock.Diagram.call_args - assert kwargs["outformat"] == "svg" - - -def test_render_diagram_default_format_is_svg(tmp_path: Path) -> None: - """When the path has no extension the format defaults to svg.""" - mock = _make_diagrams_mock() - data = DiagramData(title="S", description=None) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out") - - _, kwargs = mock.Diagram.call_args - assert kwargs["outformat"] == "svg" - - -# ############### -# render_diagram — terminal nodes -# ############### - - -def test_render_diagram_creates_node_for_requires_terminal(tmp_path: Path) -> None: - """A _TerminalNode is instantiated for each requires interface.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - terminals=[InterfaceTerminal(name="DataFeed", direction="in")], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - assert "DataFeed" in _NodeStub.created_labels - - -def test_render_diagram_creates_node_for_provides_terminal(tmp_path: Path) -> None: - """A _TerminalNode is instantiated for each provides interface.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - terminals=[InterfaceTerminal(name="Result", direction="out")], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - assert "Result" in _NodeStub.created_labels - - -def test_render_diagram_creates_nodes_for_both_terminals(tmp_path: Path) -> None: - """Terminal nodes are created for both requires and provides interfaces.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - terminals=[ - InterfaceTerminal(name="In", direction="in"), - InterfaceTerminal(name="Out", direction="out"), - ], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - assert "In" in _NodeStub.created_labels - assert "Out" in _NodeStub.created_labels - - -# ############### -# render_diagram — leaf vs cluster -# ############### - - -def test_render_diagram_uses_entity_node_for_leaf(tmp_path: Path) -> None: - """A leaf entity (no children) creates a single entity node.""" - mock = _make_diagrams_mock() - data = DiagramData(title="Leaf", description=None) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - # No Cluster for leaf entities - mock.Cluster.assert_not_called() - # The entity title appears as a node label - assert "Leaf" in _NodeStub.created_labels - - -def test_render_diagram_uses_cluster_for_entity_with_children(tmp_path: Path) -> None: - """An entity with children wraps them in a Cluster.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="Parent", - description=None, - children=[ChildBox(name="Alpha", description=None, kind="component")], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - mock.Cluster.assert_called_once_with("Parent") - - -def test_render_diagram_creates_child_nodes(tmp_path: Path) -> None: - """A node is instantiated for each child inside the cluster.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - children=[ - ChildBox(name="Alpha", description=None, kind="component"), - ChildBox(name="Beta", description=None, kind="system"), - ], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - assert "Alpha" in _NodeStub.created_labels - assert "Beta" in _NodeStub.created_labels - - -# ############### -# render_diagram — connections -# ############### - - -def test_render_diagram_creates_edge_for_child_connection(tmp_path: Path) -> None: - """An Edge is created for each connection between children.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - children=[ - ChildBox(name="A", description=None, kind="component"), - ChildBox(name="B", description=None, kind="component"), - ], - connections=[ConnectionData(source="A", target="B", label="IFace")], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - mock.Edge.assert_called_with(label="IFace") - - -def test_render_diagram_creates_edges_for_terminals(tmp_path: Path) -> None: - """Edges are created to connect terminal nodes to the entity.""" - mock = _make_diagrams_mock() - data = DiagramData( - title="S", - description=None, - terminals=[ - InterfaceTerminal(name="In", direction="in"), - InterfaceTerminal(name="Out", direction="out"), - ], - ) - - with _diagrams_patch(mock): - render_diagram(data, tmp_path / "out.svg") - - # Edge() (no label) is called for each terminal connection - assert mock.Edge.called - - -# ############### -# render_diagram — ImportError -# ############### - - -def test_render_diagram_raises_import_error_without_diagrams(tmp_path: Path) -> None: - """render_diagram raises ImportError when the diagrams package is missing.""" - data = DiagramData(title="S", description=None) - with ( - patch.dict(sys.modules, {"diagrams": None}), # type: ignore[dict-item] - pytest.raises(ImportError), - ): - render_diagram(data, tmp_path / "out.png") From a49be1a6cdc4ab12254514492ab2d472e7e1f6ba Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Tue, 3 Mar 2026 21:37:52 +0100 Subject: [PATCH 2/3] new change --- src/archml/views/backend/diagram.py | 101 +++++++++++++++++++--------- tests/views/backend/test_diagram.py | 42 +++++++++--- 2 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/archml/views/backend/diagram.py b/src/archml/views/backend/diagram.py index dcd95e8..488006a 100644 --- a/src/archml/views/backend/diagram.py +++ b/src/archml/views/backend/diagram.py @@ -12,7 +12,10 @@ a styled ````/```` pair, each :class:`~archml.views.placement.BoundaryLayout` to a labelled rectangle, and each :class:`~archml.views.placement.EdgeRoute` to a ```` with an -arrowhead at the target end. +explicit filled-polygon arrowhead at the target end. + +Text labels are clipped to their node bounding box via SVG ```` +elements, so long labels never overflow the node rectangle. The visual vocabulary mirrors the colour scheme used by the original ``diagrams``-based renderer: @@ -21,11 +24,12 @@ - Internal child nodes — green (component) or blue (system). - External actor nodes — purple. - Terminal nodes — amber. -- Edges — dark-grey polylines with a filled arrowhead and a midpoint label. +- Edges — dark-grey lines with a filled arrowhead and a midpoint label. """ from __future__ import annotations +import math import xml.etree.ElementTree as ET from pathlib import Path @@ -88,7 +92,12 @@ def render_diagram( _CORNER_RADIUS = 6 _STROKE_WIDTH = 1.5 _BOUNDARY_STROKE_WIDTH = 2.0 -_BOUNDARY_LABEL_OFFSET = 14.0 # y offset of boundary title from top edge +_BOUNDARY_LABEL_OFFSET = 14.0 # y distance from boundary top to title baseline +_LABEL_PADDING = 6.0 # horizontal padding inside node for text clip region + +# Arrowhead geometry (layout units, before scaling). +_ARROW_LEN = 10.0 # length of the arrowhead along the edge direction +_ARROW_HALF_W = 4.0 # half-width of the arrowhead base def _node_colours(kind: NodeKind | None) -> tuple[str, str]: @@ -107,6 +116,12 @@ def _f(value: float, scale: float) -> str: return f"{value * scale:.2f}" +def _make_clip_id(node_id: str) -> str: + """Return a safe XML ID for the ```` of *node_id*.""" + safe = node_id.replace(".", "-").replace(":", "-").replace("/", "-").replace("@", "-") + return f"clip-{safe}" + + def _build_svg(diagram: VizDiagram, plan: LayoutPlan, scale: float) -> ET.Element: """Construct the complete SVG element tree from *diagram* and *plan*.""" tw = plan.total_width * scale @@ -122,7 +137,7 @@ def _build_svg(diagram: VizDiagram, plan: LayoutPlan, scale: float) -> ET.Elemen }, ) - _add_defs(svg) + defs = ET.SubElement(svg, "defs") # Root boundary if diagram.root.id in plan.boundaries: @@ -136,12 +151,14 @@ def _build_svg(diagram: VizDiagram, plan: LayoutPlan, scale: float) -> ET.Elemen for node in diagram.peripheral_nodes: node_meta[node.id] = (node.label, node.kind) - # Render all positioned nodes. + # Render all positioned nodes (clip paths added to ). for node_id, nl in plan.nodes.items(): label, kind = node_meta.get(node_id, (node_id, None)) - _render_node(svg, label, nl, kind, scale) + clip_id = _make_clip_id(node_id) + _add_node_clip(defs, clip_id, nl, scale) + _render_node(svg, label, nl, kind, scale, clip_id) - # Render edges. + # Render edges with explicit arrowheads. for edge in diagram.edges: route = plan.edge_routes.get(edge.id) if route is not None: @@ -150,22 +167,19 @@ def _build_svg(diagram: VizDiagram, plan: LayoutPlan, scale: float) -> ET.Elemen return svg -def _add_defs(svg: ET.Element) -> None: - """Add a ```` block containing the arrowhead marker.""" - defs = ET.SubElement(svg, "defs") - marker = ET.SubElement( - defs, - "marker", +def _add_node_clip(defs: ET.Element, clip_id: str, nl: NodeLayout, scale: float) -> None: + """Add a ```` for *nl* to *defs*, using the node bounds with padding.""" + clip = ET.SubElement(defs, "clipPath", {"id": clip_id}) + ET.SubElement( + clip, + "rect", { - "id": "arrowhead", - "markerWidth": "8", - "markerHeight": "6", - "refX": "7", - "refY": "3", - "orient": "auto", + "x": _f(nl.x + _LABEL_PADDING, scale), + "y": _f(nl.y, scale), + "width": _f(nl.width - 2 * _LABEL_PADDING, scale), + "height": _f(nl.height, scale), }, ) - ET.SubElement(marker, "polygon", {"points": "0 0, 8 3, 0 6", "fill": _EDGE_COLOUR}) def _render_boundary(svg: ET.Element, label: str, bl: BoundaryLayout, scale: float) -> None: @@ -209,8 +223,9 @@ def _render_node( nl: NodeLayout, kind: NodeKind | None, scale: float, + clip_id: str, ) -> None: - """Draw a node rectangle with a centred label.""" + """Draw a node rectangle with a centred, clipped label.""" fill, stroke = _node_colours(kind) r = str(_CORNER_RADIUS) ET.SubElement( @@ -239,6 +254,7 @@ def _render_node( "font-family": _FONT_FAMILY, "font-size": str(int(_FONT_SIZE * scale)), "fill": _TEXT_COLOUR, + "clip-path": f"url(#{clip_id})", }, ) text.text = label @@ -250,11 +266,28 @@ def _render_edge( label: str, scale: float, ) -> None: - """Draw a polyline edge with an arrowhead and a midpoint label.""" + """Draw an edge as a polyline body plus an explicit arrowhead polygon at the target end.""" if len(waypoints) < 2: return - points_str = " ".join(f"{x * scale:.2f},{y * scale:.2f}" for x, y in waypoints) + # Compute the arrowhead direction from the last segment of the edge. + x1, y1 = waypoints[-2] + x2, y2 = waypoints[-1] + dx, dy = x2 - x1, y2 - y1 + length = math.hypot(dx, dy) + if length < 1e-9: + return + + ndx, ndy = dx / length, dy / length + + # The arrow tip is at the target port anchor; the base is set back by _ARROW_LEN. + arrow_len = min(_ARROW_LEN, length * 0.45) + base_x = x2 - arrow_len * ndx + base_y = y2 - arrow_len * ndy + + # Edge body: polyline stopping at the arrowhead base so it doesn't overlap it. + body_wps = list(waypoints[:-1]) + [(base_x, base_y)] + points_str = " ".join(f"{x * scale:.2f},{y * scale:.2f}" for x, y in body_wps) ET.SubElement( svg, "polyline", @@ -262,18 +295,26 @@ def _render_edge( "points": points_str, "fill": "none", "stroke": _EDGE_COLOUR, - "stroke-width": "1.2", - "marker-end": "url(#arrowhead)", + "stroke-width": "1.5", + "stroke-linejoin": "round", + "stroke-linecap": "round", }, ) + # Arrowhead: filled triangle (tip, left wing, right wing). + lx = base_x - _ARROW_HALF_W * ndy + ly = base_y + _ARROW_HALF_W * ndx + rx = base_x + _ARROW_HALF_W * ndy + ry = base_y - _ARROW_HALF_W * ndx + arrow_pts = " ".join( + f"{x * scale:.2f},{y * scale:.2f}" for x, y in [(x2, y2), (lx, ly), (rx, ry)] + ) + ET.SubElement(svg, "polygon", {"points": arrow_pts, "fill": _EDGE_COLOUR}) + # Label at the midpoint of the edge. mid = len(waypoints) // 2 - x1, y1 = waypoints[mid - 1] - x2, y2 = waypoints[mid] - mx = (x1 + x2) / 2 * scale - my = (y1 + y2) / 2 * scale - 4 * scale - + mx = (waypoints[mid - 1][0] + waypoints[mid][0]) / 2 * scale + my = (waypoints[mid - 1][1] + waypoints[mid][1]) / 2 * scale - 4 * scale text = ET.SubElement( svg, "text", diff --git a/tests/views/backend/test_diagram.py b/tests/views/backend/test_diagram.py index f2cbb34..717e7e7 100644 --- a/tests/views/backend/test_diagram.py +++ b/tests/views/backend/test_diagram.py @@ -249,6 +249,24 @@ def test_render_edge_label_present(tmp_path: Path) -> None: assert "PaymentRequest" in _text_content(root) +def test_render_node_text_has_clip_path(tmp_path: Path) -> None: + """Each node text element references a clip-path to prevent label overflow.""" + comp = Component(name="Sys", components=[Component(name="LongComponentNameThatMightOverflow")]) + root = _render_and_parse(comp, tmp_path) + texts = [el for el in root.iter(f"{{{_SVG_NS}}}text") if "clip-path" in el.attrib] + assert len(texts) >= 1 + + +def test_render_clip_paths_defined_in_defs(tmp_path: Path) -> None: + """```` elements for node labels are defined inside ````.""" + comp = Component(name="Sys", components=[Component(name="A"), Component(name="B")]) + root = _render_and_parse(comp, tmp_path) + defs = root.find(f"{{{_SVG_NS}}}defs") + assert defs is not None + clip_paths = list(defs.iter(f"{{{_SVG_NS}}}clipPath")) + assert len(clip_paths) >= 2 # one per child node + + def test_render_edge_polyline_present(tmp_path: Path) -> None: """An edge between two children produces at least one ```` element.""" a = Component(name="A", requires=[_iref("IFace")]) @@ -263,15 +281,21 @@ def test_render_edge_polyline_present(tmp_path: Path) -> None: assert len(polylines) >= 1 -def test_render_arrowhead_marker_defined(tmp_path: Path) -> None: - """The SVG ```` section contains an arrowhead marker.""" - comp = Component(name="Sys", components=[Component(name="A")]) - root = _render_and_parse(comp, tmp_path) - defs = root.find(f"{{{_SVG_NS}}}defs") - assert defs is not None - marker = defs.find(f"{{{_SVG_NS}}}marker") - assert marker is not None - assert marker.attrib.get("id") == "arrowhead" +def test_render_edge_has_explicit_arrowhead_polygon(tmp_path: Path) -> None: + """An edge produces an explicit filled ```` arrowhead in the SVG.""" + a = Component(name="A", requires=[_iref("IFace")]) + b = Component(name="B", provides=[_iref("IFace")]) + sys = System( + name="Root", + components=[a, b], + connections=[_conn("A", "B", "IFace")], + ) + root = _render_and_parse(sys, tmp_path) + polygons = list(root.iter(f"{{{_SVG_NS}}}polygon")) + assert len(polygons) >= 1 + # The arrowhead polygon must have a fill attribute (not transparent). + fills = {p.attrib.get("fill") for p in polygons} + assert any(f and f != "none" for f in fills) # ############### From 4b45c5ae029bbca0d1207d48c140ff76531eda3e Mon Sep 17 00:00:00 2001 From: Andi Hellmund Date: Wed, 4 Mar 2026 00:47:41 +0100 Subject: [PATCH 3/3] user in parser --- src/archml/compiler/parser.py | 58 ++++++++++++- src/archml/compiler/scanner.py | 2 + src/archml/compiler/semantic_analysis.py | 86 +++++++++++++++++-- src/archml/model/entities.py | 20 +++++ src/archml/views/backend/diagram.py | 16 ++-- src/archml/views/topology.py | 26 ++++-- tests/compiler/test_parser.py | 103 +++++++++++++++++++++++ tests/compiler/test_scanner.py | 1 + tests/compiler/test_semantic_analysis.py | 103 +++++++++++++++++++++++ tests/views/test_topology.py | 83 +++++++++++++++++- 10 files changed, 470 insertions(+), 28 deletions(-) diff --git a/src/archml/compiler/parser.py b/src/archml/compiler/parser.py index 7083118..53755e3 100644 --- a/src/archml/compiler/parser.py +++ b/src/archml/compiler/parser.py @@ -18,6 +18,7 @@ InterfaceRef, System, TypeDef, + UserDef, ) from archml.model.types import ( DirectoryTypeRef, @@ -76,6 +77,7 @@ def parse(source: str) -> ArchFile: { TokenType.SYSTEM, TokenType.COMPONENT, + TokenType.USER, TokenType.INTERFACE, TokenType.TYPE, TokenType.ENUM, @@ -202,6 +204,8 @@ def _parse_top_level(self, result: ArchFile) -> None: result.components.append(self._parse_component(is_external=False)) elif tok.type == TokenType.SYSTEM: result.systems.append(self._parse_system(is_external=False)) + elif tok.type == TokenType.USER: + result.users.append(self._parse_user(is_external=False)) elif tok.type == TokenType.EXTERNAL: self._advance() # consume 'external' inner = self._current() @@ -209,9 +213,11 @@ def _parse_top_level(self, result: ArchFile) -> None: result.components.append(self._parse_component(is_external=True)) elif inner.type == TokenType.SYSTEM: result.systems.append(self._parse_system(is_external=True)) + elif inner.type == TokenType.USER: + result.users.append(self._parse_user(is_external=True)) else: raise ParseError( - f"Expected 'component' or 'system' after 'external', got {inner.value!r}", + f"Expected 'component', 'system', or 'user' after 'external', got {inner.value!r}", inner.line, inner.column, ) @@ -440,6 +446,8 @@ def _parse_system(self, is_external: bool) -> System: system.components.append(self._parse_component(is_external=False)) elif self._check(TokenType.SYSTEM): system.systems.append(self._parse_system(is_external=False)) + elif self._check(TokenType.USER): + system.users.append(self._parse_user(is_external=False)) elif self._check(TokenType.EXTERNAL): self._advance() # consume 'external' inner = self._current() @@ -447,9 +455,12 @@ def _parse_system(self, is_external: bool) -> System: system.components.append(self._parse_component(is_external=True)) elif inner.type == TokenType.SYSTEM: system.systems.append(self._parse_system(is_external=True)) + elif inner.type == TokenType.USER: + system.users.append(self._parse_user(is_external=True)) else: raise ParseError( - f"Expected 'component' or 'system' after 'external' inside system body, got {inner.value!r}", + f"Expected 'component', 'system', or 'user' after 'external'" + f" inside system body, got {inner.value!r}", inner.line, inner.column, ) @@ -468,7 +479,7 @@ def _parse_system(self, is_external: bool) -> System: return system def _parse_use_statement(self, system: System) -> None: - """Parse: use component | use system . + """Parse: use component | use system | use user . Creates a stub entity in the system. The validation layer resolves stubs to their imported definitions. @@ -483,13 +494,52 @@ def _parse_use_statement(self, system: System) -> None: self._advance() name_tok = self._expect(TokenType.IDENTIFIER) system.systems.append(System(name=name_tok.value)) + elif kind.type == TokenType.USER: + self._advance() + name_tok = self._expect(TokenType.IDENTIFIER) + system.users.append(UserDef(name=name_tok.value)) else: raise ParseError( - f"Expected 'component' or 'system' after 'use', got {kind.value!r}", + f"Expected 'component', 'system', or 'user' after 'use', got {kind.value!r}", kind.line, kind.column, ) + # ------------------------------------------------------------------ + # User declarations + # ------------------------------------------------------------------ + + def _parse_user(self, is_external: bool) -> UserDef: + """Parse: [external] user { [attrs] (requires|provides)* } + + Users are leaf nodes: they support title, description, tags, requires, + and provides, but no sub-entities or connections. + """ + self._expect(TokenType.USER) + name_tok = self._expect(TokenType.IDENTIFIER) + self._expect(TokenType.LBRACE) + user = UserDef(name=name_tok.value, is_external=is_external) + while not self._check(TokenType.RBRACE, TokenType.EOF): + if self._check(TokenType.TITLE): + user.title = self._parse_string_attr(TokenType.TITLE) + elif self._check(TokenType.DESCRIPTION): + user.description = self._parse_string_attr(TokenType.DESCRIPTION) + elif self._check(TokenType.TAGS): + user.tags = self._parse_tags() + elif self._check(TokenType.REQUIRES): + user.requires.append(self._parse_interface_ref(TokenType.REQUIRES)) + elif self._check(TokenType.PROVIDES): + user.provides.append(self._parse_interface_ref(TokenType.PROVIDES)) + else: + tok = self._current() + raise ParseError( + f"Unexpected token {tok.value!r} in user body", + tok.line, + tok.column, + ) + self._expect(TokenType.RBRACE) + return user + # ------------------------------------------------------------------ # Connection declarations # ------------------------------------------------------------------ diff --git a/src/archml/compiler/scanner.py b/src/archml/compiler/scanner.py index ab78d3f..42a7322 100644 --- a/src/archml/compiler/scanner.py +++ b/src/archml/compiler/scanner.py @@ -20,6 +20,7 @@ class TokenType(enum.Enum): # Keywords SYSTEM = "system" COMPONENT = "component" + USER = "user" INTERFACE = "interface" TYPE = "type" ENUM = "enum" @@ -124,6 +125,7 @@ def tokenize(source: str) -> list[Token]: _KEYWORDS: dict[str, TokenType] = { "system": TokenType.SYSTEM, "component": TokenType.COMPONENT, + "user": TokenType.USER, "interface": TokenType.INTERFACE, "type": TokenType.TYPE, "enum": TokenType.ENUM, diff --git a/src/archml/compiler/semantic_analysis.py b/src/archml/compiler/semantic_analysis.py index 6851592..8183713 100644 --- a/src/archml/compiler/semantic_analysis.py +++ b/src/archml/compiler/semantic_analysis.py @@ -13,7 +13,7 @@ from dataclasses import dataclass -from archml.model.entities import ArchFile, Component, Connection, EnumDef, InterfaceDef, InterfaceRef, System +from archml.model.entities import ArchFile, Component, Connection, EnumDef, InterfaceDef, InterfaceRef, System, UserDef from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef # ############### @@ -107,9 +107,13 @@ def __init__( ) -> None: self._file = arch_file self._resolved = resolved_imports - # Top-level component and system names visible at file scope. + # Top-level component, system, and user names visible at file scope. # These are valid connection endpoints from within any nested system. - self._file_entity_names: set[str] = {c.name for c in arch_file.components} | {s.name for s in arch_file.systems} + self._file_entity_names: set[str] = ( + {c.name for c in arch_file.components} + | {s.name for s in arch_file.systems} + | {u.name for u in arch_file.users} + ) def analyze(self) -> list[SemanticError]: """Run all semantic checks and return collected errors.""" @@ -180,7 +184,18 @@ def analyze(self) -> list[SemanticError]: ) ) - # 7. Validate import entities against resolved source files. + # 7. Check top-level users. + for user in self._file.users: + errors.extend( + _check_user( + user, + all_interface_plain_names, + local_interface_defs, + imported_names, + ) + ) + + # 8. Validate import entities against resolved source files. errors.extend(self._check_import_resolutions()) return errors @@ -281,12 +296,22 @@ def _check_system( "Duplicate sub-system name '{}' in " + ctx, ) ) + # Check for duplicate user names within this system. + errors.extend( + _check_duplicate_names( + [u.name for u in system.users], + "Duplicate user name '{}' in " + ctx, + ) + ) - # Check for name conflicts between components and sub-systems. + # Check for name conflicts between components, sub-systems, and users. comp_names = {c.name for c in system.components} sys_names = {s.name for s in system.systems} + user_names = {u.name for u in system.users} for name in sorted(comp_names & sys_names): errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a component and a sub-system")) + for name in sorted((comp_names | sys_names) & user_names): + errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a user and a component or sub-system")) # Check requires / provides interface references. for ref in system.requires: @@ -313,12 +338,12 @@ def _check_system( ) # Connection endpoints in a system may reference: - # 1. Direct members of this system (components and sub-systems), + # 1. Direct members of this system (components, sub-systems, and users), # 2. Top-level entities in the file (e.g. external systems defined # at the top level and referenced in an internal connection), or # 3. Imported names (brought in via `from ... import` and used via - # `use component/system`). - member_names = comp_names | sys_names + # `use component/system/user`). + member_names = comp_names | sys_names | user_names connection_scope = member_names | self._file_entity_names | imported_names for conn in system.connections: errors.extend( @@ -353,6 +378,15 @@ def _check_system( imported_names, ) ) + for user in system.users: + errors.extend( + _check_user( + user, + all_interface_names, + local_interface_defs, + imported_names, + ) + ) return errors @@ -401,6 +435,8 @@ def _assign_qualified_names(arch_file: ArchFile, *, file_key: str | None = None) _assign_component_qualified_names(comp, prefix=file_prefix) for system in arch_file.systems: _assign_system_qualified_names(system, prefix=file_prefix) + for user in arch_file.users: + _assign_user_qualified_name(user, prefix=file_prefix) def _assign_component_qualified_names(comp: Component, prefix: str | None) -> None: @@ -410,6 +446,11 @@ def _assign_component_qualified_names(comp: Component, prefix: str | None) -> No _assign_component_qualified_names(sub, prefix=comp.qualified_name) +def _assign_user_qualified_name(user: UserDef, prefix: str | None) -> None: + """Set the qualified name for a user entity.""" + user.qualified_name = f"{prefix}::{user.name}" if prefix else user.name + + def _assign_system_qualified_names(system: System, prefix: str | None) -> None: """Recursively set qualified names for a system and all its children.""" system.qualified_name = f"{prefix}::{system.name}" if prefix else system.name @@ -417,6 +458,8 @@ def _assign_system_qualified_names(system: System, prefix: str | None) -> None: _assign_component_qualified_names(comp, prefix=system.qualified_name) for sub_sys in system.systems: _assign_system_qualified_names(sub_sys, prefix=system.qualified_name) + for user in system.users: + _assign_user_qualified_name(user, prefix=system.qualified_name) def _check_duplicate_imports(arch_file: ArchFile) -> list[SemanticError]: @@ -451,6 +494,7 @@ def _collect_all_top_level_names(arch_file: ArchFile) -> set[str]: names.update(i.name for i in arch_file.interfaces) names.update(c.name for c in arch_file.components) names.update(s.name for s in arch_file.systems) + names.update(u.name for u in arch_file.users) return names @@ -517,6 +561,12 @@ def _check_top_level_duplicates(arch_file: ArchFile) -> list[SemanticError]: "Duplicate system name '{}'", ) ) + errors.extend( + _check_duplicate_names( + [u.name for u in arch_file.users], + "Duplicate user name '{}'", + ) + ) # An enum and a type with the same name create ambiguity for field type # references. @@ -609,6 +659,26 @@ def _check_interface_ref( return errors +def _check_user( + user: UserDef, + all_interface_names: set[str], + local_interface_defs: dict[tuple[str, str | None], InterfaceDef], + imported_names: set[str], +) -> list[SemanticError]: + """Check requires/provides interface references on a user entity.""" + errors: list[SemanticError] = [] + ctx = f"user '{user.name}'" + for ref in user.requires: + errors.extend( + _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires") + ) + for ref in user.provides: + errors.extend( + _check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides") + ) + return errors + + def _check_connection( ctx: str, conn: Connection, diff --git a/src/archml/model/entities.py b/src/archml/model/entities.py index 6eaf9b8..c495171 100644 --- a/src/archml/model/entities.py +++ b/src/archml/model/entities.py @@ -71,6 +71,23 @@ class Connection(BaseModel): description: str | None = None +class UserDef(BaseModel): + """A human actor (role or persona) that interacts with the system. + + Users are leaf nodes: they declare required and provided interfaces but + cannot contain sub-entities or connections. + """ + + name: str + title: str | None = None + description: str | None = None + tags: list[str] = _Field(default_factory=list) + requires: list[InterfaceRef] = _Field(default_factory=list) + provides: list[InterfaceRef] = _Field(default_factory=list) + is_external: bool = False + qualified_name: str = "" + + class Component(BaseModel): """A module with declared interface ports and optional nested sub-components.""" @@ -97,6 +114,7 @@ class System(BaseModel): provides: list[InterfaceRef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) systems: list[System] = _Field(default_factory=list) + users: list[UserDef] = _Field(default_factory=list) connections: list[Connection] = _Field(default_factory=list) is_external: bool = False qualified_name: str = "" @@ -118,8 +136,10 @@ class ArchFile(BaseModel): interfaces: list[InterfaceDef] = _Field(default_factory=list) components: list[Component] = _Field(default_factory=list) systems: list[System] = _Field(default_factory=list) + users: list[UserDef] = _Field(default_factory=list) # Resolve forward references in self-referential models. Component.model_rebuild() System.model_rebuild() +ArchFile.model_rebuild() diff --git a/src/archml/views/backend/diagram.py b/src/archml/views/backend/diagram.py index 488006a..ed69c97 100644 --- a/src/archml/views/backend/diagram.py +++ b/src/archml/views/backend/diagram.py @@ -78,6 +78,8 @@ def render_diagram( _STROKE_COMPONENT = "#448844" _FILL_SYSTEM = "#ddeeff" _STROKE_SYSTEM = "#4466aa" +_FILL_USER = "#fef3e2" +_STROKE_USER = "#b36200" _FILL_EXTERNAL = "#f0e8f0" _STROKE_EXTERNAL = "#664488" _FILL_TERMINAL = "#fff8e1" @@ -93,11 +95,11 @@ def render_diagram( _STROKE_WIDTH = 1.5 _BOUNDARY_STROKE_WIDTH = 2.0 _BOUNDARY_LABEL_OFFSET = 14.0 # y distance from boundary top to title baseline -_LABEL_PADDING = 6.0 # horizontal padding inside node for text clip region +_LABEL_PADDING = 6.0 # horizontal padding inside node for text clip region # Arrowhead geometry (layout units, before scaling). -_ARROW_LEN = 10.0 # length of the arrowhead along the edge direction -_ARROW_HALF_W = 4.0 # half-width of the arrowhead base +_ARROW_LEN = 10.0 # length of the arrowhead along the edge direction +_ARROW_HALF_W = 4.0 # half-width of the arrowhead base def _node_colours(kind: NodeKind | None) -> tuple[str, str]: @@ -106,7 +108,9 @@ def _node_colours(kind: NodeKind | None) -> tuple[str, str]: return _FILL_COMPONENT, _STROKE_COMPONENT if kind == "system": return _FILL_SYSTEM, _STROKE_SYSTEM - if kind in ("external_component", "external_system"): + if kind == "user": + return _FILL_USER, _STROKE_USER + if kind in ("external_component", "external_system", "external_user"): return _FILL_EXTERNAL, _STROKE_EXTERNAL return _FILL_TERMINAL, _STROKE_TERMINAL @@ -306,9 +310,7 @@ def _render_edge( ly = base_y + _ARROW_HALF_W * ndx rx = base_x + _ARROW_HALF_W * ndy ry = base_y - _ARROW_HALF_W * ndx - arrow_pts = " ".join( - f"{x * scale:.2f},{y * scale:.2f}" for x, y in [(x2, y2), (lx, ly), (rx, ry)] - ) + arrow_pts = " ".join(f"{x * scale:.2f},{y * scale:.2f}" for x, y in [(x2, y2), (lx, ly), (rx, ry)]) ET.SubElement(svg, "polygon", {"points": arrow_pts, "fill": _EDGE_COLOUR}) # Label at the midpoint of the edge. diff --git a/src/archml/views/topology.py b/src/archml/views/topology.py index a6545ec..38bfaa2 100644 --- a/src/archml/views/topology.py +++ b/src/archml/views/topology.py @@ -34,13 +34,13 @@ from dataclasses import dataclass, field from typing import Literal -from archml.model.entities import Component, InterfaceRef, System +from archml.model.entities import Component, InterfaceRef, System, UserDef # ############### # Public Interface # ############### -NodeKind = Literal["component", "system", "external_component", "external_system", "terminal"] +NodeKind = Literal["component", "system", "user", "external_component", "external_system", "external_user", "terminal"] """Semantic classification of a :class:`VizNode`.""" BoundaryKind = Literal["component", "system"] @@ -224,7 +224,7 @@ class VizDiagram: def build_viz_diagram( entity: Component | System, *, - external_entities: dict[str, Component | System] | None = None, + external_entities: dict[str, Component | System | UserDef] | None = None, ) -> VizDiagram: """Build a :class:`VizDiagram` topology from a model entity. @@ -268,6 +268,9 @@ def build_viz_diagram( for sys in entity.systems: child_path = f"{entity_path}::{sys.name}" child_node_map[sys.name] = _make_child_node(sys, child_path) + for user in entity.users: + child_path = f"{entity_path}::{user.name}" + child_node_map[user.name] = _make_child_node(user, child_path) # --- Root boundary --- root_ports = _make_ports(root_id, entity) @@ -412,7 +415,7 @@ def _make_port( ) -def _make_ports(node_id: str, entity: Component | System) -> list[VizPort]: +def _make_ports(node_id: str, entity: Component | System | UserDef) -> list[VizPort]: """Create :class:`VizPort` instances for all requires and provides of *entity*.""" ports: list[VizPort] = [] for ref in entity.requires: @@ -422,13 +425,15 @@ def _make_ports(node_id: str, entity: Component | System) -> list[VizPort]: return ports -def _make_child_node(entity: Component | System, entity_path: str) -> VizNode: +def _make_child_node(entity: Component | System | UserDef, entity_path: str) -> VizNode: """Create a :class:`VizNode` for a direct child of the focus entity.""" node_id = _make_id(entity_path) if isinstance(entity, Component): kind: NodeKind = "external_component" if entity.is_external else "component" - else: + elif isinstance(entity, System): kind = "external_system" if entity.is_external else "system" + else: + kind = "external_user" if entity.is_external else "user" return VizNode( id=node_id, label=entity.name, @@ -441,7 +446,7 @@ def _make_child_node(entity: Component | System, entity_path: str) -> VizNode: ) -def _make_external_node(name: str, entity: Component | System | None) -> VizNode: +def _make_external_node(name: str, entity: Component | System | UserDef | None) -> VizNode: """Create a :class:`VizNode` for an external connection endpoint. When *entity* is provided its full metadata is used; otherwise a minimal @@ -450,7 +455,12 @@ def _make_external_node(name: str, entity: Component | System | None) -> VizNode if entity is not None: path = entity.qualified_name or name node_id = _make_id(path) - kind: NodeKind = "external_component" if isinstance(entity, Component) else "external_system" + if isinstance(entity, Component): + kind: NodeKind = "external_component" + elif isinstance(entity, System): + kind = "external_system" + else: + kind = "external_user" return VizNode( id=node_id, label=entity.name, diff --git a/tests/compiler/test_parser.py b/tests/compiler/test_parser.py index 432936f..40b5784 100644 --- a/tests/compiler/test_parser.py +++ b/tests/compiler/test_parser.py @@ -47,6 +47,7 @@ def test_empty_string_returns_empty_arch_file(self) -> None: assert result.interfaces == [] assert result.components == [] assert result.systems == [] + assert result.users == [] def test_whitespace_only_returns_empty_arch_file(self) -> None: result = _parse(" \t\n\n ") @@ -1918,3 +1919,105 @@ def test_interface_field_empty_annotation_block(self) -> None: field = result.interfaces[0].fields[0] assert field.description is None assert field.schema_ref is None + + +# ############### +# User Declarations +# ############### + + +class TestUserDeclarations: + def test_minimal_user(self) -> None: + result = _parse("user Customer {}") + assert len(result.users) == 1 + u = result.users[0] + assert u.name == "Customer" + assert u.title is None + assert u.description is None + assert u.tags == [] + assert u.requires == [] + assert u.provides == [] + assert u.is_external is False + + def test_user_with_title_and_description(self) -> None: + source = 'user Customer { title = "Customer" description = "An end user." }' + result = _parse(source) + u = result.users[0] + assert u.title == "Customer" + assert u.description == "An end user." + + def test_user_with_tags(self) -> None: + result = _parse('user Customer { tags = ["external", "persona"] }') + u = result.users[0] + assert u.tags == ["external", "persona"] + + def test_user_with_requires(self) -> None: + result = _parse("user Customer { requires OrderConfirmation }") + u = result.users[0] + assert len(u.requires) == 1 + assert u.requires[0].name == "OrderConfirmation" + assert u.requires[0].version is None + + def test_user_with_provides(self) -> None: + result = _parse("user Customer { provides OrderRequest }") + u = result.users[0] + assert len(u.provides) == 1 + assert u.provides[0].name == "OrderRequest" + + def test_user_with_versioned_interface(self) -> None: + result = _parse("user Customer { requires OrderConfirmation @v2 }") + u = result.users[0] + assert u.requires[0].version == "v2" + + def test_user_with_multiple_interfaces(self) -> None: + source = "user Customer { requires OrderConfirmation provides OrderRequest }" + result = _parse(source) + u = result.users[0] + assert len(u.requires) == 1 + assert len(u.provides) == 1 + + def test_external_user(self) -> None: + result = _parse("external user LegacyClient {}") + assert len(result.users) == 1 + u = result.users[0] + assert u.name == "LegacyClient" + assert u.is_external is True + + def test_multiple_top_level_users(self) -> None: + source = "user A {} user B {}" + result = _parse(source) + assert len(result.users) == 2 + assert result.users[0].name == "A" + assert result.users[1].name == "B" + + def test_user_inline_in_system(self) -> None: + source = "system S { user Customer {} }" + result = _parse(source) + s = result.systems[0] + assert len(s.users) == 1 + assert s.users[0].name == "Customer" + assert s.users[0].is_external is False + + def test_external_user_inline_in_system(self) -> None: + source = "system S { external user LegacyClient {} }" + result = _parse(source) + assert result.systems[0].users[0].is_external is True + + def test_use_user_in_system(self) -> None: + source = "system S { use user Customer }" + result = _parse(source) + s = result.systems[0] + assert len(s.users) == 1 + assert s.users[0].name == "Customer" + + def test_user_body_disallows_component_keyword(self) -> None: + with pytest.raises(ParseError): + _parse("user Customer { component Sub {} }") + + def test_user_body_disallows_connect_keyword(self) -> None: + with pytest.raises(ParseError): + _parse("user Customer { connect A -> B by X }") + + def test_external_invalid_keyword_after_raises(self) -> None: + with pytest.raises(ParseError, match="Expected 'component', 'system', or 'user'"): + _parse("external interface I {}") diff --git a/tests/compiler/test_scanner.py b/tests/compiler/test_scanner.py index 2f00800..3f5e162 100644 --- a/tests/compiler/test_scanner.py +++ b/tests/compiler/test_scanner.py @@ -71,6 +71,7 @@ class TestKeywords: [ ("system", TokenType.SYSTEM), ("component", TokenType.COMPONENT), + ("user", TokenType.USER), ("interface", TokenType.INTERFACE), ("type", TokenType.TYPE), ("enum", TokenType.ENUM), diff --git a/tests/compiler/test_semantic_analysis.py b/tests/compiler/test_semantic_analysis.py index 33cd938..bc9a08f 100644 --- a/tests/compiler/test_semantic_analysis.py +++ b/tests/compiler/test_semantic_analysis.py @@ -1263,3 +1263,106 @@ def test_multiple_top_level_interfaces_with_same_name_from_different_files_repre analyze(arch_file) assert arch_file.interfaces[0].qualified_name == "Foo" assert arch_file.interfaces[1].qualified_name == "Foo@v2" + + +# ############### +# User Checks +# ############### + + +class TestUserSemantics: + def test_user_with_valid_interface_refs(self) -> None: + _assert_clean(""" +interface OrderRequest {} +interface OrderConfirmation {} +user Customer { + provides OrderRequest + requires OrderConfirmation +} +""") + + def test_user_unknown_requires_interface(self) -> None: + _assert_error( + "user Customer { requires Unknown }", + "unknown interface 'Unknown'", + ) + + def test_user_unknown_provides_interface(self) -> None: + _assert_error( + "user Customer { provides Unknown }", + "unknown interface 'Unknown'", + ) + + def test_duplicate_top_level_user(self) -> None: + _assert_error( + "user A {} user A {}", + "Duplicate user name 'A'", + ) + + def test_duplicate_user_in_system(self) -> None: + _assert_error( + "system S { user A {} user A {} }", + "Duplicate user name 'A'", + ) + + def test_user_name_conflicts_with_component_in_system(self) -> None: + _assert_error( + "system S { component A {} user A {} }", + "name 'A' is used for both a user and a component or sub-system", + ) + + def test_user_name_conflicts_with_system_in_system(self) -> None: + _assert_error( + "system S { system Sub {} user Sub {} }", + "name 'Sub' is used for both a user and a component or sub-system", + ) + + def test_user_as_connection_endpoint_in_system(self) -> None: + _assert_clean(""" +interface OrderRequest {} +user Customer { provides OrderRequest } +component OrderService { requires OrderRequest } +system S { + use user Customer + use component OrderService + connect Customer -> OrderService by OrderRequest +} +""") + + def test_top_level_user_as_connection_endpoint(self) -> None: + _assert_clean(""" +interface I {} +user A { provides I } +component B { requires I } +system S { + use component B + connect A -> B by I +} +""") + + def test_user_qualified_name_top_level(self) -> None: + arch_file = parse("user Customer {}") + analyze(arch_file, file_key="myapp/actors") + assert arch_file.users[0].qualified_name == "myapp/actors::Customer" + + def test_user_qualified_name_no_file_key(self) -> None: + arch_file = parse("user Customer {}") + analyze(arch_file) + assert arch_file.users[0].qualified_name == "Customer" + + def test_user_qualified_name_in_system(self) -> None: + arch_file = parse("system S { user Customer {} }") + analyze(arch_file) + assert arch_file.systems[0].users[0].qualified_name == "S::Customer" + + def test_external_user_valid(self) -> None: + _assert_clean(""" +interface I {} +external user ExternalClient { provides I } +""") + + def test_user_versioned_interface_ref(self) -> None: + _assert_clean(""" +interface I @v2 {} +user Customer { requires I @v2 } +""") diff --git a/tests/views/test_topology.py b/tests/views/test_topology.py index 787dddd..9c70582 100644 --- a/tests/views/test_topology.py +++ b/tests/views/test_topology.py @@ -3,7 +3,7 @@ """Tests for the abstract visualization topology model and its builder.""" -from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System +from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System, UserDef from archml.views.topology import ( VizBoundary, VizNode, @@ -678,3 +678,84 @@ def test_ecommerce_system_topology() -> None: for edge in diag.edges: assert edge.source_port_id in all_ports assert edge.target_port_id in all_ports + + +# ############### +# User nodes +# ############### + + +def test_user_as_child_node_of_system_has_kind_user() -> None: + """A UserDef inside a System becomes a child VizNode with kind 'user'.""" + customer = UserDef( + name="Customer", + requires=[InterfaceRef(name="OrderConfirmation")], + provides=[InterfaceRef(name="OrderRequest")], + ) + system = System(name="ECommerce", users=[customer]) + diag = build_viz_diagram(system) + child_ids = _child_ids(diag.root) + assert "ECommerce__Customer" in child_ids + customer_node = next(c for c in diag.root.children if c.id == "ECommerce__Customer") + assert isinstance(customer_node, VizNode) + assert customer_node.kind == "user" + + +def test_external_user_child_has_kind_external_user() -> None: + """An external UserDef child gets kind 'external_user'.""" + ext_user = UserDef(name="Partner", is_external=True) + system = System(name="S", users=[ext_user]) + diag = build_viz_diagram(system) + child_node = next(c for c in diag.root.children if c.label == "Partner") + assert isinstance(child_node, VizNode) + assert child_node.kind == "external_user" + + +def test_user_child_ports_are_built() -> None: + """Ports are created for a user's requires and provides declarations.""" + customer = UserDef( + name="Customer", + requires=[InterfaceRef(name="OrderConfirmation")], + provides=[InterfaceRef(name="OrderRequest")], + ) + system = System(name="S", users=[customer]) + diag = build_viz_diagram(system) + customer_node = next(c for c in diag.root.children if c.label == "Customer") + assert isinstance(customer_node, VizNode) + directions = {p.direction for p in customer_node.ports} + assert "requires" in directions + assert "provides" in directions + + +def test_user_as_connection_endpoint() -> None: + """A user can be a source or target of a connection edge.""" + iref = InterfaceRef(name="OrderRequest") + customer = UserDef(name="Customer", provides=[iref]) + order_svc = Component(name="OrderService", requires=[iref]) + conn = _conn("Customer", "OrderService", "OrderRequest") + system = System( + name="ECommerce", + users=[customer], + components=[order_svc], + connections=[conn], + ) + diag = build_viz_diagram(system) + assert len(diag.edges) == 1 + edge = diag.edges[0] + assert "Customer" in edge.source_port_id + assert "OrderService" in edge.target_port_id + all_ports = collect_all_ports(diag) + assert edge.source_port_id in all_ports + assert edge.target_port_id in all_ports + + +def test_external_user_as_peripheral_node() -> None: + """A user supplied via external_entities appears as an external_user peripheral node.""" + iref = InterfaceRef(name="Report") + ext_user = UserDef(name="Analyst", provides=[iref]) + comp = Component(name="ReportService", requires=[iref]) + conn = _conn("Analyst", "ReportService", "Report") + system = System(name="S", components=[comp], connections=[conn]) + diag = build_viz_diagram(system, external_entities={"Analyst": ext_user}) + kinds = {n.kind for n in diag.peripheral_nodes} + assert "external_user" in kinds