diff --git a/docs/agent-threads/notebook-api/001-notebook-api-handoff.md b/docs/agent-threads/notebook-api/001-notebook-api-handoff.md new file mode 100644 index 0000000..b586843 --- /dev/null +++ b/docs/agent-threads/notebook-api/001-notebook-api-handoff.md @@ -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 diff --git a/src/wireviz/wireviz.py b/src/wireviz/wireviz.py index dcbe677..aeb3f44 100755 --- a/src/wireviz/wireviz.py +++ b/src/wireviz/wireviz.py @@ -40,6 +40,8 @@ def parse( output_dir: Union[str, Path] = None, output_name: Union[None, str] = None, image_paths: Union[Path, str, List] = [], + harness: "Harness" = None, + populate_bom: bool = True, ) -> Any: """ 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. Note: If inp is a path to a YAML file, 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: Depending on the return_types parameter, may return: @@ -123,25 +134,30 @@ def parse( # define variables ========================================================= # containers for parsed component data and connection sets - template_connectors = {} - template_cables = {} + # when reusing a harness, start from its persistent templates + 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 = [] - # actual harness - harness = Harness( - metadata=Metadata(**yaml_data.get("metadata", {})), - options=Options(**yaml_data.get("options", {})), - tweak=Tweak(**yaml_data.get("tweak", {})), - ) - # others + # actual harness — create new or reuse existing + if harness is None: + harness = Harness( + metadata=Metadata(**yaml_data.get("metadata", {})), + options=Options(**yaml_data.get("options", {})), + tweak=Tweak(**yaml_data.get("tweak", {})), + ) + # 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 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 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 # parse YAML input file ==================================================== @@ -176,6 +192,10 @@ def parse( 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 ============== template_separator_char = harness.options.template_separator @@ -405,7 +425,8 @@ def parse( # harness population completed ============================================= - harness.populate_bom() + if populate_bom: + harness.populate_bom() if output_formats: harness.output(filename=output_file, fmt=output_formats, view=False) diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index f7d97e9..ece0103 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -3,7 +3,7 @@ from collections import namedtuple from dataclasses import dataclass from enum import Enum, IntEnum -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union import tabulate as tabulate_module @@ -140,6 +140,22 @@ def bom_list(bom): 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): print() print(tabulate_module.tabulate(bom_list(bom), headers="firstrow")) diff --git a/src/wireviz/wv_harness.py b/src/wireviz/wv_harness.py index d148fc1..41ab628 100644 --- a/src/wireviz/wv_harness.py +++ b/src/wireviz/wv_harness.py @@ -61,19 +61,29 @@ class Harness: self.mates = [] self.bom = defaultdict(dict) 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: check_old(f"Connector '{designator}'", OLD_CONNECTOR_ATTR, kwargs) conn = Connector(designator=designator, *args, **kwargs) self.connectors[designator] = conn + self._invalidate_graph() def add_cable(self, designator: str, *args, **kwargs) -> None: cbl = Cable(designator=designator, *args, **kwargs) self.cables[designator] = cbl + self._invalidate_graph() def add_additional_bom_item(self, item: dict) -> None: new_item = AdditionalBomItem(**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: from_con = self.connectors[from_name] @@ -87,10 +97,12 @@ class Harness: from_pin, Side.RIGHT, 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: arrow = Arrow(direction=parse_arrow_str(arrow_str), weight=ArrowWeight.SINGLE) self.mates.append(MateComponent(from_name, to_name, arrow)) + self._invalidate_graph() def populate_bom(self): # called once harness creation is complete # helper lists @@ -293,6 +305,7 @@ class Harness: self.connectors[from_name].activate_pin(from_pin, Side.RIGHT) if to_name in self.connectors: self.connectors[to_name].activate_pin(to_pin, Side.LEFT) + self._invalidate_graph() def create_graph(self) -> Graph: dot = Graph() diff --git a/tests/test_notebook_api.py b/tests/test_notebook_api.py new file mode 100644 index 0000000..2430c20 --- /dev/null +++ b/tests/test_notebook_api.py @@ -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"]