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.
506 lines
16 KiB
Python
506 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
"""Tests for the notebook-ready API additions:
|
|
- Graph cache invalidation (Gap 1)
|
|
- Structured BOM export via bom_list_dicts (Gap 2)
|
|
- YAML fragment merging via parse(harness=...) (Gap 3)
|
|
- Full incremental notebook workflow simulation
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from wireviz.wv_bom import bom_list, bom_list_dicts
|
|
from wireviz.wv_dataclasses import Metadata, Options, Tweak
|
|
from wireviz.wv_harness import Harness
|
|
from wireviz.wireviz import parse
|
|
|
|
|
|
def _make_harness(**kwargs):
|
|
"""Create a minimal Harness for testing."""
|
|
defaults = dict(
|
|
metadata=Metadata({}),
|
|
options=Options(),
|
|
tweak=Tweak(),
|
|
)
|
|
defaults.update(kwargs)
|
|
return Harness(**defaults)
|
|
|
|
|
|
# ===========================================================================
|
|
# Gap 1: Graph Cache Invalidation
|
|
# ===========================================================================
|
|
|
|
|
|
class TestGraphCacheInvalidation:
|
|
"""After render, mutating the harness should produce updated output."""
|
|
|
|
def test_add_connector_after_render(self):
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, None, None)
|
|
|
|
svg1 = h.svg
|
|
assert "X1" in svg1
|
|
|
|
# Add a second connector after rendering
|
|
h.add_connector("X2", pins=[1, 2])
|
|
h.connect(None, None, "W1", 1, "X2", 1)
|
|
|
|
svg2 = h.svg
|
|
assert "X2" in svg2
|
|
assert svg1 != svg2
|
|
|
|
def test_add_cable_after_render(self):
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, None, None)
|
|
|
|
svg1 = h.svg
|
|
assert "W1" in svg1
|
|
|
|
# Add a second cable after rendering
|
|
h.add_cable("W2", wirecount=1, colors=["RD"])
|
|
h.connect("X1", 2, "W2", 1, None, None)
|
|
|
|
svg2 = h.svg
|
|
assert "W2" in svg2
|
|
assert svg1 != svg2
|
|
|
|
def test_connect_after_render(self):
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2])
|
|
h.add_connector("X2", pins=[1, 2])
|
|
h.add_cable("W1", wirecount=2, colors=["BK", "RD"])
|
|
h.connect("X1", 1, "W1", 1, "X2", 1)
|
|
|
|
svg1 = h.svg
|
|
|
|
# Add another connection after rendering
|
|
h.connect("X1", 2, "W1", 2, "X2", 2)
|
|
|
|
svg2 = h.svg
|
|
assert svg1 != svg2
|
|
|
|
def test_add_mate_component_after_render(self):
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2])
|
|
h.add_connector("X2", pins=[1, 2])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, "X2", 1)
|
|
|
|
svg1 = h.svg
|
|
|
|
# Add mate after rendering
|
|
h.add_connector("X3", pins=[1])
|
|
h.add_connector("X4", pins=[1])
|
|
h.add_mate_component("X3", "X4", "-->")
|
|
|
|
svg2 = h.svg
|
|
assert "X3" in svg2
|
|
assert "X4" in svg2
|
|
assert svg1 != svg2
|
|
|
|
def test_graph_property_caches_correctly(self):
|
|
"""graph property should return same object on repeated access."""
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, None, None)
|
|
|
|
g1 = h.graph
|
|
g2 = h.graph
|
|
assert g1 is g2 # same cached object
|
|
|
|
def test_invalidation_clears_cache(self):
|
|
"""After mutation, graph property should return a new object."""
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, None, None)
|
|
|
|
g1 = h.graph
|
|
|
|
h.add_connector("X2", pins=[1])
|
|
g2 = h.graph
|
|
assert g1 is not g2
|
|
|
|
|
|
# ===========================================================================
|
|
# Gap 2: Structured BOM Export
|
|
# ===========================================================================
|
|
|
|
|
|
class TestBomListDicts:
|
|
"""bom_list_dicts() should return JSON-serializable list of dicts."""
|
|
|
|
def _make_populated_harness(self):
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2, 3], type="Molex KK 3-pin")
|
|
h.add_connector("X2", pins=[1, 2, 3], type="Molex KK 3-pin")
|
|
h.add_cable(
|
|
"W1", wirecount=3,
|
|
colors=["BK", "RD", "GN"],
|
|
length=0.5,
|
|
)
|
|
h.connect("X1", 1, "W1", 1, "X2", 1)
|
|
h.connect("X1", 2, "W1", 2, "X2", 2)
|
|
h.connect("X1", 3, "W1", 3, "X2", 3)
|
|
h.populate_bom()
|
|
return h
|
|
|
|
def test_returns_list_of_dicts(self):
|
|
h = self._make_populated_harness()
|
|
result = bom_list_dicts(h.bom)
|
|
assert isinstance(result, list)
|
|
assert all(isinstance(row, dict) for row in result)
|
|
|
|
def test_keys_match_headers(self):
|
|
h = self._make_populated_harness()
|
|
rows = bom_list(h.bom)
|
|
headers = rows[0]
|
|
dicts = bom_list_dicts(h.bom)
|
|
for d in dicts:
|
|
assert list(d.keys()) == headers
|
|
|
|
def test_values_match_rows(self):
|
|
h = self._make_populated_harness()
|
|
rows = bom_list(h.bom)
|
|
dicts = bom_list_dicts(h.bom)
|
|
for i, d in enumerate(dicts):
|
|
row = rows[i + 1] # skip header row
|
|
assert list(d.values()) == row
|
|
|
|
def test_json_serializable(self):
|
|
h = self._make_populated_harness()
|
|
dicts = bom_list_dicts(h.bom)
|
|
# Should not raise
|
|
serialized = json.dumps(dicts, default=str)
|
|
assert isinstance(serialized, str)
|
|
# Round-trip
|
|
deserialized = json.loads(serialized)
|
|
assert len(deserialized) == len(dicts)
|
|
|
|
def test_empty_bom_returns_empty_list(self):
|
|
from collections import defaultdict
|
|
empty_bom = defaultdict(dict)
|
|
result = bom_list_dicts(empty_bom)
|
|
assert result == []
|
|
|
|
def test_has_id_field(self):
|
|
h = self._make_populated_harness()
|
|
dicts = bom_list_dicts(h.bom)
|
|
assert all("#" in d for d in dicts)
|
|
ids = [d["#"] for d in dicts]
|
|
assert ids == sorted(ids) # IDs should be in order
|
|
|
|
def test_has_description(self):
|
|
h = self._make_populated_harness()
|
|
dicts = bom_list_dicts(h.bom)
|
|
assert all("Description" in d for d in dicts)
|
|
|
|
|
|
# ===========================================================================
|
|
# Gap 3: YAML Fragment Merging
|
|
# ===========================================================================
|
|
|
|
|
|
class TestYamlFragmentMerging:
|
|
"""parse() with harness= parameter should append to existing harness."""
|
|
|
|
def test_parse_with_existing_harness_adds_connector(self):
|
|
h = _make_harness()
|
|
# First fragment: define connector X1
|
|
fragment1 = {
|
|
"connectors": {"X1": {"pins": [1, 2, 3]}},
|
|
"cables": {"W1": {"wirecount": 1, "colors": ["BK"]}},
|
|
"connections": [
|
|
[
|
|
{"X1": [1]},
|
|
{"W1": [1]},
|
|
],
|
|
],
|
|
}
|
|
parse(fragment1, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
assert "X1" in h.connectors
|
|
assert "W1" in h.cables
|
|
|
|
def test_parse_fragment_adds_to_existing_components(self):
|
|
h = _make_harness()
|
|
# Fragment 1: define X1 and W1
|
|
fragment1 = {
|
|
"connectors": {"X1": {"pins": [1, 2]}},
|
|
"cables": {"W1": {"wirecount": 1, "colors": ["BK"]}},
|
|
"connections": [
|
|
[{"X1": [1]}, {"W1": [1]}],
|
|
],
|
|
}
|
|
parse(fragment1, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Fragment 2: define X2 and connect to W1
|
|
fragment2 = {
|
|
"connectors": {"X2": {"pins": [1, 2]}},
|
|
"connections": [
|
|
[{"W1": [1]}, {"X2": [1]}],
|
|
],
|
|
}
|
|
parse(fragment2, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
assert "X2" in h.connectors
|
|
# W1 should have a connection to X2
|
|
assert len(h.cables["W1"]._connections) == 2
|
|
|
|
def test_populate_bom_false_skips_bom(self):
|
|
h = _make_harness()
|
|
fragment = {
|
|
"connectors": {"X1": {"pins": [1]}},
|
|
"cables": {"W1": {"wirecount": 1, "colors": ["BK"]}},
|
|
"connections": [
|
|
[{"X1": [1]}, {"W1": [1]}],
|
|
],
|
|
}
|
|
parse(fragment, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# BOM should not be populated
|
|
assert len(h.bom) == 0
|
|
|
|
def test_populate_bom_true_populates_bom(self):
|
|
h = _make_harness()
|
|
fragment = {
|
|
"connectors": {"X1": {"pins": [1]}},
|
|
"cables": {"W1": {"wirecount": 1, "colors": ["BK"]}},
|
|
"connections": [
|
|
[{"X1": [1]}, {"W1": [1]}],
|
|
],
|
|
}
|
|
parse(fragment, return_types="harness", harness=h, populate_bom=True)
|
|
|
|
assert len(h.bom) > 0
|
|
|
|
def test_reuse_existing_connector_across_fragments(self):
|
|
"""Connector defined in fragment 1 can be referenced in fragment 2."""
|
|
h = _make_harness()
|
|
fragment1 = {
|
|
"connectors": {"X1": {"pins": [1, 2]}},
|
|
"cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}},
|
|
"connections": [
|
|
[{"X1": [1]}, {"W1": [1]}],
|
|
],
|
|
}
|
|
parse(fragment1, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Fragment 2: reference existing X1 (no re-definition needed)
|
|
fragment2 = {
|
|
"connections": [
|
|
[{"X1": [2]}, {"W1": [2]}],
|
|
],
|
|
}
|
|
parse(fragment2, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Both connections should exist on W1
|
|
assert len(h.cables["W1"]._connections) == 2
|
|
|
|
def test_parse_without_harness_param_backward_compatible(self):
|
|
"""parse() without harness= should work identically to before."""
|
|
yaml_input = {
|
|
"connectors": {"X1": {"pins": [1, 2]}, "X2": {"pins": [1, 2]}},
|
|
"cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}},
|
|
"connections": [
|
|
[
|
|
{"X1": [1, 2]},
|
|
{"W1": [1, 2]},
|
|
{"X2": [1, 2]},
|
|
],
|
|
],
|
|
}
|
|
result = parse(yaml_input, return_types="harness")
|
|
assert isinstance(result, Harness)
|
|
assert "X1" in result.connectors
|
|
assert "X2" in result.connectors
|
|
assert "W1" in result.cables
|
|
assert len(result.bom) > 0 # BOM was populated
|
|
|
|
def test_metadata_ignored_when_harness_provided(self):
|
|
"""When harness is provided, fragment's metadata/options are ignored."""
|
|
h = _make_harness(metadata=Metadata({"title": "Original Title"}))
|
|
fragment = {
|
|
"metadata": {"title": "Should Be Ignored"},
|
|
"connectors": {"X1": {"pins": [1]}},
|
|
"cables": {"W1": {"wirecount": 1, "colors": ["BK"]}},
|
|
"connections": [
|
|
[{"X1": [1]}, {"W1": [1]}],
|
|
],
|
|
}
|
|
parse(fragment, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Original metadata should be preserved
|
|
assert h.metadata["title"] == "Original Title"
|
|
|
|
|
|
# ===========================================================================
|
|
# Full Incremental Workflow Simulation
|
|
# ===========================================================================
|
|
|
|
|
|
class TestIncrementalWorkflow:
|
|
"""Simulate a notebook building a harness cell-by-cell with live preview."""
|
|
|
|
def test_cell_by_cell_with_render(self):
|
|
"""
|
|
Cell 1: Create harness, add connector X1
|
|
Cell 2: Render (should show X1)
|
|
Cell 3: Add cable W1 and connector X2, connect
|
|
Cell 4: Render (should show X1, W1, X2 with connections)
|
|
"""
|
|
# Cell 1: Create harness and add first connector
|
|
h = _make_harness()
|
|
h.add_connector("X1", pins=[1, 2, 3])
|
|
h.add_cable("W1", wirecount=1, colors=["BK"])
|
|
h.connect("X1", 1, "W1", 1, None, None)
|
|
|
|
# Cell 2: Render
|
|
svg1 = h.svg
|
|
assert "X1" in svg1
|
|
assert "W1" in svg1
|
|
|
|
# Cell 3: Add more components
|
|
h.add_connector("X2", pins=[1, 2, 3])
|
|
h.connect(None, None, "W1", 1, "X2", 1)
|
|
|
|
# Cell 4: Render again — should include X2
|
|
svg2 = h.svg
|
|
assert "X1" in svg2
|
|
assert "X2" in svg2
|
|
assert "W1" in svg2
|
|
assert svg1 != svg2 # output changed
|
|
|
|
def test_yaml_fragments_with_intermediate_render(self):
|
|
"""Build harness from YAML fragments with SVG preview between each."""
|
|
h = _make_harness()
|
|
|
|
# Fragment 1: connectors
|
|
frag1 = {
|
|
"connectors": {
|
|
"X1": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
"X2": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
},
|
|
}
|
|
parse(frag1, return_types="harness", harness=h, populate_bom=False)
|
|
# Can't render yet — no cables or connections to draw edges
|
|
|
|
# Fragment 2: cable
|
|
frag2 = {
|
|
"cables": {
|
|
"W1": {"wirecount": 2, "colors": ["BK", "RD"]},
|
|
},
|
|
}
|
|
parse(frag2, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Fragment 3: connections
|
|
frag3 = {
|
|
"connections": [
|
|
[
|
|
{"X1": [1, 2]},
|
|
{"W1": [1, 2]},
|
|
{"X2": [1, 2]},
|
|
],
|
|
],
|
|
}
|
|
parse(frag3, return_types="harness", harness=h, populate_bom=False)
|
|
|
|
# Now render — should have full harness
|
|
svg = h.svg
|
|
assert "X1" in svg
|
|
assert "X2" in svg
|
|
assert "W1" in svg
|
|
|
|
# Populate BOM explicitly
|
|
h.populate_bom()
|
|
dicts = bom_list_dicts(h.bom)
|
|
assert len(dicts) > 0
|
|
serialized = json.dumps(dicts, default=str)
|
|
assert isinstance(serialized, str)
|
|
|
|
def test_mixed_api_and_yaml_fragments(self):
|
|
"""Mix programmatic API calls with YAML fragment parsing."""
|
|
h = _make_harness()
|
|
|
|
# Programmatic: add connector
|
|
h.add_connector("X1", pins=[1, 2])
|
|
|
|
# YAML fragment: add cable and second connector
|
|
frag = {
|
|
"connectors": {"X2": {"pins": [1, 2]}},
|
|
"cables": {"W1": {"wirecount": 2, "colors": ["BK", "RD"]}},
|
|
"connections": [
|
|
[
|
|
{"X1": [1, 2]},
|
|
{"W1": [1, 2]},
|
|
{"X2": [1, 2]},
|
|
],
|
|
],
|
|
}
|
|
parse(frag, return_types="harness", harness=h, populate_bom=True)
|
|
|
|
assert "X1" in h.connectors
|
|
assert "X2" in h.connectors
|
|
assert "W1" in h.cables
|
|
assert len(h.bom) > 0
|
|
|
|
svg = h.svg
|
|
assert "X1" in svg
|
|
assert "X2" in svg
|
|
|
|
def test_bom_consistency_across_fragments(self):
|
|
"""BOM should be consistent whether built in one shot or incrementally."""
|
|
# One-shot harness
|
|
one_shot_yaml = {
|
|
"connectors": {
|
|
"X1": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
"X2": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
},
|
|
"cables": {
|
|
"W1": {"wirecount": 2, "colors": ["BK", "RD"]},
|
|
},
|
|
"connections": [
|
|
[
|
|
{"X1": [1, 2]},
|
|
{"W1": [1, 2]},
|
|
{"X2": [1, 2]},
|
|
],
|
|
],
|
|
}
|
|
h_oneshot = parse(one_shot_yaml, return_types="harness")
|
|
bom_oneshot = bom_list_dicts(h_oneshot.bom)
|
|
|
|
# Incremental harness
|
|
h_inc = _make_harness()
|
|
frag1 = {
|
|
"connectors": {
|
|
"X1": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
"X2": {"pins": [1, 2], "type": "Header 2-pin"},
|
|
},
|
|
"cables": {
|
|
"W1": {"wirecount": 2, "colors": ["BK", "RD"]},
|
|
},
|
|
"connections": [
|
|
[
|
|
{"X1": [1, 2]},
|
|
{"W1": [1, 2]},
|
|
{"X2": [1, 2]},
|
|
],
|
|
],
|
|
}
|
|
parse(frag1, return_types="harness", harness=h_inc, populate_bom=True)
|
|
bom_inc = bom_list_dicts(h_inc.bom)
|
|
|
|
# BOM should match
|
|
assert len(bom_oneshot) == len(bom_inc)
|
|
for d1, d2 in zip(bom_oneshot, bom_inc):
|
|
assert d1["Description"] == d2["Description"]
|
|
assert d1["Qty"] == d2["Qty"]
|