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.
This commit is contained in:
Ryan Malloy 2026-02-13 07:08:21 -07:00
parent cfc25dfabf
commit 65af27e0da
5 changed files with 684 additions and 15 deletions

View File

@ -0,0 +1,114 @@
# Message 001
| Field | Value |
|-------|-------|
| From | notebook-api-agent |
| To | web-ui-agent |
| Date | 2026-02-13T00:00:00Z |
| Re | Notebook-Ready API additions to WireViz core |
---
Three new capabilities have been added to the WireViz core library on `testing/web-ui-features` to support interactive/notebook-style harness building. All changes are backward-compatible.
## 1. Graph Cache Invalidation (automatic)
The `Harness._graph` cache is now invalidated automatically whenever any mutating method is called (`add_connector`, `add_cable`, `add_additional_bom_item`, `add_mate_pin`, `add_mate_component`, `connect`).
**No action needed on your side.** After any mutation, accessing `harness.svg` or `harness.png` will regenerate the diagram. Previously, calling `.svg` after adding new components would return stale output.
```python
h.add_connector("X1", pins=[1, 2])
svg1 = h.svg # renders X1
h.add_connector("X2", pins=[1, 2])
svg2 = h.svg # automatically re-renders, now includes X2
```
## 2. Structured BOM Export via `bom_list_dicts()`
New function in `wireviz.wv_bom`:
```python
from wireviz.wv_bom import bom_list_dicts
dicts = bom_list_dicts(harness.bom)
# Returns: [{"#": 1, "Qty": 2, "Description": "...", ...}, ...]
```
- Returns `List[Dict]` (JSON-serializable)
- Each dict maps column header to cell value
- Empty BOM returns `[]`
- Safe for `json.dumps(dicts, default=str)`
- Keys vary based on which columns have data (empty columns like P/N are omitted, matching `bom_list()` behavior)
## 3. YAML Fragment Merging via `parse(harness=...)`
The `parse()` function now accepts two new optional parameters:
```python
from wireviz.wireviz import parse
parse(
fragment, # YAML string, dict, or file path
return_types="harness",
harness=existing_harness, # append to this harness
populate_bom=False, # skip BOM for intermediate fragments
)
```
### Cell-by-cell building pattern:
```python
from wireviz.wv_harness import Harness
from wireviz.wv_dataclasses import Metadata, Options, Tweak
from wireviz.wireviz import parse
from wireviz.wv_bom import bom_list_dicts
# Create empty harness
h = Harness(metadata=Metadata({}), options=Options(), tweak=Tweak())
# Cell 1: Define connectors
parse({"connectors": {"X1": {"pins": [1,2]}, "X2": {"pins": [1,2]}}},
return_types="harness", harness=h, populate_bom=False)
# Cell 2: Define cable
parse({"cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}}},
return_types="harness", harness=h, populate_bom=False)
# Cell 3: Connect and render
parse({"connections": [[{"X1": [1,2]}, {"W1": [1,2]}, {"X2": [1,2]}]]},
return_types="harness", harness=h, populate_bom=True)
# Now get outputs
svg_data = h.svg # rendered diagram
bom_data = bom_list_dicts(h.bom) # JSON-serializable BOM
```
### Mixed programmatic + YAML pattern:
```python
h = Harness(metadata=Metadata({}), options=Options(), tweak=Tweak())
h.add_connector("X1", pins=[1, 2]) # programmatic
# YAML fragment references existing X1
parse({"connectors": {"X2": {"pins": [1,2]}},
"cables": {"W1": {"wirecount": 2, "colors": ["BK","RD"]}},
"connections": [[{"X1": [1,2]}, {"W1": [1,2]}, {"X2": [1,2]}]]},
return_types="harness", harness=h, populate_bom=True)
```
### Key behaviors:
- When `harness` is provided, the fragment's `metadata`/`options`/`tweak` sections are ignored
- Templates defined in earlier fragments are available to later fragments (persisted on the harness)
- `populate_bom=False` skips BOM computation; call `h.populate_bom()` explicitly when ready
- Existing components can be re-referenced across fragments without re-definition
---
**Next steps for recipient:**
- [ ] Integrate `harness.svg` for live preview in notebook cells
- [ ] Use `bom_list_dicts()` for the BOM panel/table
- [ ] Implement cell-by-cell YAML parsing using `parse(harness=h, populate_bom=False)`
- [ ] Call `populate_bom=True` on final render or when BOM display is requested

View File

@ -40,6 +40,8 @@ def parse(
output_dir: Union[str, Path] = None, output_dir: Union[str, Path] = None,
output_name: Union[None, str] = None, output_name: Union[None, str] = None,
image_paths: Union[Path, str, List] = [], image_paths: Union[Path, str, List] = [],
harness: "Harness" = None,
populate_bom: bool = True,
) -> Any: ) -> Any:
""" """
This function takes an input, parses it as a WireViz Harness file, This function takes an input, parses it as a WireViz Harness file,
@ -82,6 +84,15 @@ def parse(
Paths to use when resolving any image paths included in the data. Paths to use when resolving any image paths included in the data.
Note: If inp is a path to a YAML file, Note: If inp is a path to a YAML file,
its parent directory will automatically be included in the list. its parent directory will automatically be included in the list.
harness (Harness, optional):
An existing Harness object to append components/connections to.
When provided, metadata/options/tweak from inp are ignored and
the existing harness is reused. Enables cell-by-cell building.
populate_bom (bool, optional):
Whether to call harness.populate_bom() after parsing.
Defaults to True. Set to False for intermediate fragments
when building incrementally, then call harness.populate_bom()
explicitly or pass True on the final fragment.
Returns: Returns:
Depending on the return_types parameter, may return: Depending on the return_types parameter, may return:
@ -123,25 +134,30 @@ def parse(
# define variables ========================================================= # define variables =========================================================
# containers for parsed component data and connection sets # containers for parsed component data and connection sets
template_connectors = {} # when reusing a harness, start from its persistent templates
template_cables = {} template_connectors = dict(harness._template_connectors) if harness is not None else {}
template_cables = dict(harness._template_cables) if harness is not None else {}
connection_sets = [] connection_sets = []
# actual harness # actual harness — create new or reuse existing
harness = Harness( if harness is None:
metadata=Metadata(**yaml_data.get("metadata", {})), harness = Harness(
options=Options(**yaml_data.get("options", {})), metadata=Metadata(**yaml_data.get("metadata", {})),
tweak=Tweak(**yaml_data.get("tweak", {})), options=Options(**yaml_data.get("options", {})),
) tweak=Tweak(**yaml_data.get("tweak", {})),
# others )
# When title is not given, either deduce it from filename, or use default text.
if "title" not in harness.metadata:
harness.metadata["title"] = output_name or f"{APP_NAME} diagram and BOM"
# store mapping of components to their respective template # store mapping of components to their respective template
designators_and_templates = {} designators_and_templates = {}
# pre-populate from existing components so re-referencing works
for des in harness.connectors:
designators_and_templates[des] = des
for des in harness.cables:
designators_and_templates[des] = des
# keep track of auto-generated designators to avoid duplicates # keep track of auto-generated designators to avoid duplicates
autogenerated_designators = {} autogenerated_designators = {}
# When title is not given, either deduce it from filename, or use default text.
if "title" not in harness.metadata:
harness.metadata["title"] = output_name or f"{APP_NAME} diagram and BOM"
# add items # add items
# parse YAML input file ==================================================== # parse YAML input file ====================================================
@ -176,6 +192,10 @@ def parse(
connection_sets = yaml_data["connections"] connection_sets = yaml_data["connections"]
# persist templates on harness for future fragment merges
harness._template_connectors.update(template_connectors)
harness._template_cables.update(template_cables)
# go through connection sets, generate and connect components ============== # go through connection sets, generate and connect components ==============
template_separator_char = harness.options.template_separator template_separator_char = harness.options.template_separator
@ -405,7 +425,8 @@ def parse(
# harness population completed ============================================= # harness population completed =============================================
harness.populate_bom() if populate_bom:
harness.populate_bom()
if output_formats: if output_formats:
harness.output(filename=output_file, fmt=output_formats, view=False) harness.output(filename=output_file, fmt=output_formats, view=False)

View File

@ -3,7 +3,7 @@
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, IntEnum from enum import Enum, IntEnum
from typing import List, Optional, Union from typing import Dict, List, Optional, Union
import tabulate as tabulate_module import tabulate as tabulate_module
@ -140,6 +140,22 @@ def bom_list(bom):
return rows return rows
def bom_list_dicts(bom) -> List[Dict]:
"""Return BOM as a list of dicts (JSON-serializable).
Each dict maps column header to cell value, making it suitable
for JSON APIs and notebook contexts.
"""
rows = bom_list(bom)
if not rows:
return []
headers = rows[0]
return [
{h: cell for h, cell in zip(headers, row)}
for row in rows[1:]
]
def print_bom_table(bom): def print_bom_table(bom):
print() print()
print(tabulate_module.tabulate(bom_list(bom), headers="firstrow")) print(tabulate_module.tabulate(bom_list(bom), headers="firstrow"))

View File

@ -61,19 +61,29 @@ class Harness:
self.mates = [] self.mates = []
self.bom = defaultdict(dict) self.bom = defaultdict(dict)
self.additional_bom_items = [] self.additional_bom_items = []
# persistent template storage for fragment merging
self._template_connectors = {}
self._template_cables = {}
def _invalidate_graph(self):
"""Clear cached graph so next access regenerates it."""
self._graph = None
def add_connector(self, designator: str, *args, **kwargs) -> None: def add_connector(self, designator: str, *args, **kwargs) -> None:
check_old(f"Connector '{designator}'", OLD_CONNECTOR_ATTR, kwargs) check_old(f"Connector '{designator}'", OLD_CONNECTOR_ATTR, kwargs)
conn = Connector(designator=designator, *args, **kwargs) conn = Connector(designator=designator, *args, **kwargs)
self.connectors[designator] = conn self.connectors[designator] = conn
self._invalidate_graph()
def add_cable(self, designator: str, *args, **kwargs) -> None: def add_cable(self, designator: str, *args, **kwargs) -> None:
cbl = Cable(designator=designator, *args, **kwargs) cbl = Cable(designator=designator, *args, **kwargs)
self.cables[designator] = cbl self.cables[designator] = cbl
self._invalidate_graph()
def add_additional_bom_item(self, item: dict) -> None: def add_additional_bom_item(self, item: dict) -> None:
new_item = AdditionalBomItem(**item) new_item = AdditionalBomItem(**item)
self.additional_bom_items.append(new_item) self.additional_bom_items.append(new_item)
self._invalidate_graph()
def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_str) -> None: def add_mate_pin(self, from_name, from_pin, to_name, to_pin, arrow_str) -> None:
from_con = self.connectors[from_name] from_con = self.connectors[from_name]
@ -87,10 +97,12 @@ class Harness:
from_pin, Side.RIGHT, is_connection=False from_pin, Side.RIGHT, is_connection=False
) )
self.connectors[to_name].activate_pin(to_pin, Side.LEFT, is_connection=False) self.connectors[to_name].activate_pin(to_pin, Side.LEFT, is_connection=False)
self._invalidate_graph()
def add_mate_component(self, from_name, to_name, arrow_str) -> None: def add_mate_component(self, from_name, to_name, arrow_str) -> None:
arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE)
self.mates.append(MateComponent(from_name, to_name, arrow)) self.mates.append(MateComponent(from_name, to_name, arrow))
self._invalidate_graph()
def populate_bom(self): # called once harness creation is complete def populate_bom(self): # called once harness creation is complete
# helper lists # helper lists
@ -293,6 +305,7 @@ class Harness:
self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) self.connectors[from_name].activate_pin(from_pin, Side.RIGHT)
if to_name in self.connectors: if to_name in self.connectors:
self.connectors[to_name].activate_pin(to_pin, Side.LEFT) self.connectors[to_name].activate_pin(to_pin, Side.LEFT)
self._invalidate_graph()
def create_graph(self) -> Graph: def create_graph(self) -> Graph:
dot = Graph() dot = Graph()

505
tests/test_notebook_api.py Normal file
View File

@ -0,0 +1,505 @@
# -*- 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"]