spice2wireviz/tests/test_roundtrip.py
Ryan Malloy b9154e851b Address all critical and important findings from safety review
C1: Port count mismatch now emits ERROR to stderr and marks
    unconnected ports as __UNCONNECTED_<name>__ (never silent)
C2: Single-module pin mapping built in lockstep — cable_wires
    always matches header_pins length, warns on unmappable nets
C3: VSS removed from is_power_net (it's ground per CMOS convention),
    dead _POWER_PATTERN regex replaced with _KNOWN_NET_PATTERN
C4: apply_filters dead computation removed, docstring clarifies
    that net-level filtering is a mapper concern

I3: WireViz render catches (ValueError, TypeError, OSError) with
    diagnostic context instead of bare Exception
I5: Invalid --format flags now warn and error instead of silently
    falling through to defaults
I6: Model/value heuristic warns on stderr when it triggers, since
    signal names like ALERT or DATA could be misidentified

New tests: VSS classification, port count mismatch (C1), model/value
heuristic warning (I6), duplicate refs (S3), empty subcircuit (S2),
full pipeline determinism across 3 runs (S1)

115 tests pass, ruff clean
2026-02-13 01:34:30 -07:00

217 lines
8.0 KiB
Python

"""Roundtrip tests: parse SPICE -> emit YAML -> validate with WireViz.
These tests verify the generated YAML is structurally valid by feeding
it to wireviz.wireviz.parse() and checking it doesn't raise exceptions.
"""
from pathlib import Path
import pytest
import yaml
from spice2wireviz.emitter.yaml_emitter import emit_yaml
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.inter_module import map_inter_module
from spice2wireviz.mapper.single_module import map_single_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
def _has_wireviz() -> bool:
try:
from wireviz.wireviz import parse # noqa: F401
return True
except ImportError:
return False
class TestYamlValidity:
"""Verify generated YAML is valid YAML and has required WireViz keys."""
def test_single_module_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert "cables" in parsed
assert "connections" in parsed
assert isinstance(parsed["connectors"], dict)
assert isinstance(parsed["cables"], dict)
assert isinstance(parsed["connections"], list)
def test_inter_module_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert "cables" in parsed
assert "connections" in parsed
def test_hierarchical_yaml_structure(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
assert "connectors" in parsed
assert len(parsed["connectors"]) >= 6 # 3 instances + 3 top-level
def test_connector_has_pin_spec(self):
"""Every connector must have at least one of pincount/pins/pinlabels."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for name, conn in parsed["connectors"].items():
has_pins = any(k in conn for k in ("pincount", "pins", "pinlabels"))
assert has_pins, f"Connector '{name}' missing pin specification"
def test_cable_has_wire_spec(self):
"""Every cable must have wirecount or colors."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for name, cable in parsed["cables"].items():
has_wires = "wirecount" in cable or "colors" in cable
assert has_wires, f"Cable '{name}' missing wire specification"
def test_connection_set_structure(self):
"""Each connection set should be a list of dicts."""
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
parsed = yaml.safe_load(yaml_str)
for i, conn_set in enumerate(parsed["connections"]):
assert isinstance(conn_set, list), f"Connection set {i} is not a list"
assert len(conn_set) == 3, f"Connection set {i} doesn't have 3 elements"
for entry in conn_set:
assert isinstance(entry, dict), f"Connection set {i} entry is not a dict"
class TestPipelineDeterminism:
"""S1: Verify the full pipeline produces byte-identical output on repeated runs."""
def test_inter_module_deterministic(self):
"""Parse the same file multiple times; output must be identical."""
outputs = []
for _ in range(3):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
yaml_str = emit_yaml(result)
outputs.append(yaml_str)
assert outputs[0] == outputs[1] == outputs[2]
def test_single_module_deterministic(self):
outputs = []
for _ in range(3):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
yaml_str = emit_yaml(result)
outputs.append(yaml_str)
assert outputs[0] == outputs[1] == outputs[2]
def test_filtered_deterministic(self):
config = FilterConfig(show_ground=False, show_power=False)
outputs = []
for _ in range(3):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist, config)
yaml_str = emit_yaml(result)
outputs.append(yaml_str)
assert outputs[0] == outputs[1] == outputs[2]
class TestEmptySubcircuit:
"""S2: Subcircuit with ports but no boundary components."""
def test_empty_subcircuit_produces_header_only(self):
text = """\
.subckt bare_module A B C
* No J*/TP*/P* inside — just passives
R1 A B 10k
.ends bare_module
"""
netlist = parse_netlist(text)
result = map_single_module(netlist, "bare_module")
# Should produce a header connector but no cables or connections
assert "bare_module" in result["connectors"]
assert len(result["cables"]) == 0
assert len(result["connections"]) == 0
class TestDuplicateRefInMapper:
"""S3: Duplicate reference designators in the mapper."""
def test_duplicate_ref_last_wins_in_inter_module(self):
"""When two top-level components share a reference, the mapper
builds connectors from both but dict key collision means last wins.
This is a known limitation that should at least not crash."""
text = """\
.subckt mod A B
.ends mod
X1 NET1 NET2 mod
J1 NET1 NET2 CONN_A
J1 NET3 NET4 CONN_B
"""
netlist = parse_netlist(text)
result = map_inter_module(netlist)
# Should not crash; J1 connector exists (last definition wins)
assert "J1" in result["connectors"]
@pytest.mark.skipif(not _has_wireviz(), reason="WireViz not installed")
class TestWireVizRoundtrip:
"""Feed generated YAML to WireViz's parse() to verify full compatibility."""
def test_single_module_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
# WireViz parse() accepts dicts directly
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_inter_module_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_hierarchical_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_filtered_roundtrip(self):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(show_ground=False, show_power=False)
result = map_inter_module(netlist, config)
harness = wv_parse(result, return_types="harness")
assert harness is not None
def test_render_svg(self, tmp_path):
from wireviz.wireviz import parse as wv_parse
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
svg = wv_parse(result, return_types="svg")
assert svg is not None
assert len(svg) > 100 # Should be a real SVG