spice2wireviz/tests/test_netlist_parser.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

233 lines
8.3 KiB
Python

"""Tests for the SPICE netlist parser."""
from pathlib import Path
import pytest
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestBasicParsing:
def test_parse_simple_board(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
assert "amplifier_board" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["amplifier_board"]
assert subckt.port_names == ["VIN", "GND", "VOUT", "SIGNAL_IN"]
def test_parse_boundary_components(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
refs = [c.reference for c in subckt.boundary_components]
assert "J1" in refs
assert "J2" in refs
assert "TP1" in refs
assert len(subckt.boundary_components) == 3
def test_boundary_component_nodes(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
j1 = next(c for c in subckt.boundary_components if c.reference == "J1")
assert j1.nodes == ["VIN", "GND"]
assert j1.value == "PWR_CONN"
def test_test_point_single_node(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
subckt = netlist.subcircuit_defs["amplifier_board"]
tp1 = next(c for c in subckt.boundary_components if c.reference == "TP1")
assert tp1.nodes == ["N001"]
assert tp1.prefix == "TP"
class TestMultiBoardParsing:
def test_parse_multi_board(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
assert len(netlist.subcircuit_defs) == 3
assert "power_supply" in netlist.subcircuit_defs
assert "amplifier" in netlist.subcircuit_defs
assert "io_board" in netlist.subcircuit_defs
def test_instances(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
refs = [inst.reference for inst in netlist.instances]
assert "X1" in refs
assert "X2" in refs
assert "X3" in refs
def test_instance_port_mapping(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
x2 = next(i for i in netlist.instances if i.reference == "X2")
assert x2.subcircuit_name == "amplifier"
assert x2.port_to_net == {"VIN": "VCC", "GND": "GND", "VOUT": "AUDIO_OUT"}
def test_top_level_components(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
refs = [c.reference for c in netlist.top_level_components]
assert "J_CHASSIS" in refs
assert "TP_VCC" in refs
def test_top_level_connector_nodes(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
j_chassis = next(c for c in netlist.top_level_components if c.reference == "J_CHASSIS")
assert "GND" in j_chassis.nodes
assert "EARTH" in j_chassis.nodes
class TestHierarchicalParsing:
def test_global_nets(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert "VCC" in netlist.global_nets
assert "GND" in netlist.global_nets
def test_continuation_lines(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
reg = netlist.subcircuit_defs["regulator"]
# The continuation line should have been joined
assert "ENABLE" in reg.parameters or len(reg.port_names) >= 3
def test_inline_comments(self):
"""Inline ; comments should be stripped."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert "main_board" in netlist.subcircuit_defs
def test_all_subcircuits_found(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
assert len(netlist.subcircuit_defs) == 3
assert "regulator" in netlist.subcircuit_defs
assert "sensor_module" in netlist.subcircuit_defs
assert "main_board" in netlist.subcircuit_defs
class TestStringParsing:
def test_parse_from_string(self):
text = """\
.subckt simple A B
J1 A B CONN
.ends simple
"""
netlist = parse_netlist(text)
assert "simple" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["simple"]
assert subckt.port_names == ["A", "B"]
assert len(subckt.boundary_components) == 1
def test_empty_netlist(self):
netlist = parse_netlist("* Just a comment\n")
assert len(netlist.subcircuit_defs) == 0
assert len(netlist.instances) == 0
def test_continuation_line_joining(self):
text = """\
.subckt wide A B C
+ D E F
J1 A B CONN
.ends wide
"""
netlist = parse_netlist(text)
subckt = netlist.subcircuit_defs["wide"]
assert subckt.port_names == ["A", "B", "C", "D", "E", "F"]
class TestNetClassification:
def test_ground_nets(self):
text = ".subckt test GND AGND DGND VCC\n.ends test\n"
netlist = parse_netlist(text)
assert netlist.is_ground_net("GND")
assert netlist.is_ground_net("AGND")
assert netlist.is_ground_net("0")
assert not netlist.is_ground_net("VCC")
def test_power_nets(self):
text = ".subckt test VCC VDD GND\n.ends test\n"
netlist = parse_netlist(text)
assert netlist.is_power_net("VCC")
assert netlist.is_power_net("VDD")
assert not netlist.is_power_net("GND")
assert not netlist.is_power_net("SIGNAL")
def test_vss_is_ground_not_power(self):
"""VSS should be classified as ground (CMOS convention), not power."""
text = ".subckt test VSS VDD\n.ends test\n"
netlist = parse_netlist(text)
assert netlist.is_ground_net("VSS")
assert not netlist.is_power_net("VSS")
class TestEdgeCases:
def test_nonexistent_file(self):
with pytest.raises(FileNotFoundError):
parse_netlist(Path("/nonexistent/file.net"))
def test_missing_subcircuit_warning(self, capsys):
text = """\
X1 A B C undefined_subckt
"""
netlist = parse_netlist(text)
assert len(netlist.instances) == 1
captured = capsys.readouterr()
assert "undefined_subckt" in captured.err
def test_x_instance_without_subckt_def(self):
text = "X1 NET1 NET2 NET3 mystery_chip\n"
netlist = parse_netlist(text)
inst = netlist.instances[0]
assert inst.subcircuit_name == "mystery_chip"
# Without definition, uses positional port names
assert "port1" in inst.port_to_net
assert inst.port_to_net["port1"] == "NET1"
def test_port_count_mismatch_fewer_nodes(self, capsys):
"""C1: When instance has fewer nodes than subcircuit ports, warn and mark unconnected."""
text = """\
.subckt amp VIN GND VOUT ENABLE
.ends amp
X1 NET1 NET2 amp
"""
netlist = parse_netlist(text)
inst = netlist.instances[0]
captured = capsys.readouterr()
assert "ERROR: port count mismatch" in captured.err
assert "4 ports" in captured.err
assert "2 nodes" in captured.err
# Unconnected ports should be marked
assert inst.port_to_net["VIN"] == "NET1"
assert inst.port_to_net["GND"] == "NET2"
assert "__UNCONNECTED_VOUT__" in inst.port_to_net["VOUT"]
assert "__UNCONNECTED_ENABLE__" in inst.port_to_net["ENABLE"]
def test_port_count_mismatch_more_nodes(self, capsys):
"""C1: When instance has more nodes than subcircuit ports, warn about extras."""
text = """\
.subckt small A B
.ends small
X1 NET1 NET2 NET3 NET4 small
"""
parse_netlist(text)
captured = capsys.readouterr()
assert "ERROR: port count mismatch" in captured.err
assert "extra nodes" in captured.err
def test_model_value_heuristic_warning(self, capsys):
"""I6: The model/value heuristic should warn when it triggers."""
text = """\
.subckt test A B
J1 A B SOME_MODEL
.ends test
"""
parse_netlist(text)
captured = capsys.readouterr()
assert "SOME_MODEL" in captured.err
assert "model/value" in captured.err
def test_duplicate_refs_both_parsed(self):
"""S3: Duplicate reference designators should both be parsed."""
text = """\
J1 NET_A NET_B CONN_TYPE
J1 NET_C NET_D CONN_TYPE
"""
netlist = parse_netlist(text)
# Both should appear (parser doesn't deduplicate)
j1_refs = [c for c in netlist.top_level_components if c.reference == "J1"]
assert len(j1_refs) == 2