Compare commits
2 Commits
cfc25dfabf
...
67a9cf9bac
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a9cf9bac | |||
| 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,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)
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -86,6 +86,7 @@ class Options:
|
||||
bgcolor_bundle: SingleColor = None
|
||||
color_output_mode: ColorOutputMode = ColorOutputMode.EN_UPPER
|
||||
mini_bom_mode: bool = True
|
||||
show_wire_loops: bool = True
|
||||
template_separator: str = "."
|
||||
output_dpi: Optional[float] = 96.0
|
||||
_pad: int = 0
|
||||
|
||||
@ -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()
|
||||
@ -374,9 +387,10 @@ class Harness:
|
||||
if not (r1, r2) == (None, None):
|
||||
dot.edge(r1, r2)
|
||||
|
||||
for color, we, ww in gv_edge_wire_inside(cable):
|
||||
if not (we, ww) == (None, None):
|
||||
dot.edge(we, ww, color=color, straight="straight", href='')
|
||||
if self.options.show_wire_loops:
|
||||
for color, we, ww in gv_edge_wire_inside(cable):
|
||||
if not (we, ww) == (None, None):
|
||||
dot.edge(we, ww, color=color, straight="straight", href='')
|
||||
for mate in self.mates:
|
||||
color, dir, code_from, code_to = gv_edge_mate(mate)
|
||||
|
||||
|
||||
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