WireViz/tests/test_notebook_api.py
Ryan Malloy 65af27e0da Add notebook-ready API: graph cache invalidation, structured BOM, fragment merging
Three additions to support interactive/notebook-style harness building:

- Graph cache invalidation: _invalidate_graph() called from all mutating
  methods so svg/png output reflects latest state after mutations
- bom_list_dicts(): JSON-serializable BOM export as list of dicts
- parse(harness=, populate_bom=): append YAML fragments to existing
  harness for cell-by-cell building with deferred BOM population

Templates persist on the Harness object across parse() calls so
component definitions in one fragment are available to connections
in later fragments.

Includes 24 new tests covering all three features plus full incremental
workflow simulation. All 122 tests pass.
2026-02-13 07:08:21 -07:00

506 lines
16 KiB
Python

# -*- 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"]