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