- Fix _try_ltspice_generation() to use spicelib.simulators.ltspice_simulator.LTspice instead of the abstract Simulator base class (which always returned unavailable) - Use LTspice.create_netlist() instead of Simulator.run() for correct netlist generation - Add --ltspice-exe CLI option to specify LTspice binary path - Add --bom flag for component BOM CSV output (works on any parse completeness) - Add --bom-wiring flag for wiring BOM CSV from mapped output - Add real 1002A.asc demo circuit and pre-generated .net as test fixtures - Add @pytest.mark.ltspice marker for tests requiring LTspice binary - Bump version to 2026.2.14
223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
"""Tests for the BOM CSV emitter."""
|
|
|
|
import csv
|
|
import io
|
|
from pathlib import Path
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from spice2wireviz.cli import main
|
|
from spice2wireviz.emitter.bom_emitter import emit_component_bom, emit_wiring_bom
|
|
from spice2wireviz.filter import FilterConfig
|
|
from spice2wireviz.mapper.inter_module import map_inter_module
|
|
from spice2wireviz.parser.models import (
|
|
ParsedNetlist,
|
|
SpiceComponent,
|
|
SpicePin,
|
|
)
|
|
from spice2wireviz.parser.netlist import parse_netlist
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def _parse_csv(csv_str: str) -> list[dict[str, str]]:
|
|
"""Parse a CSV string into a list of dicts."""
|
|
reader = csv.DictReader(io.StringIO(csv_str))
|
|
return list(reader)
|
|
|
|
|
|
class TestComponentBom:
|
|
def test_simple_board_bom(self):
|
|
"""Component BOM from simple_board.net has expected connectors."""
|
|
netlist = parse_netlist(FIXTURES / "simple_board.net")
|
|
config = FilterConfig()
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
assert len(rows) > 0
|
|
|
|
# Check CSV is parseable and has expected columns
|
|
assert "Reference" in rows[0]
|
|
assert "Prefix" in rows[0]
|
|
assert "Value" in rows[0]
|
|
|
|
def test_bom_respects_filters(self):
|
|
"""Filter config limits which components appear in BOM."""
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
# Only include J-prefix components
|
|
config = FilterConfig(include_prefixes=["J"])
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
for row in rows:
|
|
assert row["Prefix"] == "J"
|
|
|
|
def test_bom_includes_subcircuit_boundary_components(self):
|
|
"""Components inside .subckt definitions are included."""
|
|
netlist = parse_netlist(FIXTURES / "simple_board.net")
|
|
config = FilterConfig()
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
subcircuit_refs = [r for r in rows if r["Subcircuit"]]
|
|
assert len(subcircuit_refs) > 0
|
|
|
|
def test_bom_includes_instances(self):
|
|
"""X* instances appear in BOM with subcircuit name as value."""
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig()
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
x_rows = [r for r in rows if r["Prefix"] == "X"]
|
|
assert len(x_rows) > 0
|
|
# Value column should contain the subcircuit name
|
|
for row in x_rows:
|
|
assert row["Value"] # not empty
|
|
|
|
def test_empty_netlist_produces_header_only(self):
|
|
"""An empty netlist produces CSV with just the header."""
|
|
netlist = ParsedNetlist()
|
|
config = FilterConfig()
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
assert len(rows) == 0
|
|
|
|
# But header should exist
|
|
lines = csv_str.strip().split("\n")
|
|
assert len(lines) == 1
|
|
assert "Reference" in lines[0]
|
|
|
|
def test_bom_preserves_attributes(self):
|
|
"""Component attributes appear in the Attributes column."""
|
|
netlist = ParsedNetlist(
|
|
top_level_components=[
|
|
SpiceComponent(
|
|
reference="J1", prefix="J", value="DB9", nodes=["A", "B"],
|
|
pins=[
|
|
SpicePin(name="A", index=1, net_name="A"),
|
|
SpicePin(name="B", index=2, net_name="B"),
|
|
],
|
|
attributes={"Footprint": "DSUB-9", "MPN": "DE-9S"},
|
|
),
|
|
],
|
|
)
|
|
config = FilterConfig()
|
|
csv_str = emit_component_bom(netlist, config)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
assert len(rows) == 1
|
|
assert "Footprint=DSUB-9" in rows[0]["Attributes"]
|
|
assert "MPN=DE-9S" in rows[0]["Attributes"]
|
|
|
|
|
|
class TestWiringBom:
|
|
def test_multi_board_wiring_bom(self):
|
|
"""Wiring BOM from mapped multi_board.net has cables."""
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig()
|
|
wireviz_dict = map_inter_module(netlist, config)
|
|
csv_str = emit_wiring_bom(wireviz_dict)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
assert len(rows) > 0
|
|
assert "Cable" in rows[0]
|
|
assert "Wirecount" in rows[0]
|
|
assert "From" in rows[0]
|
|
assert "To" in rows[0]
|
|
|
|
def test_wiring_bom_has_net_labels(self):
|
|
"""Cables include net name labels."""
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig()
|
|
wireviz_dict = map_inter_module(netlist, config)
|
|
csv_str = emit_wiring_bom(wireviz_dict)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
# At least some cables should have net labels
|
|
nets_found = [r for r in rows if r["Nets"]]
|
|
assert len(nets_found) > 0
|
|
|
|
def test_wiring_bom_endpoints(self):
|
|
"""Each cable connects two endpoints."""
|
|
netlist = parse_netlist(FIXTURES / "multi_board.net")
|
|
config = FilterConfig()
|
|
wireviz_dict = map_inter_module(netlist, config)
|
|
csv_str = emit_wiring_bom(wireviz_dict)
|
|
|
|
rows = _parse_csv(csv_str)
|
|
for row in rows:
|
|
assert row["From"] # not empty
|
|
assert row["To"] # not empty
|
|
|
|
def test_empty_wireviz_produces_header_only(self):
|
|
"""An empty wireviz dict produces CSV with just the header."""
|
|
csv_str = emit_wiring_bom({})
|
|
|
|
rows = _parse_csv(csv_str)
|
|
assert len(rows) == 0
|
|
|
|
lines = csv_str.strip().split("\n")
|
|
assert len(lines) == 1
|
|
assert "Cable" in lines[0]
|
|
|
|
|
|
class TestBomCli:
|
|
"""CLI integration for --bom and --bom-wiring flags."""
|
|
|
|
def test_bom_flag(self):
|
|
"""--bom produces CSV output."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main, [str(FIXTURES / "simple_board.net"), "--bom"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Reference" in result.output
|
|
# Should be CSV, not YAML
|
|
assert "connectors:" not in result.output
|
|
|
|
def test_bom_wiring_flag(self):
|
|
"""--bom-wiring produces cable CSV."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[str(FIXTURES / "multi_board.net"), "--bom-wiring"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Cable" in result.output
|
|
assert "Wirecount" in result.output
|
|
# Should be CSV, not YAML
|
|
assert "connectors:" not in result.output
|
|
|
|
def test_bom_wiring_with_subcircuit(self):
|
|
"""--bom-wiring with -s uses single-module mapping."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[str(FIXTURES / "simple_board.net"), "-s", "amplifier_board", "--bom-wiring"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Cable" in result.output
|
|
|
|
def test_bom_and_bom_wiring_mutually_exclusive(self):
|
|
"""--bom and --bom-wiring cannot be used together."""
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main,
|
|
[str(FIXTURES / "simple_board.net"), "--bom", "--bom-wiring"],
|
|
)
|
|
assert result.exit_code != 0
|
|
|
|
def test_bom_to_file(self, tmp_path):
|
|
"""--bom with -o writes to file."""
|
|
out = tmp_path / "bom.csv"
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
main, [str(FIXTURES / "simple_board.net"), "--bom", "-o", str(out)]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert out.exists()
|
|
content = out.read_text()
|
|
assert "Reference" in content
|