spice2wireviz/tests/test_single_module.py
Ryan Malloy 08c92bfefb Add tiered .asc parser with companion netlist resolution
Implement three-tier resolution for LTspice .asc schematic files:

1. Companion netlist - finds .net/.cir/.sp beside the .asc (automatic)
2. LTspice generation - invokes LTspice binary (opt-in via --generate-netlist)
3. Metadata-only fallback - extracts component refs/values without connectivity

Safety: DataCompleteness enum forces callers to check completeness.
CLI blocks diagram generation on METADATA_ONLY with clear remediation.
Metadata enrichment is additive-only with protected field guards.

Also: update project URLs to Gitea, add .asc usage docs to README,
fix pre-existing ruff warning in test_single_module.py.
2026-02-13 04:59:03 -07:00

321 lines
13 KiB
Python

"""Tests for the single-module mapper."""
from pathlib import Path
import pytest
from spice2wireviz.filter import FilterConfig
from spice2wireviz.mapper.single_module import (
_optimize_single_layout,
_remap_pins,
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"]
class TestSingleModuleLayoutOptimization:
"""Tests for the star-topology layout optimization."""
def test_boundary_component_order(self):
"""Boundary components should appear in header pin order."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
names = list(result["connectors"].keys())
# Header first, then J1 (avg pin 1.5), then J2 (avg pin 3.5), then TP1
assert names[0] == "amplifier_board"
assert names.index("J1") < names.index("J2")
def test_j2_pins_reordered_to_eliminate_crossing(self):
"""J2 pins should be [VOUT, SIGNAL_IN] to match header order."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
j2 = result["connectors"]["J2"]
# Header has VOUT@3, SIGNAL_IN@4 — J2 must match: VOUT first
assert j2["pinlabels"] == ["VOUT", "SIGNAL_IN"]
def test_connections_parallel_after_optimization(self):
"""After optimization, cable wires should not cross (pins monotonic)."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
for conn in result["connections"]:
left_pins = conn[0][next(iter(conn[0]))]
right_pins = conn[2][next(iter(conn[2]))]
l_list = [left_pins] if isinstance(left_pins, int) else left_pins
r_list = [right_pins] if isinstance(right_pins, int) else right_pins
assert l_list == sorted(l_list), f"Left pins not sorted: {l_list}"
assert r_list == sorted(r_list), f"Right pins not sorted: {r_list}"
def test_connection_pins_valid(self):
"""All pin indices must be within the connector's pin count."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
connectors = result["connectors"]
for conn in result["connections"]:
for entry in [conn[0], conn[2]]:
name = next(iter(entry))
pins = entry[name]
pin_list = [pins] if isinstance(pins, int) else pins
n_pins = len(connectors[name].get("pinlabels", []))
if n_pins == 0:
n_pins = connectors[name].get("pincount", 0)
for p in pin_list:
assert 1 <= p <= n_pins, (
f"Pin {p} out of range for {name} ({n_pins} pins)"
)
def test_optimize_no_crash_single_connector(self):
"""Single connector (no boundary) should pass through."""
connectors = {"header": {"pinlabels": ["A", "B"]}}
result_c, _cb, _cn = _optimize_single_layout("header", connectors, {}, [])
assert result_c == connectors
def test_optimize_no_crash_empty(self):
"""Empty inputs should pass through."""
result_c, _cb, _cn = _optimize_single_layout("header", {}, {}, [])
assert result_c == {}
def test_header_pin_regrouping(self):
"""Header pins connecting to the same component should be grouped."""
# Synthetic: header has interleaved pins
# Pin 1 → compA, Pin 2 → compB, Pin 3 → compA, Pin 4 → compB
connectors = {
"H": {"pinlabels": ["A1", "B1", "A2", "B2"]},
"CA": {"pinlabels": ["X", "Y"]},
"CB": {"pinlabels": ["M", "N"]},
}
connections = [
[{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
[{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}],
]
result_c, _, _result_cn = _optimize_single_layout(
"H", connectors, {}, connections
)
# Header should be regrouped: [A1, A2, B1, B2]
assert result_c["H"]["pinlabels"] == ["A1", "A2", "B1", "B2"]
# CA should come before CB
names = list(result_c.keys())
assert names.index("CA") < names.index("CB")
def test_header_regrouping_updates_connections(self):
"""After header regrouping, connection pin indices must be updated."""
connectors = {
"H": {"pinlabels": ["A1", "B1", "A2", "B2"]},
"CA": {"pinlabels": ["X", "Y"]},
"CB": {"pinlabels": ["M", "N"]},
}
connections = [
[{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
[{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}],
]
_, _, result_cn = _optimize_single_layout("H", connectors, {}, connections)
# After regrouping: H pins [A1,A2,B1,B2] → old 1→new 1, old 3→new 2,
# old 2→new 3, old 4→new 4
# Connection 1: H:[1,3] should become H:[1,2]
h_pins_0 = result_cn[0][0]["H"]
assert h_pins_0 == [1, 2]
# Connection 2: H:[2,4] should become H:[3,4]
h_pins_1 = result_cn[1][0]["H"]
assert h_pins_1 == [3, 4]
def test_comp_pin_reorder_eliminates_crossing(self):
"""Component pins should be reordered to match header pin order."""
# Header: [A, B], CompX: [Y, X] where A→Y and B→X
# This means header pin 1→comp pin 2, header pin 2→comp pin 1 (crossing!)
connectors = {
"H": {"pinlabels": ["A", "B"]},
"CX": {"pinlabels": ["Y", "X"]},
}
connections = [
[{"H": [1, 2]}, {"W1": [1, 2]}, {"CX": [2, 1]}],
]
result_c, _, result_cn = _optimize_single_layout(
"H", connectors, {}, connections
)
# CX should be reordered to [X, Y] so pin 1→pin 1, pin 2→pin 2
assert result_c["CX"]["pinlabels"] == ["X", "Y"]
# Connection should now be CX:[1,2] (no crossing)
assert result_cn[0][2]["CX"] == [1, 2]
def test_disconnected_test_point_preserved(self):
"""Test points with no header connection should appear last."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
names = list(result["connectors"].keys())
# TP1 connects to internal net N001 (not a port), so it's disconnected
# from the header. It should appear after connected components.
assert names[-1] == "TP1"
def test_connections_sorted_by_header_pin(self):
"""Connections should be sorted by their minimum header pin."""
netlist = parse_netlist(FIXTURES / "simple_board.net")
result = map_single_module(netlist, "amplifier_board")
prev_min = 0
for conn in result["connections"]:
# Header is always on the left in single-module connections
left_name = next(iter(conn[0]))
h_pins = conn[0][left_name]
pin_list = [h_pins] if isinstance(h_pins, int) else h_pins
min_pin = min(pin_list)
assert min_pin >= prev_min, (
f"Connection order wrong: min pin {min_pin} < previous {prev_min}"
)
prev_min = min_pin
def test_shared_header_pin_deduplication(self):
"""Header pin shared by two components should not corrupt mapping."""
# Pin 2 of header connects to both CA and CB (e.g., GND fan-out)
connectors = {
"H": {"pinlabels": ["VIN", "GND", "VOUT"]},
"CA": {"pinlabels": ["X", "Y"]},
"CB": {"pinlabels": ["M", "N"]},
}
connections = [
[{"H": [1, 2]}, {"W1": [1, 2]}, {"CA": [1, 2]}],
[{"H": [2, 3]}, {"W2": [1, 2]}, {"CB": [1, 2]}],
]
result_c, _, result_cn = _optimize_single_layout(
"H", connectors, {}, connections
)
# Should not crash, and all pin indices must be valid
h_labels = result_c["H"]["pinlabels"]
assert len(h_labels) == 3
# All connections should have valid pin references
for conn in result_cn:
h_pins = conn[0]["H"]
pin_list = [h_pins] if isinstance(h_pins, int) else h_pins
for p in pin_list:
assert 1 <= p <= 3, f"Header pin {p} out of range"
def test_remap_pins_missing_key_raises(self):
"""_remap_pins should raise ValueError with context on missing pin."""
mapping = {1: 2, 2: 1}
with pytest.raises(ValueError, match="Pin remapping failed for J1"):
_remap_pins("J1", [1, 3], mapping)
def test_remap_pins_scalar(self):
"""_remap_pins should handle scalar pin input."""
mapping = {1: 3, 2: 1, 3: 2}
assert _remap_pins("X", 2, mapping) == 1
def test_comp_pin_fanout_averaging(self):
"""Component pin connecting to multiple header pins should use average."""
# CX pin 1 connects to header pins 4 and 5 → remapped avg = 2.5
# CX pin 2 connects to header pin 1 → remapped pos = 1.0
# After header regrouping [1,4,5] → positions [1,2,3]:
# pin 1: avg(2,3) = 2.5, pin 2: pos(1) = 1.0
# So pin 2 should come before pin 1 → [P2, P1]
connectors = {
"H": {"pinlabels": ["A", "B", "C", "D", "E"]},
"CX": {"pinlabels": ["P1", "P2"]},
}
connections = [
[{"H": 4}, {"W1": 1}, {"CX": 1}],
[{"H": 5}, {"W2": 1}, {"CX": 1}],
[{"H": 1}, {"W3": 1}, {"CX": 2}],
]
result_c, _, _ = _optimize_single_layout("H", connectors, {}, connections)
# Pin 2 (remapped h_pos=1.0) before Pin 1 (avg remapped h_pos=2.5)
assert result_c["CX"]["pinlabels"] == ["P2", "P1"]