From ea6fd6032ae2bd6d9c39466e8094b664f10f4a49 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:06:11 +0000 Subject: [PATCH] feat: add first visualization for architecture entities Implements the `archml visualize` CLI command that renders a box diagram for a specified system or component using the pydiagrams library. Key changes: - `src/archml/views/resolver.py`: entity resolver for `::` delimited paths (e.g. `SystemA::ComponentB::Sub`) across compiled ArchFile models. - `src/archml/views/diagram.py`: `build_diagram_data()` builds a framework-agnostic `DiagramData` from a model entity; `render_diagram()` delegates to pydiagrams for the actual image output. - `src/archml/cli/main.py`: new `visualize [directory]` subcommand; gracefully handles missing pydiagrams with a clear error. - `pyproject.toml`: adds `pydiagrams>=0.1` as a project dependency. - Tests in `tests/views/test_resolver.py` and `tests/views/test_diagram.py` cover resolution logic and diagram building; the renderer is tested with a mocked pydiagrams so no real rendering is required in CI. - `tests/cli/test_main.py`: extended with tests for the visualize command. Closes #42 Co-authored-by: Andi Hellmund --- pyproject.toml | 2 +- src/archml/cli/main.py | 91 ++++++++++++ src/archml/views/diagram.py | 183 +++++++++++++++++++++++ src/archml/views/resolver.py | 108 ++++++++++++++ tests/cli/test_main.py | 64 ++++++++ tests/views/test_diagram.py | 274 +++++++++++++++++++++++++++++++++++ tests/views/test_resolver.py | 134 +++++++++++++++++ 7 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 src/archml/views/diagram.py create mode 100644 src/archml/views/resolver.py create mode 100644 tests/views/test_diagram.py create mode 100644 tests/views/test_resolver.py diff --git a/pyproject.toml b/pyproject.toml index f213efe..ceeeee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "A text-based DSL for defining software architecture alongside cod readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12" -dependencies = ["dash>=2.0", "pydantic>=2.0", "pyyaml>=6.0"] +dependencies = ["dash>=2.0", "pydiagrams>=0.1", "pydantic>=2.0", "pyyaml>=6.0"] [project.scripts] archml = "archml.cli.main:main" diff --git a/src/archml/cli/main.py b/src/archml/cli/main.py index 4005812..46f4a34 100644 --- a/src/archml/cli/main.py +++ b/src/archml/cli/main.py @@ -76,6 +76,27 @@ def main() -> None: help="Host to bind the server to (default: 127.0.0.1)", ) + # visualize subcommand + visualize_parser = subparsers.add_parser( + "visualize", + help="Generate a diagram for a system or component", + description="Render a box diagram for the specified architecture entity.", + ) + visualize_parser.add_argument( + "entity", + help="Entity path to visualize (e.g. 'SystemA' or 'SystemA::ComponentB')", + ) + visualize_parser.add_argument( + "output", + help="Output file path for the rendered diagram (e.g. 'diagram.png')", + ) + visualize_parser.add_argument( + "directory", + nargs="?", + default=".", + help="Directory containing the ArchML workspace (default: current directory)", + ) + args = parser.parse_args() if args.command is None: parser.print_help() @@ -99,6 +120,8 @@ def _dispatch(args: argparse.Namespace) -> int: return _cmd_check(args) if args.command == "serve": return _cmd_serve(args) + if args.command == "visualize": + return _cmd_visualize(args) return 0 @@ -192,6 +215,74 @@ def _cmd_check(args: argparse.Namespace) -> int: return 0 +def _cmd_visualize(args: argparse.Namespace) -> int: + """Handle the visualize subcommand.""" + from archml.views.diagram import build_diagram_data, render_diagram + from archml.views.resolver import EntityNotFoundError, resolve_entity + from archml.workspace.config import LocalPathImport + + directory = Path(args.directory).resolve() + + if not directory.exists(): + print(f"Error: directory '{directory}' does not exist.", file=sys.stderr) + return 1 + + workspace_yaml = directory / ".archml-workspace.yaml" + + if not workspace_yaml.exists(): + print( + f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.", + file=sys.stderr, + ) + return 1 + + source_import_map: dict[str, Path] = {"": directory} + + try: + config = load_workspace_config(workspace_yaml) + except WorkspaceConfigError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + build_dir = directory / config.build_directory + + for imp in config.source_imports: + if isinstance(imp, LocalPathImport): + source_import_map[imp.name] = (directory / imp.local_path).resolve() + + archml_files = [f for f in directory.rglob("*.archml") if build_dir not in f.parents] + if not archml_files: + print("No .archml files found in the workspace.", file=sys.stderr) + return 1 + + try: + compiled = compile_files(archml_files, build_dir, source_import_map) + except CompilerError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + try: + entity = resolve_entity(compiled, args.entity) + except EntityNotFoundError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + output_path = Path(args.output) + data = build_diagram_data(entity) + + try: + render_diagram(data, output_path) + except ImportError: + print( + "Error: 'pydiagrams' is not installed. Run 'pip install pydiagrams' to enable visualization.", + file=sys.stderr, + ) + return 1 + + print(f"Diagram written to '{output_path}'.") + return 0 + + def _cmd_serve(args: argparse.Namespace) -> int: """Handle the serve subcommand.""" directory = Path(args.directory).resolve() diff --git a/src/archml/views/diagram.py b/src/archml/views/diagram.py new file mode 100644 index 0000000..46c7e04 --- /dev/null +++ b/src/archml/views/diagram.py @@ -0,0 +1,183 @@ +# 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 ``pydiagrams`` library. + +The diagram shows: +- The target entity as the outer container / title. +- All direct child components and systems as inner boxes. +- The target entity's ``requires`` interfaces as incoming terminal elements. +- The target entity's ``provides`` interfaces as outgoing terminal elements. +- Connections between child entities as labelled arrows. +""" + +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 ``pydiagrams``. + + The output format is determined by the file extension of *output_path* + (e.g. ``.png``, ``.svg``). + + Args: + data: The diagram description to render. + output_path: Destination file path for the rendered image. + + Raises: + ImportError: If the ``pydiagrams`` package is not installed. + """ + from pydiagrams import ComponentDiagram # type: ignore[import-untyped] + + diag = ComponentDiagram(title=data.title, description=data.description or "") + + for terminal in data.terminals: + diag.add_interface(name=terminal.name, direction=terminal.direction) + + for child in data.children: + diag.add_component(name=child.name, description=child.description or "") + + for conn in data.connections: + diag.add_connection(source=conn.source, target=conn.target, label=conn.label) + + diag.render(str(output_path)) + + +# ################ +# 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/src/archml/views/resolver.py b/src/archml/views/resolver.py new file mode 100644 index 0000000..f16c7d7 --- /dev/null +++ b/src/archml/views/resolver.py @@ -0,0 +1,108 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Entity resolution for ArchML views. + +Resolves ``::``-delimited entity paths (e.g. ``SystemA::ComponentB::Sub``) +to the corresponding :class:`~archml.model.entities.Component` or +:class:`~archml.model.entities.System` in a compiled model. +""" + +from __future__ import annotations + +from archml.model.entities import ArchFile, Component, System + +# ############### +# Public Interface +# ############### + + +class EntityNotFoundError(Exception): + """Raised when an entity path cannot be resolved in the model.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + + +def resolve_entity( + arch_files: dict[str, ArchFile], + path: str, +) -> Component | System: + """Resolve a ``::``-delimited entity path to a model entity. + + The first path segment identifies a top-level system or component (by + name) across all provided *arch_files*. Subsequent segments navigate + into nested systems or components of the previously found entity. + + Examples:: + + resolve_entity(files, "SystemA") + resolve_entity(files, "SystemA::Worker") + resolve_entity(files, "SystemA::SubSystem::ComponentX") + + Args: + arch_files: Mapping from canonical file key to compiled + :class:`~archml.model.entities.ArchFile`. + path: ``::``-delimited path string identifying the target entity. + + Returns: + The resolved :class:`~archml.model.entities.Component` or + :class:`~archml.model.entities.System`. + + Raises: + EntityNotFoundError: If any segment along the path cannot be found. + """ + segments = [s.strip() for s in path.split("::")] + if not segments or not any(segments): + raise EntityNotFoundError(f"Invalid entity path: '{path}'") + + first = segments[0] + entity: Component | System | None = _find_top_level(arch_files, first) + if entity is None: + raise EntityNotFoundError( + f"Top-level entity '{first}' not found in any architecture file" + ) + + for segment in segments[1:]: + entity = _find_child(entity, segment) + if entity is None: + raise EntityNotFoundError( + f"Entity '{segment}' not found in path '{path}'" + ) + + return entity + + +# ################ +# Implementation +# ################ + + +def _find_top_level( + arch_files: dict[str, ArchFile], + name: str, +) -> Component | System | None: + """Search all arch files for a top-level component or system by name.""" + for arch_file in arch_files.values(): + for system in arch_file.systems: + if system.name == name: + return system + for component in arch_file.components: + if component.name == name: + return component + return None + + +def _find_child( + entity: Component | System, + name: str, +) -> Component | System | None: + """Search the direct children of *entity* for a member named *name*.""" + for comp in entity.components: + if comp.name == name: + return comp + if isinstance(entity, System): + for sub in entity.systems: + if sub.name == name: + return sub + return None diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 32bf03e..d4f08b7 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -344,3 +344,67 @@ def test_serve_custom_host_and_port(tmp_path: Path, monkeypatch: pytest.MonkeyPa main() assert exc_info.value.code == 0 mock_app.run.assert_called_once_with(host="0.0.0.0", port=9000, debug=False) + + +# -------- visualize tests -------- + + +def test_visualize_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when directory does not exist.""" + missing = tmp_path / "nonexistent" + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(missing)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_visualize_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when no workspace file is found.""" + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_visualize_fails_if_no_archml_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when no .archml files are found.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_visualize_fails_if_entity_not_found( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """visualize exits with error code 1 when entity path is not found.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / "arch.archml").write_text("component Worker {}\n") + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "NonExistent", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Error" in captured.err + + +def test_visualize_succeeds_with_mocked_renderer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """visualize exits with code 0 and writes the diagram when entity is found.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (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: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + mock_render.assert_called_once() + captured = capsys.readouterr() + assert "diagram.png" in captured.out diff --git a/tests/views/test_diagram.py b/tests/views/test_diagram.py new file mode 100644 index 0000000..c255dd8 --- /dev/null +++ b/tests/views/test_diagram.py @@ -0,0 +1,274 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for diagram data builder and renderer.""" + +import sys +from pathlib import Path +from types import ModuleType +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 — mocked pydiagrams +# ############### + + +def _make_pydiagrams_mock() -> MagicMock: + """Return a mock pydiagrams module with ComponentDiagram.""" + mock_module = MagicMock(spec=ModuleType) + mock_diagram = MagicMock() + mock_module.ComponentDiagram.return_value = mock_diagram + return mock_module + + +def test_render_diagram_calls_pydiagrams(tmp_path: Path) -> None: + """render_diagram imports pydiagrams and calls ComponentDiagram.""" + mock_pydiagrams = _make_pydiagrams_mock() + data = DiagramData(title="SystemA", description="Test system") + + with patch.dict(sys.modules, {"pydiagrams": mock_pydiagrams}): + render_diagram(data, tmp_path / "out.png") + + mock_pydiagrams.ComponentDiagram.assert_called_once_with( + title="SystemA", description="Test system" + ) + + +def test_render_diagram_adds_terminals(tmp_path: Path) -> None: + """render_diagram calls add_interface for each terminal.""" + mock_pydiagrams = _make_pydiagrams_mock() + mock_diag = mock_pydiagrams.ComponentDiagram.return_value + + data = DiagramData( + title="S", + description=None, + terminals=[ + InterfaceTerminal(name="Input", direction="in"), + InterfaceTerminal(name="Output", direction="out"), + ], + ) + + with patch.dict(sys.modules, {"pydiagrams": mock_pydiagrams}): + render_diagram(data, tmp_path / "out.png") + + calls = [str(c) for c in mock_diag.add_interface.call_args_list] + assert any("Input" in c for c in calls) + assert any("Output" in c for c in calls) + + +def test_render_diagram_adds_children(tmp_path: Path) -> None: + """render_diagram calls add_component for each child box.""" + mock_pydiagrams = _make_pydiagrams_mock() + mock_diag = mock_pydiagrams.ComponentDiagram.return_value + + data = DiagramData( + title="S", + description=None, + children=[ + ChildBox(name="Alpha", description="first", kind="component"), + ChildBox(name="Beta", description=None, kind="system"), + ], + ) + + with patch.dict(sys.modules, {"pydiagrams": mock_pydiagrams}): + render_diagram(data, tmp_path / "out.png") + + assert mock_diag.add_component.call_count == 2 + + +def test_render_diagram_adds_connections(tmp_path: Path) -> None: + """render_diagram calls add_connection for each connection.""" + mock_pydiagrams = _make_pydiagrams_mock() + mock_diag = mock_pydiagrams.ComponentDiagram.return_value + + data = DiagramData( + title="S", + description=None, + connections=[ConnectionData(source="A", target="B", label="IFace")], + ) + + with patch.dict(sys.modules, {"pydiagrams": mock_pydiagrams}): + render_diagram(data, tmp_path / "out.png") + + mock_diag.add_connection.assert_called_once_with(source="A", target="B", label="IFace") + + +def test_render_diagram_calls_render_with_output_path(tmp_path: Path) -> None: + """render_diagram calls diag.render() with the string output path.""" + mock_pydiagrams = _make_pydiagrams_mock() + mock_diag = mock_pydiagrams.ComponentDiagram.return_value + + out = tmp_path / "diagram.svg" + data = DiagramData(title="S", description=None) + + with patch.dict(sys.modules, {"pydiagrams": mock_pydiagrams}): + render_diagram(data, out) + + mock_diag.render.assert_called_once_with(str(out)) + + +def test_render_diagram_raises_import_error_without_pydiagrams(tmp_path: Path) -> None: + """render_diagram raises ImportError when pydiagrams is not installed.""" + data = DiagramData(title="S", description=None) + # Setting pydiagrams to None in sys.modules makes `import pydiagrams` raise ImportError. + with patch.dict(sys.modules, {"pydiagrams": None}): # type: ignore[dict-item] + with pytest.raises(ImportError): + render_diagram(data, tmp_path / "out.png") diff --git a/tests/views/test_resolver.py b/tests/views/test_resolver.py new file mode 100644 index 0000000..035b985 --- /dev/null +++ b/tests/views/test_resolver.py @@ -0,0 +1,134 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the entity path resolver.""" + +import pytest + +from archml.model.entities import ArchFile, Component, System +from archml.views.resolver import EntityNotFoundError, resolve_entity + +# ############### +# Public Interface +# ############### + + +def _make_files(**entities: Component | System) -> dict[str, ArchFile]: + """Build a minimal arch_files dict with the given top-level entities.""" + components = [e for e in entities.values() if isinstance(e, Component)] + systems = [e for e in entities.values() if isinstance(e, System)] + return {"main": ArchFile(components=components, systems=systems)} + + +# -------- top-level resolution -------- + + +def test_resolve_top_level_system() -> None: + """Resolving a bare system name returns that system.""" + sys_a = System(name="SystemA") + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA") + assert result is sys_a + + +def test_resolve_top_level_component() -> None: + """Resolving a bare component name returns that component.""" + comp = Component(name="Worker") + files = _make_files(Worker=comp) + result = resolve_entity(files, "Worker") + assert result is comp + + +def test_resolve_top_level_system_across_multiple_files() -> None: + """Entity is found when it lives in the second of two arch files.""" + comp = Component(name="Auth") + files = { + "file_a": ArchFile(components=[Component(name="Other")]), + "file_b": ArchFile(components=[comp]), + } + result = resolve_entity(files, "Auth") + assert result is comp + + +# -------- nested resolution -------- + + +def test_resolve_nested_component_in_system() -> None: + """Resolving 'SystemA::Worker' returns the Worker component inside SystemA.""" + worker = Component(name="Worker") + sys_a = System(name="SystemA", components=[worker]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA::Worker") + assert result is worker + + +def test_resolve_three_level_path() -> None: + """Resolving 'SystemA::Sub::Leaf' navigates two levels deep.""" + leaf = Component(name="Leaf") + sub = Component(name="Sub", components=[leaf]) + sys_a = System(name="SystemA", components=[sub]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA::Sub::Leaf") + assert result is leaf + + +def test_resolve_nested_system_in_system() -> None: + """Resolving 'Outer::Inner' where Inner is a sub-system.""" + inner = System(name="Inner") + outer = System(name="Outer", systems=[inner]) + files = _make_files(Outer=outer) + result = resolve_entity(files, "Outer::Inner") + assert result is inner + + +def test_resolve_component_in_nested_system() -> None: + """Component inside a nested system is reachable via multi-segment path.""" + comp = Component(name="Worker") + inner = System(name="Inner", components=[comp]) + outer = System(name="Outer", systems=[inner]) + files = _make_files(Outer=outer) + result = resolve_entity(files, "Outer::Inner::Worker") + assert result is comp + + +# -------- whitespace tolerance -------- + + +def test_resolve_path_with_spaces_around_separator() -> None: + """Segments are stripped of surrounding whitespace.""" + worker = Component(name="Worker") + sys_a = System(name="SystemA", components=[worker]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, " SystemA :: Worker ") + assert result is worker + + +# -------- error cases -------- + + +def test_resolve_unknown_top_level_raises() -> None: + """An unknown top-level name raises EntityNotFoundError.""" + files = _make_files(Foo=Component(name="Foo")) + with pytest.raises(EntityNotFoundError, match="Bar"): + resolve_entity(files, "Bar") + + +def test_resolve_unknown_child_raises() -> None: + """An unknown child segment raises EntityNotFoundError.""" + sys_a = System(name="SystemA", components=[Component(name="Worker")]) + files = _make_files(SystemA=sys_a) + with pytest.raises(EntityNotFoundError, match="Missing"): + resolve_entity(files, "SystemA::Missing") + + +def test_resolve_empty_path_raises() -> None: + """An empty path string raises EntityNotFoundError.""" + files: dict[str, ArchFile] = {} + with pytest.raises(EntityNotFoundError): + resolve_entity(files, "") + + +def test_resolve_empty_files_raises() -> None: + """Raises EntityNotFoundError when arch_files is empty.""" + with pytest.raises(EntityNotFoundError): + resolve_entity({}, "SystemA")