spice2wireviz/tests/test_bom_emitter.py

223 lines
7.5 KiB
Python
Raw Permalink Normal View History

"""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