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:
parent
cfc25dfabf
commit
65af27e0da
114
docs/agent-threads/notebook-api/001-notebook-api-handoff.md
Normal file
114
docs/agent-threads/notebook-api/001-notebook-api-handoff.md
Normal 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
|
||||
@ -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,24 +134,29 @@ 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
|
||||
# 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", {})),
|
||||
)
|
||||
# others
|
||||
# store mapping of components to their respective template
|
||||
designators_and_templates = {}
|
||||
# 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"
|
||||
# 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 = {}
|
||||
|
||||
# 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,6 +425,7 @@ def parse(
|
||||
|
||||
# harness population completed =============================================
|
||||
|
||||
if populate_bom:
|
||||
harness.populate_bom()
|
||||
|
||||
if output_formats:
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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()
|
||||
|
||||
505
tests/test_notebook_api.py
Normal file
505
tests/test_notebook_api.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user