# -*- coding: utf-8 -*- """Tests for the notebook-ready API additions: - Graph cache invalidation (Gap 1) - Structured BOM export via bom_list_dicts (Gap 2) - YAML fragment merging via parse(harness=...) (Gap 3) - Full incremental notebook workflow simulation """ import json import pytest from wireviz.wv_bom import bom_list, bom_list_dicts from wireviz.wv_dataclasses import Metadata, Options, Tweak from wireviz.wv_harness import Harness from wireviz.wireviz import parse def _make_harness(**kwargs): """Create a minimal Harness for testing.""" defaults = dict( metadata=Metadata({}), options=Options(), tweak=Tweak(), ) defaults.update(kwargs) return Harness(**defaults) # =========================================================================== # Gap 1: Graph Cache Invalidation # =========================================================================== class TestGraphCacheInvalidation: """After render, mutating the harness should produce updated output.""" def test_add_connector_after_render(self): h = _make_harness() h.add_connector("X1", pins=[1, 2]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, None, None) svg1 = h.svg assert "X1" in svg1 # Add a second connector after rendering h.add_connector("X2", pins=[1, 2]) h.connect(None, None, "W1", 1, "X2", 1) svg2 = h.svg assert "X2" in svg2 assert svg1 != svg2 def test_add_cable_after_render(self): h = _make_harness() h.add_connector("X1", pins=[1, 2]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, None, None) svg1 = h.svg assert "W1" in svg1 # Add a second cable after rendering h.add_cable("W2", wirecount=1, colors=["RD"]) h.connect("X1", 2, "W2", 1, None, None) svg2 = h.svg assert "W2" in svg2 assert svg1 != svg2 def test_connect_after_render(self): h = _make_harness() h.add_connector("X1", pins=[1, 2]) h.add_connector("X2", pins=[1, 2]) h.add_cable("W1", wirecount=2, colors=["BK", "RD"]) h.connect("X1", 1, "W1", 1, "X2", 1) svg1 = h.svg # Add another connection after rendering h.connect("X1", 2, "W1", 2, "X2", 2) svg2 = h.svg assert svg1 != svg2 def test_add_mate_component_after_render(self): h = _make_harness() h.add_connector("X1", pins=[1, 2]) h.add_connector("X2", pins=[1, 2]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, "X2", 1) svg1 = h.svg # Add mate after rendering h.add_connector("X3", pins=[1]) h.add_connector("X4", pins=[1]) h.add_mate_component("X3", "X4", "-->") svg2 = h.svg assert "X3" in svg2 assert "X4" in svg2 assert svg1 != svg2 def test_graph_property_caches_correctly(self): """graph property should return same object on repeated access.""" h = _make_harness() h.add_connector("X1", pins=[1]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, None, None) g1 = h.graph g2 = h.graph assert g1 is g2 # same cached object def test_invalidation_clears_cache(self): """After mutation, graph property should return a new object.""" h = _make_harness() h.add_connector("X1", pins=[1]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, None, None) g1 = h.graph h.add_connector("X2", pins=[1]) g2 = h.graph assert g1 is not g2 # =========================================================================== # Gap 2: Structured BOM Export # =========================================================================== class TestBomListDicts: """bom_list_dicts() should return JSON-serializable list of dicts.""" def _make_populated_harness(self): h = _make_harness() h.add_connector("X1", pins=[1, 2, 3], type="Molex KK 3-pin") h.add_connector("X2", pins=[1, 2, 3], type="Molex KK 3-pin") h.add_cable( "W1", wirecount=3, colors=["BK", "RD", "GN"], length=0.5, ) h.connect("X1", 1, "W1", 1, "X2", 1) h.connect("X1", 2, "W1", 2, "X2", 2) h.connect("X1", 3, "W1", 3, "X2", 3) h.populate_bom() return h def test_returns_list_of_dicts(self): h = self._make_populated_harness() result = bom_list_dicts(h.bom) assert isinstance(result, list) assert all(isinstance(row, dict) for row in result) def test_keys_match_headers(self): h = self._make_populated_harness() rows = bom_list(h.bom) headers = rows[0] dicts = bom_list_dicts(h.bom) for d in dicts: assert list(d.keys()) == headers def test_values_match_rows(self): h = self._make_populated_harness() rows = bom_list(h.bom) dicts = bom_list_dicts(h.bom) for i, d in enumerate(dicts): row = rows[i + 1] # skip header row assert list(d.values()) == row def test_json_serializable(self): h = self._make_populated_harness() dicts = bom_list_dicts(h.bom) # Should not raise serialized = json.dumps(dicts, default=str) assert isinstance(serialized, str) # Round-trip deserialized = json.loads(serialized) assert len(deserialized) == len(dicts) def test_empty_bom_returns_empty_list(self): from collections import defaultdict empty_bom = defaultdict(dict) result = bom_list_dicts(empty_bom) assert result == [] def test_has_id_field(self): h = self._make_populated_harness() dicts = bom_list_dicts(h.bom) assert all("#" in d for d in dicts) ids = [d["#"] for d in dicts] assert ids == sorted(ids) # IDs should be in order def test_has_description(self): h = self._make_populated_harness() dicts = bom_list_dicts(h.bom) assert all("Description" in d for d in dicts) # =========================================================================== # Gap 3: YAML Fragment Merging # =========================================================================== class TestYamlFragmentMerging: """parse() with harness= parameter should append to existing harness.""" def test_parse_with_existing_harness_adds_connector(self): h = _make_harness() # First fragment: define connector X1 fragment1 = { "connectors": {"X1": {"pins": [1, 2, 3]}}, "cables": {"W1": {"wirecount": 1, "colors": ["BK"]}}, "connections": [ [ {"X1": [1]}, {"W1": [1]}, ], ], } parse(fragment1, return_types="harness", harness=h, populate_bom=False) assert "X1" in h.connectors assert "W1" in h.cables def test_parse_fragment_adds_to_existing_components(self): h = _make_harness() # Fragment 1: define X1 and W1 fragment1 = { "connectors": {"X1": {"pins": [1, 2]}}, "cables": {"W1": {"wirecount": 1, "colors": ["BK"]}}, "connections": [ [{"X1": [1]}, {"W1": [1]}], ], } parse(fragment1, return_types="harness", harness=h, populate_bom=False) # Fragment 2: define X2 and connect to W1 fragment2 = { "connectors": {"X2": {"pins": [1, 2]}}, "connections": [ [{"W1": [1]}, {"X2": [1]}], ], } parse(fragment2, return_types="harness", harness=h, populate_bom=False) assert "X2" in h.connectors # W1 should have a connection to X2 assert len(h.cables["W1"]._connections) == 2 def test_populate_bom_false_skips_bom(self): h = _make_harness() fragment = { "connectors": {"X1": {"pins": [1]}}, "cables": {"W1": {"wirecount": 1, "colors": ["BK"]}}, "connections": [ [{"X1": [1]}, {"W1": [1]}], ], } parse(fragment, return_types="harness", harness=h, populate_bom=False) # BOM should not be populated assert len(h.bom) == 0 def test_populate_bom_true_populates_bom(self): h = _make_harness() fragment = { "connectors": {"X1": {"pins": [1]}}, "cables": {"W1": {"wirecount": 1, "colors": ["BK"]}}, "connections": [ [{"X1": [1]}, {"W1": [1]}], ], } parse(fragment, return_types="harness", harness=h, populate_bom=True) assert len(h.bom) > 0 def test_reuse_existing_connector_across_fragments(self): """Connector defined in fragment 1 can be referenced in fragment 2.""" h = _make_harness() fragment1 = { "connectors": {"X1": {"pins": [1, 2]}}, "cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}}, "connections": [ [{"X1": [1]}, {"W1": [1]}], ], } parse(fragment1, return_types="harness", harness=h, populate_bom=False) # Fragment 2: reference existing X1 (no re-definition needed) fragment2 = { "connections": [ [{"X1": [2]}, {"W1": [2]}], ], } parse(fragment2, return_types="harness", harness=h, populate_bom=False) # Both connections should exist on W1 assert len(h.cables["W1"]._connections) == 2 def test_parse_without_harness_param_backward_compatible(self): """parse() without harness= should work identically to before.""" yaml_input = { "connectors": {"X1": {"pins": [1, 2]}, "X2": {"pins": [1, 2]}}, "cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}}, "connections": [ [ {"X1": [1, 2]}, {"W1": [1, 2]}, {"X2": [1, 2]}, ], ], } result = parse(yaml_input, return_types="harness") assert isinstance(result, Harness) assert "X1" in result.connectors assert "X2" in result.connectors assert "W1" in result.cables assert len(result.bom) > 0 # BOM was populated def test_metadata_ignored_when_harness_provided(self): """When harness is provided, fragment's metadata/options are ignored.""" h = _make_harness(metadata=Metadata({"title": "Original Title"})) fragment = { "metadata": {"title": "Should Be Ignored"}, "connectors": {"X1": {"pins": [1]}}, "cables": {"W1": {"wirecount": 1, "colors": ["BK"]}}, "connections": [ [{"X1": [1]}, {"W1": [1]}], ], } parse(fragment, return_types="harness", harness=h, populate_bom=False) # Original metadata should be preserved assert h.metadata["title"] == "Original Title" # =========================================================================== # Full Incremental Workflow Simulation # =========================================================================== class TestIncrementalWorkflow: """Simulate a notebook building a harness cell-by-cell with live preview.""" def test_cell_by_cell_with_render(self): """ Cell 1: Create harness, add connector X1 Cell 2: Render (should show X1) Cell 3: Add cable W1 and connector X2, connect Cell 4: Render (should show X1, W1, X2 with connections) """ # Cell 1: Create harness and add first connector h = _make_harness() h.add_connector("X1", pins=[1, 2, 3]) h.add_cable("W1", wirecount=1, colors=["BK"]) h.connect("X1", 1, "W1", 1, None, None) # Cell 2: Render svg1 = h.svg assert "X1" in svg1 assert "W1" in svg1 # Cell 3: Add more components h.add_connector("X2", pins=[1, 2, 3]) h.connect(None, None, "W1", 1, "X2", 1) # Cell 4: Render again — should include X2 svg2 = h.svg assert "X1" in svg2 assert "X2" in svg2 assert "W1" in svg2 assert svg1 != svg2 # output changed def test_yaml_fragments_with_intermediate_render(self): """Build harness from YAML fragments with SVG preview between each.""" h = _make_harness() # Fragment 1: connectors frag1 = { "connectors": { "X1": {"pins": [1, 2], "type": "Header 2-pin"}, "X2": {"pins": [1, 2], "type": "Header 2-pin"}, }, } parse(frag1, return_types="harness", harness=h, populate_bom=False) # Can't render yet — no cables or connections to draw edges # Fragment 2: cable frag2 = { "cables": { "W1": {"wirecount": 2, "colors": ["BK", "RD"]}, }, } parse(frag2, return_types="harness", harness=h, populate_bom=False) # Fragment 3: connections frag3 = { "connections": [ [ {"X1": [1, 2]}, {"W1": [1, 2]}, {"X2": [1, 2]}, ], ], } parse(frag3, return_types="harness", harness=h, populate_bom=False) # Now render — should have full harness svg = h.svg assert "X1" in svg assert "X2" in svg assert "W1" in svg # Populate BOM explicitly h.populate_bom() dicts = bom_list_dicts(h.bom) assert len(dicts) > 0 serialized = json.dumps(dicts, default=str) assert isinstance(serialized, str) def test_mixed_api_and_yaml_fragments(self): """Mix programmatic API calls with YAML fragment parsing.""" h = _make_harness() # Programmatic: add connector h.add_connector("X1", pins=[1, 2]) # YAML fragment: add cable and second connector frag = { "connectors": {"X2": {"pins": [1, 2]}}, "cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}}, "connections": [ [ {"X1": [1, 2]}, {"W1": [1, 2]}, {"X2": [1, 2]}, ], ], } parse(frag, return_types="harness", harness=h, populate_bom=True) assert "X1" in h.connectors assert "X2" in h.connectors assert "W1" in h.cables assert len(h.bom) > 0 svg = h.svg assert "X1" in svg assert "X2" in svg def test_bom_consistency_across_fragments(self): """BOM should be consistent whether built in one shot or incrementally.""" # One-shot harness one_shot_yaml = { "connectors": { "X1": {"pins": [1, 2], "type": "Header 2-pin"}, "X2": {"pins": [1, 2], "type": "Header 2-pin"}, }, "cables": { "W1": {"wirecount": 2, "colors": ["BK", "RD"]}, }, "connections": [ [ {"X1": [1, 2]}, {"W1": [1, 2]}, {"X2": [1, 2]}, ], ], } h_oneshot = parse(one_shot_yaml, return_types="harness") bom_oneshot = bom_list_dicts(h_oneshot.bom) # Incremental harness h_inc = _make_harness() frag1 = { "connectors": { "X1": {"pins": [1, 2], "type": "Header 2-pin"}, "X2": {"pins": [1, 2], "type": "Header 2-pin"}, }, "cables": { "W1": {"wirecount": 2, "colors": ["BK", "RD"]}, }, "connections": [ [ {"X1": [1, 2]}, {"W1": [1, 2]}, {"X2": [1, 2]}, ], ], } parse(frag1, return_types="harness", harness=h_inc, populate_bom=True) bom_inc = bom_list_dicts(h_inc.bom) # BOM should match assert len(bom_oneshot) == len(bom_inc) for d1, d2 in zip(bom_oneshot, bom_inc): assert d1["Description"] == d2["Description"] assert d1["Qty"] == d2["Qty"]