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:
commit
e20a956f51
24 changed files with 2630 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
40
tests/fixtures/hierarchical.net
vendored
Normal file
40
tests/fixtures/hierarchical.net
vendored
Normal 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
27
tests/fixtures/multi_board.net
vendored
Normal 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
14
tests/fixtures/simple_board.net
vendored
Normal 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
130
tests/test_cli.py
Normal 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
167
tests/test_filter.py
Normal 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
145
tests/test_inter_module.py
Normal 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
|
||||
171
tests/test_netlist_parser.py
Normal file
171
tests/test_netlist_parser.py
Normal 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
145
tests/test_roundtrip.py
Normal 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
104
tests/test_single_module.py
Normal 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
124
tests/test_yaml_emitter.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue