Initial project structure for spice2wireviz

SPICE netlist to WireViz YAML converter with:
- Custom lightweight netlist parser (.net/.cir/.sp)
- Single-module mapper (subcircuit external interface)
- Inter-module mapper (multi-board wiring)
- Filter engine with glob patterns
- Click CLI with auto-detection, inspection commands
- Optional .asc parser via spicelib
- Comprehensive test suite with fixtures
This commit is contained in:
Ryan Malloy 2026-02-13 01:24:41 -07:00
commit e20a956f51
24 changed files with 2630 additions and 0 deletions

0
tests/__init__.py Normal file
View file

40
tests/fixtures/hierarchical.net vendored Normal file
View file

@ -0,0 +1,40 @@
* Hierarchical netlist with nested subcircuits and global nets
* Tests continuation lines, parameters, .global directive
.global VCC GND
.subckt regulator VIN VOUT GND
+ ENABLE=1
R1 VIN N001 100
C1 N001 GND 10u
U1 N001 VOUT GND VCC LDO EN=ENABLE
J1 VIN GND INPUT_CONN
TP1 VOUT
.ends regulator
.subckt sensor_module SDA SCL VCC GND ALERT
* I2C sensor with pullups and test points
R_SDA SDA VCC 4.7k
R_SCL SCL VCC 4.7k
U1 SDA SCL VCC GND ALERT BME280
J1 SDA SCL VCC GND ALERT I2C_HDR
TP1 ALERT
.ends sensor_module
.subckt main_board VCC GND SDA SCL ALERT USB_D+ USB_D-
; Main MCU board with USB and I2C
U1 VCC GND SDA SCL USB_D+ USB_D- MCU
J1 USB_D+ USB_D- GND USB_CONN
J2 SDA SCL VCC GND I2C_CONN
P1 VCC GND POWER_PLUG
.ends main_board
* Top-level system wiring
X_REG VIN_RAW V3V3 GND regulator ENABLE=1
X_SENSOR I2C_SDA I2C_SCL V3V3 GND ALERT_SIG sensor_module
X_MAIN V3V3 GND I2C_SDA I2C_SCL ALERT_SIG USB_DP USB_DM main_board
* External connectors
J_PWR VIN_RAW GND BARREL_JACK
J_USB USB_DP USB_DM GND USB_B_CONN
TP_3V3 V3V3

27
tests/fixtures/multi_board.net vendored Normal file
View file

@ -0,0 +1,27 @@
* Multi-board system: power supply, amplifier, and I/O board
* Demonstrates inter-module wiring
.subckt power_supply VCC GND
J1 AC_IN AC_GND AC_INLET
.ends power_supply
.subckt amplifier VIN GND VOUT
R1 VIN N001 10k
C1 N001 GND 100n
J1 VIN GND INPUT_CONN
TP1 VOUT
.ends amplifier
.subckt io_board SIG_IN SIG_OUT GND CTRL
J1 SIG_IN SIG_OUT DB9_CONN
J2 CTRL GND CTRL_CONN
.ends io_board
* Top-level instantiation
X1 VCC GND power_supply
X2 VCC GND AUDIO_OUT amplifier
X3 AUDIO_OUT CTRL_SIG GND ENABLE io_board
* Top-level connectors
J_CHASSIS GND EARTH chassis_gnd
TP_VCC VCC

14
tests/fixtures/simple_board.net vendored Normal file
View file

@ -0,0 +1,14 @@
* Simple board netlist with one subcircuit
* Has connectors J1 (power), J2 (signal I/O), and test point TP1
.subckt amplifier_board VIN GND VOUT SIGNAL_IN
* Internal components (passives, ICs — not relevant to wiring)
R1 VIN N001 10k
R2 N001 GND 10k
U1 N001 VOUT GND VCC opamp
* Boundary components (connectors/test points visible at board edge)
J1 VIN GND PWR_CONN
J2 SIGNAL_IN VOUT SIG_CONN
TP1 N001
.ends amplifier_board

130
tests/test_cli.py Normal file
View file

@ -0,0 +1,130 @@
"""Tests for the Click CLI."""
from pathlib import Path
from click.testing import CliRunner
from spice2wireviz.cli import main
FIXTURES = Path(__file__).parent / "fixtures"
class TestCLIBasic:
def test_version(self):
runner = CliRunner()
result = runner.invoke(main, ["--version"])
assert result.exit_code == 0
assert "2026-02-13" in result.output
def test_list_subcircuits(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-subcircuits"])
assert result.exit_code == 0
assert "power_supply" in result.output
assert "amplifier" in result.output
assert "io_board" in result.output
def test_list_components(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net"), "--list-components"])
assert result.exit_code == 0
assert "J_CHASSIS" in result.output
assert "TP_VCC" in result.output
def test_nonexistent_file(self):
runner = CliRunner()
result = runner.invoke(main, ["/nonexistent/file.net"])
assert result.exit_code != 0
class TestCLISingleModule:
def test_single_mode_explicit(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.net"), "-m", "single", "-s", "amplifier_board"],
)
assert result.exit_code == 0
assert "connectors:" in result.output
assert "amplifier_board" in result.output
def test_single_mode_auto_detect(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "simple_board.net"), "-s", "amplifier_board"]
)
assert result.exit_code == 0
assert "connectors:" in result.output
def test_single_mode_only_subcircuit(self):
"""When netlist has one subcircuit and no instances, auto-detect single mode."""
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "simple_board.net")])
assert result.exit_code == 0
assert "connectors:" in result.output
def test_dry_run(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "simple_board.net"), "-s", "amplifier_board", "--dry-run"],
)
assert result.exit_code == 0
assert "Mode: single" in result.output
assert "Connectors:" in result.output
class TestCLIInterModule:
def test_inter_mode_explicit(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "-m", "inter"]
)
assert result.exit_code == 0
assert "connectors:" in result.output
assert "cables:" in result.output
def test_inter_mode_auto_detect(self):
runner = CliRunner()
result = runner.invoke(main, [str(FIXTURES / "multi_board.net")])
assert result.exit_code == 0
assert "connectors:" in result.output
def test_output_to_file(self, tmp_path):
out = tmp_path / "output.yml"
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "-o", str(out)]
)
assert result.exit_code == 0
assert out.exists()
content = out.read_text()
assert "connectors:" in content
class TestCLIFiltering:
def test_no_ground(self):
runner = CliRunner()
result = runner.invoke(
main, [str(FIXTURES / "multi_board.net"), "--no-ground"]
)
assert result.exit_code == 0
# GND should not appear in pinlabels
assert "- GND" not in result.output
def test_include_prefixes(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "multi_board.net"), "--include-prefixes", "J"],
)
assert result.exit_code == 0
def test_exclude_refs(self):
runner = CliRunner()
result = runner.invoke(
main,
[str(FIXTURES / "multi_board.net"), "--exclude-refs", "X1,TP_VCC"],
)
assert result.exit_code == 0
assert "X1:" not in result.output

167
tests/test_filter.py Normal file
View file

@ -0,0 +1,167 @@
"""Tests for the filter engine."""
from spice2wireviz.filter import FilterConfig, apply_filters, filter_component, filter_instance, filter_net
from spice2wireviz.parser.models import (
ParsedNetlist,
SpiceComponent,
SpicePin,
SubcircuitInstance,
)
def _make_netlist(**kwargs) -> ParsedNetlist:
defaults = {
"subcircuit_defs": {},
"instances": [],
"top_level_components": [],
"all_nets": set(),
"global_nets": set(),
}
defaults.update(kwargs)
return ParsedNetlist(**defaults)
def _make_component(ref: str, prefix: str, nodes: list[str] | None = None) -> SpiceComponent:
nodes = nodes or []
return SpiceComponent(
reference=ref,
prefix=prefix,
nodes=nodes,
pins=[SpicePin(name=n, index=i + 1, net_name=n) for i, n in enumerate(nodes)],
)
def _make_instance(ref: str, subckt: str, port_to_net: dict[str, str]) -> SubcircuitInstance:
return SubcircuitInstance(reference=ref, subcircuit_name=subckt, port_to_net=port_to_net)
class TestComponentFilter:
def test_default_includes_boundary(self):
config = FilterConfig()
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert filter_component(_make_component("TP1", "TP"), config, netlist)
assert filter_component(_make_component("P1", "P"), config, netlist)
def test_exclude_prefix(self):
config = FilterConfig(exclude_prefixes=["TP"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("TP1", "TP"), config, netlist)
def test_include_prefix_restricts(self):
config = FilterConfig(include_prefixes=["J"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("TP1", "TP"), config, netlist)
def test_exclude_ref(self):
config = FilterConfig(exclude_refs=["J2"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("J2", "J"), config, netlist)
def test_include_ref(self):
config = FilterConfig(include_refs=["J1"])
netlist = _make_netlist()
assert filter_component(_make_component("J1", "J"), config, netlist)
assert not filter_component(_make_component("J2", "J"), config, netlist)
class TestInstanceFilter:
def test_default_includes_x(self):
config = FilterConfig()
netlist = _make_netlist()
inst = _make_instance("X1", "amp", {"VIN": "NET1"})
assert filter_instance(inst, config, netlist)
def test_exclude_subcircuit(self):
config = FilterConfig(exclude_subcircuits=["power_supply"])
netlist = _make_netlist()
assert not filter_instance(
_make_instance("X1", "power_supply", {}), config, netlist
)
assert filter_instance(
_make_instance("X2", "amplifier", {}), config, netlist
)
def test_include_subcircuit(self):
config = FilterConfig(include_subcircuits=["amplifier"])
netlist = _make_netlist()
assert filter_instance(
_make_instance("X2", "amplifier", {}), config, netlist
)
assert not filter_instance(
_make_instance("X1", "power_supply", {}), config, netlist
)
def test_exclude_ref(self):
config = FilterConfig(exclude_refs=["X3"])
netlist = _make_netlist()
assert not filter_instance(
_make_instance("X3", "io_board", {}), config, netlist
)
class TestNetFilter:
def test_default_shows_all(self):
config = FilterConfig()
netlist = _make_netlist()
assert filter_net("VCC", config, netlist)
assert filter_net("GND", config, netlist)
assert filter_net("SIGNAL", config, netlist)
def test_hide_ground(self):
config = FilterConfig(show_ground=False)
netlist = _make_netlist()
assert not filter_net("GND", config, netlist)
assert not filter_net("AGND", config, netlist)
assert filter_net("VCC", config, netlist)
def test_hide_power(self):
config = FilterConfig(show_power=False)
netlist = _make_netlist()
assert not filter_net("VCC", config, netlist)
assert not filter_net("VDD", config, netlist)
assert filter_net("GND", config, netlist)
def test_include_nets_glob(self):
config = FilterConfig(include_nets=["SIG_*"])
netlist = _make_netlist()
assert filter_net("SIG_IN", config, netlist)
assert filter_net("SIG_OUT", config, netlist)
assert not filter_net("VCC", config, netlist)
def test_exclude_nets_glob(self):
config = FilterConfig(exclude_nets=["N0*"])
netlist = _make_netlist()
assert not filter_net("N001", config, netlist)
assert filter_net("SIGNAL", config, netlist)
class TestApplyFilters:
def test_filters_components(self):
comps = [
_make_component("J1", "J", ["NET1"]),
_make_component("TP1", "TP", ["NET2"]),
_make_component("R1", "R", ["NET3"]),
]
netlist = _make_netlist(top_level_components=comps)
config = FilterConfig() # default: J, TP, P, X
filtered = apply_filters(netlist, config)
refs = [c.reference for c in filtered.top_level_components]
assert "J1" in refs
assert "TP1" in refs
assert "R1" not in refs # R not in default include
def test_filters_instances(self):
insts = [
_make_instance("X1", "power", {"VCC": "NET1"}),
_make_instance("X2", "amp", {"VIN": "NET2"}),
]
netlist = _make_netlist(instances=insts)
config = FilterConfig(exclude_refs=["X1"])
filtered = apply_filters(netlist, config)
refs = [i.reference for i in filtered.instances]
assert "X1" not in refs
assert "X2" in refs

145
tests/test_inter_module.py Normal file
View file

@ -0,0 +1,145 @@
"""Tests for the inter-module mapper."""
from pathlib import Path
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.inter_module import map_inter_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestInterModuleMapping:
def test_multi_board_connectors(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
# Should have connectors for each X instance and top-level components
assert "X1" in connectors
assert "X2" in connectors
assert "X3" in connectors
assert "J_CHASSIS" in connectors
assert "TP_VCC" in connectors
def test_instance_connector_type(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x1 = result["connectors"]["X1"]
assert x1["type"] == "power_supply"
def test_instance_pinlabels(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x2 = result["connectors"]["X2"]
assert "VIN" in x2["pinlabels"]
assert "GND" in x2["pinlabels"]
assert "VOUT" in x2["pinlabels"]
def test_cables_for_shared_nets(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
# Should have cables connecting modules via shared nets
cables = result["cables"]
assert len(cables) >= 1
def test_connections_exist(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
connections = result["connections"]
assert len(connections) >= 1
def test_traceability_notes(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
x1 = result["connectors"]["X1"]
assert "SPICE instance" in x1["notes"]
def test_top_level_test_point_style(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
result = map_inter_module(netlist)
tp = result["connectors"]["TP_VCC"]
assert tp.get("style") == "simple"
class TestInterModuleFiltering:
def test_exclude_instance(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(exclude_refs=["X1"])
result = map_inter_module(netlist, config)
assert "X1" not in result["connectors"]
assert "X2" in result["connectors"]
def test_exclude_subcircuit(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(exclude_subcircuits=["power_supply"])
result = map_inter_module(netlist, config)
assert "X1" not in result["connectors"]
def test_no_ground_filter(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(show_ground=False)
result = map_inter_module(netlist, config)
# GND should not appear in any connector's pinlabels
for name, conn in result["connectors"].items():
if "pinlabels" in conn:
assert "GND" not in conn["pinlabels"], f"GND found in {name}"
def test_no_power_filter(self):
netlist = parse_netlist(FIXTURES / "multi_board.net")
config = FilterConfig(show_power=False)
result = map_inter_module(netlist, config)
for name, conn in result["connectors"].items():
if "pinlabels" in conn:
assert "VCC" not in conn["pinlabels"], f"VCC found in {name}"
class TestHierarchicalInterModule:
def test_hierarchical_instances(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
assert "X_REG" in connectors
assert "X_SENSOR" in connectors
assert "X_MAIN" in connectors
def test_external_connectors(self):
netlist = parse_netlist(FIXTURES / "hierarchical.net")
result = map_inter_module(netlist)
connectors = result["connectors"]
assert "J_PWR" in connectors
assert "J_USB" in connectors
assert "TP_3V3" in connectors
def test_grouped_parallel_wires(self):
"""Parallel wires between same modules should be grouped."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(group_parallel_wires=True)
result = map_inter_module(netlist, config)
# With grouping, fewer cables than total net connections
cables = result["cables"]
connections = result["connections"]
assert len(cables) == len(connections)
def test_ungrouped_wires(self):
"""Without grouping, each wire gets its own cable."""
netlist = parse_netlist(FIXTURES / "hierarchical.net")
config = FilterConfig(group_parallel_wires=False)
result = map_inter_module(netlist, config)
# Each cable should have wirecount=1
for cable in result["cables"].values():
assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1

View file

@ -0,0 +1,171 @@
"""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")
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"

145
tests/test_roundtrip.py Normal file
View file

@ -0,0 +1,145 @@
"""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"
@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

104
tests/test_single_module.py Normal file
View file

@ -0,0 +1,104 @@
"""Tests for the single-module mapper."""
from pathlib import Path
import pytest
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.single_module import map_single_module
from spice2wireviz.parser.netlist import parse_netlist
FIXTURES = Path(__file__).parent / "fixtures"
class TestSingleModuleMapping:
def test_simple_board_connectors(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
connectors = result["connectors"]
assert "amplifier_board" in connectors # module header
assert "J1" in connectors
assert "J2" in connectors
assert "TP1" in connectors
def test_module_header_pinlabels(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
header = result["connectors"]["amplifier_board"]
assert header["pinlabels"] == ["VIN", "GND", "VOUT", "SIGNAL_IN"]
assert header["type"] == "Module Interface"
def test_connector_pinlabels(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
j1 = result["connectors"]["J1"]
assert j1["pinlabels"] == ["VIN", "GND"]
def test_test_point_style(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
tp1 = result["connectors"]["TP1"]
assert tp1.get("style") == "simple"
def test_cables_created(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
cables = result["cables"]
assert len(cables) >= 2 # At least cables for J1 and J2 connections
def test_connections_created(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
connections = result["connections"]
assert len(connections) >= 2
def test_traceability_notes(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
header = result["connectors"]["amplifier_board"]
assert "SPICE subcircuit" in header["notes"]
def test_nonexistent_subcircuit_raises(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
with pytest.raises(ValueError, match="not found"):
map_single_module(netlist, "nonexistent")
def test_metadata_passthrough(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
meta = {"title": "Test Diagram", "author": "Test"}
result = map_single_module(netlist, "amplifier_board", metadata=meta)
assert result["metadata"]["title"] == "Test Diagram"
class TestSingleModuleFiltering:
def test_no_ground_filter(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(show_ground=False)
result = map_single_module(netlist, "amplifier_board", config)
header = result["connectors"]["amplifier_board"]
assert "GND" not in header["pinlabels"]
def test_no_power_filter(self):
"""When power is hidden, VIN should still appear as it's a port name, not a power net."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(show_power=False)
result = map_single_module(netlist, "amplifier_board", config)
# VIN is a port name — it's not VCC/VDD so it stays
header = result["connectors"]["amplifier_board"]
assert "VIN" in header["pinlabels"]
def test_exclude_component_ref(self):
netlist = parse_netlist(FIXTURES / "simple_board.net")
config = FilterConfig(exclude_refs=["TP1"])
result = map_single_module(netlist, "amplifier_board", config)
assert "TP1" not in result["connectors"]

124
tests/test_yaml_emitter.py Normal file
View file

@ -0,0 +1,124 @@
"""Tests for the YAML emitter."""
import yaml
from spice2wireviz.emitter.yaml_emitter import (
assemble_wireviz_doc,
build_cable,
build_connection,
build_connector,
emit_yaml,
)
class TestBuildConnector:
def test_minimal_with_pinlabels(self):
conn = build_connector("J1", pinlabels=["A", "B"])
assert conn["pinlabels"] == ["A", "B"]
def test_minimal_with_pincount(self):
conn = build_connector("J1", pincount=4)
assert conn["pincount"] == 4
def test_with_type_and_notes(self):
conn = build_connector("J1", pinlabels=["A"], connector_type="DB9", notes="test")
assert conn["type"] == "DB9"
assert conn["notes"] == "test"
def test_simple_style(self):
conn = build_connector("TP1", pinlabels=["SIG"], style="simple")
assert conn["style"] == "simple"
def test_empty_strings_omitted(self):
conn = build_connector("J1", pinlabels=["A"])
assert "type" not in conn
assert "style" not in conn
assert "notes" not in conn
class TestBuildCable:
def test_with_wirecount(self):
cable = build_cable("W1", wirecount=3)
assert cable["wirecount"] == 3
def test_with_colors(self):
cable = build_cable("W1", colors=["BK", "RD", ""])
assert cable["colors"] == ["BK", "RD", ""]
def test_with_labels(self):
cable = build_cable("W1", wirecount=2, wirelabels=["GND", "VCC"])
assert cable["wirelabels"] == ["GND", "VCC"]
def test_bundle_category(self):
cable = build_cable("W1", wirecount=2, category="bundle")
assert cable["category"] == "bundle"
class TestBuildConnection:
def test_single_wire(self):
conn = build_connection("J1", [1], "W1", [1], "J2", [1])
assert len(conn) == 3
assert conn[0] == {"J1": 1}
assert conn[1] == {"W1": 1}
assert conn[2] == {"J2": 1}
def test_multi_wire(self):
conn = build_connection("J1", [1, 2, 3], "W1", [1, 2, 3], "J2", [1, 2, 3])
assert conn[0] == {"J1": [1, 2, 3]}
assert conn[1] == {"W1": [1, 2, 3]}
assert conn[2] == {"J2": [1, 2, 3]}
class TestAssembleDoc:
def test_minimal_doc(self):
doc = assemble_wireviz_doc(
connectors={"J1": {"pincount": 2}},
cables={"W1": {"wirecount": 1}},
connections=[[{"J1": 1}, {"W1": 1}, {"J2": 1}]],
)
assert "connectors" in doc
assert "cables" in doc
assert "connections" in doc
assert "metadata" not in doc
def test_with_metadata(self):
doc = assemble_wireviz_doc(
connectors={},
cables={},
connections=[],
metadata={"title": "Test"},
)
assert doc["metadata"]["title"] == "Test"
class TestEmitYaml:
def test_produces_valid_yaml(self):
doc = assemble_wireviz_doc(
connectors={"J1": {"pinlabels": ["A", "B"]}, "J2": {"pinlabels": ["C", "D"]}},
cables={"W1": {"wirecount": 2}},
connections=[[{"J1": [1, 2]}, {"W1": [1, 2]}, {"J2": [1, 2]}]],
)
yaml_str = emit_yaml(doc)
parsed = yaml.safe_load(yaml_str)
assert parsed["connectors"]["J1"]["pinlabels"] == ["A", "B"]
def test_deterministic_output(self):
doc = assemble_wireviz_doc(
connectors={"Z": {"pincount": 1}, "A": {"pincount": 1}},
cables={"W1": {"wirecount": 1}},
connections=[],
)
yaml1 = emit_yaml(doc)
yaml2 = emit_yaml(doc)
assert yaml1 == yaml2 # Byte-identical
def test_preserves_insertion_order(self):
doc = assemble_wireviz_doc(
connectors={"Z_conn": {"pincount": 1}, "A_conn": {"pincount": 1}},
cables={},
connections=[],
)
yaml_str = emit_yaml(doc)
z_pos = yaml_str.index("Z_conn")
a_pos = yaml_str.index("A_conn")
assert z_pos < a_pos # Order preserved, not sorted alphabetically