From 5a5337566c7d26aa49209508068e2772339524f4 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 07:00:39 -0700 Subject: [PATCH] Add BOM output, fix LTspice Tier 2 import, real .asc integration tests - 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 --- pyproject.toml | 3 +- src/spice2wireviz/__init__.py | 2 +- src/spice2wireviz/cli.py | 46 +++++ src/spice2wireviz/emitter/bom_emitter.py | 109 +++++++++++ src/spice2wireviz/parser/asc.py | 29 ++- tests/fixtures/1002A.asc | 96 ++++++++++ tests/fixtures/1002A.net | 19 ++ tests/test_asc.py | 91 +++++++++- tests/test_bom_emitter.py | 222 +++++++++++++++++++++++ tests/test_cli.py | 2 +- uv.lock | 2 +- 11 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 src/spice2wireviz/emitter/bom_emitter.py create mode 100644 tests/fixtures/1002A.asc create mode 100644 tests/fixtures/1002A.net create mode 100644 tests/test_bom_emitter.py diff --git a/pyproject.toml b/pyproject.toml index 04b3ba5..bb9c031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "spice2wireviz" -version = "2026.2.13" +version = "2026.2.14" description = "Convert LTspice SPICE netlists to WireViz wiring diagrams" authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] requires-python = ">=3.11" @@ -52,3 +52,4 @@ select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"] [tool.pytest.ini_options] testpaths = ["tests"] +markers = ["ltspice: requires LTspice binary installed locally"] diff --git a/src/spice2wireviz/__init__.py b/src/spice2wireviz/__init__.py index 54da59c..770d982 100644 --- a/src/spice2wireviz/__init__.py +++ b/src/spice2wireviz/__init__.py @@ -1,3 +1,3 @@ """spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams.""" -__version__ = "2026.2.13" +__version__ = "2026.2.14" diff --git a/src/spice2wireviz/cli.py b/src/spice2wireviz/cli.py index e1b5f64..bee611e 100644 --- a/src/spice2wireviz/cli.py +++ b/src/spice2wireviz/cli.py @@ -10,6 +10,7 @@ from pathlib import Path import click from . import __version__ +from .emitter.bom_emitter import emit_component_bom, emit_wiring_bom from .emitter.yaml_emitter import emit_yaml from .filter import FilterConfig, apply_filters from .mapper.inter_module import map_inter_module @@ -129,6 +130,22 @@ def _parse_comma_list(ctx: click.Context, param: click.Parameter, value: str | N is_flag=True, help="For .asc files: invoke LTspice to generate a netlist if no companion .net exists.", ) +@click.option( + "--ltspice-exe", + type=click.Path(path_type=Path), + default=None, + help="Path to LTspice binary (for --generate-netlist). Auto-detected if omitted.", +) +@click.option( + "--bom", + is_flag=True, + help="Emit component BOM as CSV instead of WireViz YAML.", +) +@click.option( + "--bom-wiring", + is_flag=True, + help="Emit wiring BOM as CSV instead of WireViz YAML (requires full mapping).", +) @click.version_option(version=__version__) def main( input_file: Path, @@ -152,8 +169,16 @@ def main( list_components: bool, dry_run: bool, generate_netlist: bool, + ltspice_exe: Path | None, + bom: bool, + bom_wiring: bool, ) -> None: """Convert SPICE netlist to WireViz YAML wiring diagram.""" + # --- Validate mutually exclusive BOM flags --- + if bom and bom_wiring: + click.echo("Error: --bom and --bom-wiring are mutually exclusive.", err=True) + sys.exit(1) + # --- Parse input (format detection) --- is_asc = input_file.suffix.lower() == ".asc" completeness = DataCompleteness.FULL # default for .net files @@ -163,6 +188,7 @@ def main( asc_result = parse_asc( input_file, allow_ltspice_generation=generate_netlist, + ltspice_exe=str(ltspice_exe) if ltspice_exe else None, ) netlist = asc_result.netlist completeness = asc_result.completeness @@ -234,6 +260,16 @@ def main( click.echo(f" {inst.reference} ({inst.subcircuit_name}): {nets}") return + # --- Component BOM (works on any completeness level) --- + if bom: + csv_str = emit_component_bom(netlist, filter_config) + if output: + output.write_text(csv_str, encoding="utf-8") + click.echo(f"Wrote component BOM: {output}", err=True) + else: + click.echo(csv_str, nl=False) + return + # --- Safety gate: block diagram generation on METADATA_ONLY --- if completeness == DataCompleteness.METADATA_ONLY: click.echo( @@ -304,6 +340,16 @@ def main( else: wireviz_dict = map_inter_module(netlist, filter_config, meta) + # --- Wiring BOM (requires mapping) --- + if bom_wiring: + csv_str = emit_wiring_bom(wireviz_dict) + if output: + output.write_text(csv_str, encoding="utf-8") + click.echo(f"Wrote wiring BOM: {output}", err=True) + else: + click.echo(csv_str, nl=False) + return + # --- Dry run --- if dry_run: conn_count = len(wireviz_dict.get("connectors", {})) diff --git a/src/spice2wireviz/emitter/bom_emitter.py b/src/spice2wireviz/emitter/bom_emitter.py new file mode 100644 index 0000000..ff6a5a8 --- /dev/null +++ b/src/spice2wireviz/emitter/bom_emitter.py @@ -0,0 +1,109 @@ +"""Bill of Materials CSV output for spice2wireviz. + +Two BOM types: + - Component BOM: boundary components (connectors, test points) from parsed netlist + - Wiring BOM: cables and wire connections from mapped WireViz output +""" + +import csv +import io +from typing import Any + +from ..filter import FilterConfig, filter_component, filter_instance +from ..parser.models import ParsedNetlist + + +def emit_component_bom(netlist: ParsedNetlist, config: FilterConfig) -> str: + """Generate a CSV bill of materials for boundary components. + + Lists connectors, test points, and subcircuit instances that pass + the filter configuration. Each row describes one component with its + reference, prefix, value, pin count, subcircuit scope, and attributes. + + Returns: + CSV string with header row. + """ + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerow(["Reference", "Prefix", "Value", "Pins", "Subcircuit", "Attributes"]) + + for comp in netlist.top_level_components: + if not filter_component(comp, config, netlist): + continue + attrs = "; ".join(f"{k}={v}" for k, v in sorted(comp.attributes.items())) + writer.writerow([ + comp.reference, + comp.prefix, + comp.value, + len(comp.pins) or len(comp.nodes), + comp.subcircuit_scope, + attrs, + ]) + + # Include boundary components inside subcircuit definitions + for subckt_def in netlist.subcircuit_defs.values(): + for comp in subckt_def.boundary_components: + if not filter_component(comp, config, netlist): + continue + attrs = "; ".join(f"{k}={v}" for k, v in sorted(comp.attributes.items())) + writer.writerow([ + comp.reference, + comp.prefix, + comp.value, + len(comp.pins) or len(comp.nodes), + subckt_def.name, + attrs, + ]) + + for inst in netlist.instances: + if not filter_instance(inst, config, netlist): + continue + attrs = "; ".join(f"{k}={v}" for k, v in sorted(inst.attributes.items())) + writer.writerow([ + inst.reference, + "X", + inst.subcircuit_name, + len(inst.port_to_net), + "", + attrs, + ]) + + return buf.getvalue() + + +def emit_wiring_bom(wireviz_dict: dict[str, Any]) -> str: + """Generate a CSV bill of materials for cables/wires from mapped output. + + Each row describes one cable with its name, wire count, net labels, + and connected endpoints. + + Returns: + CSV string with header row. + """ + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + writer.writerow(["Cable", "Wirecount", "Nets", "From", "To"]) + + cables = wireviz_dict.get("cables", {}) + connections = wireviz_dict.get("connections", []) + + # Build cable -> (from_connector, to_connector) mapping from connections + cable_endpoints: dict[str, tuple[str, str]] = {} + for conn_set in connections: + if len(conn_set) < 3: + continue + from_name = next(iter(conn_set[0])) + cable_name = next(iter(conn_set[1])) + to_name = next(iter(conn_set[2])) + cable_endpoints[cable_name] = (from_name, to_name) + + for cable_name, cable_def in cables.items(): + wirecount = cable_def.get("wirecount", 0) + if cable_def.get("colors"): + wirecount = max(wirecount, len(cable_def["colors"])) + wirelabels = cable_def.get("wirelabels", []) + nets = ", ".join(wirelabels) if wirelabels else "" + from_conn, to_conn = cable_endpoints.get(cable_name, ("", "")) + writer.writerow([cable_name, wirecount, nets, from_conn, to_conn]) + + return buf.getvalue() diff --git a/src/spice2wireviz/parser/asc.py b/src/spice2wireviz/parser/asc.py index 718619f..2ba6dc2 100644 --- a/src/spice2wireviz/parser/asc.py +++ b/src/spice2wireviz/parser/asc.py @@ -46,6 +46,7 @@ def parse_asc( *, allow_ltspice_generation: bool = False, ltspice_timeout: float = 30.0, + ltspice_exe: str | Path | None = None, ) -> AscParseResult: """Parse an LTspice .asc schematic file with tiered netlist resolution. @@ -58,6 +59,9 @@ def parse_asc( allow_ltspice_generation: If True, attempt to invoke LTspice when no companion netlist is found. Requires LTspice binary on PATH. ltspice_timeout: Timeout in seconds for LTspice netlist generation. + ltspice_exe: Explicit path to LTspice binary. When provided, calls + LTspice.create_from() to configure the simulator. When omitted, + spicelib uses its own auto-detection. Returns: AscParseResult with the parsed netlist, completeness level, and @@ -79,7 +83,7 @@ def parse_asc( # Tier 2: LTspice generation (opt-in) if allow_ltspice_generation: - result = _try_ltspice_generation(path, ltspice_timeout) + result = _try_ltspice_generation(path, ltspice_timeout, ltspice_exe) if result is not None: return result @@ -109,24 +113,34 @@ def _try_companion_netlist(asc_path: Path) -> AscParseResult | None: def _try_ltspice_generation( - asc_path: Path, timeout: float + asc_path: Path, + timeout: float, + ltspice_exe: str | Path | None = None, ) -> AscParseResult | None: """Tier 2: Invoke LTspice to generate a netlist from the .asc file.""" try: - from spicelib.sim.simulator import Simulator + from spicelib.simulators.ltspice_simulator import LTspice except ImportError: return None - if not Simulator.is_available(): + # Configure the binary path if explicitly provided + if ltspice_exe is not None: + exe_path = Path(ltspice_exe) + if not exe_path.exists(): + print(f"LTspice binary not found: {exe_path}", file=sys.stderr) + return None + LTspice.create_from(str(exe_path)) + + if not LTspice.is_available(): print( - "LTspice binary not found on PATH; skipping netlist generation.", + "LTspice binary not found; skipping netlist generation. " + "Use --ltspice-exe to specify the path.", file=sys.stderr, ) return None - net_path = asc_path.with_suffix(".net") try: - Simulator.run(str(asc_path), timeout=timeout) + net_path = LTspice.create_netlist(str(asc_path), timeout=timeout) except TimeoutError as exc: print(f"LTspice timed out after {timeout}s: {exc}", file=sys.stderr) return None @@ -140,6 +154,7 @@ def _try_ltspice_generation( ) return None + net_path = Path(net_path) if not net_path.exists(): print( f"LTspice did not produce expected output: {net_path}", diff --git a/tests/fixtures/1002A.asc b/tests/fixtures/1002A.asc new file mode 100644 index 0000000..c1191e6 --- /dev/null +++ b/tests/fixtures/1002A.asc @@ -0,0 +1,96 @@ +Version 4 +SHEET 1 896 680 +WIRE -256 64 -256 48 +WIRE -128 64 -128 48 +WIRE 288 64 128 64 +WIRE 400 64 368 64 +WIRE 432 64 400 64 +WIRE 544 64 512 64 +WIRE 32 112 16 112 +WIRE 128 112 128 64 +WIRE 128 112 112 112 +WIRE 160 112 128 112 +WIRE 272 112 240 112 +WIRE -256 160 -256 144 +WIRE -128 160 -128 144 +WIRE 192 208 192 192 +WIRE 128 224 128 112 +WIRE 160 224 128 224 +WIRE 448 224 448 208 +WIRE 272 240 272 112 +WIRE 272 240 224 240 +WIRE 304 240 272 240 +WIRE 400 240 400 64 +WIRE 400 240 384 240 +WIRE 416 240 400 240 +WIRE 64 256 -96 256 +WIRE 160 256 64 256 +WIRE 544 256 544 64 +WIRE 544 256 480 256 +WIRE 592 256 544 256 +WIRE -96 272 -96 256 +WIRE 416 272 400 272 +WIRE 192 288 192 272 +WIRE 448 304 448 288 +WIRE -96 384 -96 352 +WIRE 160 384 -96 384 +WIRE 400 384 400 272 +WIRE 400 384 160 384 +WIRE -96 400 -96 384 +WIRE -96 496 -96 480 +FLAG -128 160 0 +FLAG 192 192 +V +FLAG -128 48 +V +FLAG 192 288 -V +FLAG -256 160 0 +FLAG -256 48 -V +FLAG 16 112 0 +FLAG -96 496 0 +FLAG 448 208 +V +FLAG 448 304 -V +FLAG 64 256 IN- +FLAG 160 384 IN+ +FLAG 592 256 OUT +SYMBOL voltage -128 48 R0 +SYMATTR InstName V1 +SYMATTR Value 15 +SYMBOL voltage -256 48 R0 +SYMATTR InstName V2 +SYMATTR Value -15 +SYMBOL res 256 96 R90 +WINDOW 0 0 56 VBottom 2 +WINDOW 3 32 56 VTop 2 +SYMATTR InstName R1 +SYMATTR Value 10K +SYMBOL res 128 96 R90 +WINDOW 0 0 56 VBottom 2 +WINDOW 3 32 56 VTop 2 +SYMATTR InstName R2 +SYMATTR Value 100K +SYMBOL voltage -96 256 R0 +SYMATTR InstName V3 +SYMATTR Value SINE(0 1m 100) +SYMBOL res 384 48 R90 +WINDOW 0 0 56 VBottom 2 +WINDOW 3 32 56 VTop 2 +SYMATTR InstName R3 +SYMATTR Value 2.2K +SYMBOL res 400 224 R90 +WINDOW 0 0 56 VBottom 2 +WINDOW 3 32 56 VTop 2 +SYMATTR InstName R4 +SYMATTR Value 10K +SYMBOL res 528 48 R90 +WINDOW 0 0 56 VBottom 2 +WINDOW 3 32 56 VTop 2 +SYMATTR InstName R5 +SYMATTR Value 100K +SYMBOL voltage -96 384 R0 +SYMATTR InstName V4 +SYMATTR Value SINE(0 1 10) +SYMBOL opamps\\LT1002A 192 176 R0 +SYMATTR InstName U1 +SYMBOL opamps\\LT1002A 448 192 R0 +SYMATTR InstName U2 +TEXT 440 392 Left 2 !.tran 300m +TEXT -48 -16 Left 2 ;Two Op Amp Instrumentation Amplifier diff --git a/tests/fixtures/1002A.net b/tests/fixtures/1002A.net new file mode 100644 index 0000000..7c6e423 --- /dev/null +++ b/tests/fixtures/1002A.net @@ -0,0 +1,19 @@ +* Z:\tmp\1002A.asc +* Generated by LTspice 26.0.1 for Windows. +V1 +V 0 15 +V2 -V 0 -15 +R1 N003 N001 10K +R2 N001 0 100K +V3 IN- IN+ SINE(0 1m 100) +R3 N002 N001 2.2K +R4 N002 N003 10K +R5 OUT N002 100K +V4 IN+ 0 SINE(0 1 10) +X§U1 IN- N001 +V -V N003 LT1001 ;§pnba In+)In-)V+)V-)OUT +X§U2 IN+ N002 +V -V OUT LT1001 ;§pnba In+)In-)V+)V-)OUT +.tran 300m +* Two Op Amp Instrumentation Amplifier +* Library below included based on ModelFile attribute of instance X§U1, X§U2 (C:\users\rpm\AppData\Local\LTspice\lib\sym\OpAmps\LT1002A.asy) +.lib LTC.lib +.backanno +.end diff --git a/tests/test_asc.py b/tests/test_asc.py index e92c468..256902c 100644 --- a/tests/test_asc.py +++ b/tests/test_asc.py @@ -206,8 +206,7 @@ class TestLTspiceGeneration: result = parse_asc(asc) assert result.completeness == DataCompleteness.METADATA_ONLY - @patch("spice2wireviz.parser.asc.Simulator", create=True) - def test_ltspice_generation_success(self, mock_sim_cls, tmp_path): + def test_ltspice_generation_success(self, tmp_path): """Mocked LTspice successfully generates a .net file.""" asc = tmp_path / "test.asc" asc.write_text("Version 4\nSHEET 1 880 680\n") @@ -220,7 +219,7 @@ class TestLTspiceGeneration: ".ends gen_mod\n" ) - # Mock the import path inside _try_ltspice_generation + # Mock the whole Tier 2 function with patch( "spice2wireviz.parser.asc._try_ltspice_generation" ) as mock_tier2: @@ -279,3 +278,89 @@ class TestCompanionNetlistInternalHelper: assert result is not None assert result.completeness == DataCompleteness.FULL assert result.source_net == net + + +class TestRealAscIntegration: + """Integration tests with a real LTspice-generated .asc / .net pair. + + 1002A.asc is a two op-amp instrumentation amplifier from the LTspice + demo circuit archive. The companion 1002A.net was pre-generated by + LTspice so these tests work in CI without LTspice installed. + """ + + def test_real_asc_companion_resolution(self): + """Tier 1: 1002A.asc resolves to companion 1002A.net.""" + result = parse_asc(FIXTURES / "1002A.asc") + assert result.completeness == DataCompleteness.FULL + assert result.source_net is not None + assert result.source_net.name == "1002A.net" + + def test_real_asc_has_components(self): + """The parsed netlist has X* instances (op-amps).""" + result = parse_asc(FIXTURES / "1002A.asc") + netlist = result.netlist + # 1002A.net has X§U1 and X§U2 (LTspice op-amp instances) + x_refs = [inst.reference for inst in netlist.instances] + assert len(x_refs) >= 2 + # The § character is part of LTspice's hierarchical naming + assert any("U1" in ref for ref in x_refs) + assert any("U2" in ref for ref in x_refs) + + def test_real_asc_has_nets(self): + """The parsed netlist has known nets from the circuit.""" + result = parse_asc(FIXTURES / "1002A.asc") + netlist = result.netlist + assert len(netlist.all_nets) > 0 + # The circuit uses +V, -V, OUT, IN+, IN- as net names + net_names = {n.upper() for n in netlist.all_nets} + assert "+V" in net_names or "V+" in net_names or any("V" in n for n in net_names) + + def test_real_asc_no_warnings_on_companion(self): + """Companion resolution should produce no warnings.""" + result = parse_asc(FIXTURES / "1002A.asc") + # Warnings are acceptable for enrichment, but not for core parsing + # Only check that we didn't get "no companion" warnings + for w in result.warnings: + assert "no companion" not in w.lower() + + @pytest.mark.ltspice + def test_real_asc_ltspice_generation(self, tmp_path): + """Tier 2: generate .net from .asc using LTspice binary. + + Requires LTspice installed. Skipped in CI. + """ + import shutil + + ltspice_path = Path("/home/rpm/.local/bin/ltspice") + if not ltspice_path.exists(): + pytest.skip("LTspice binary not found") + + # Copy .asc to temp dir (avoid polluting fixtures) + asc_copy = tmp_path / "1002A.asc" + shutil.copy2(FIXTURES / "1002A.asc", asc_copy) + + # Remove any existing .net so Tier 2 is forced + net_copy = tmp_path / "1002A.net" + if net_copy.exists(): + net_copy.unlink() + + result = parse_asc( + asc_copy, + allow_ltspice_generation=True, + ltspice_exe=str(ltspice_path), + ) + assert result.completeness == DataCompleteness.FULL + assert result.source_net is not None + assert result.source_net.name == "1002A.net" + + def test_ltspice_exe_nonexistent_path(self, tmp_path): + """--ltspice-exe with a bad path falls through to Tier 3.""" + asc = tmp_path / "test.asc" + asc.write_text("Version 4\nSHEET 1 880 680\n") + + result = parse_asc( + asc, + allow_ltspice_generation=True, + ltspice_exe="/nonexistent/ltspice", + ) + assert result.completeness == DataCompleteness.METADATA_ONLY diff --git a/tests/test_bom_emitter.py b/tests/test_bom_emitter.py new file mode 100644 index 0000000..1947ca0 --- /dev/null +++ b/tests/test_bom_emitter.py @@ -0,0 +1,222 @@ +"""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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 65d39cf..e0ced9a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,7 +14,7 @@ class TestCLIBasic: runner = CliRunner() result = runner.invoke(main, ["--version"]) assert result.exit_code == 0 - assert "2026.2.13" in result.output + assert "2026.2.14" in result.output def test_list_subcircuits(self): runner = CliRunner() diff --git a/uv.lock b/uv.lock index b0a68d8..ca35d55 100644 --- a/uv.lock +++ b/uv.lock @@ -994,7 +994,7 @@ wheels = [ [[package]] name = "spice2wireviz" -version = "2026.2.13" +version = "2026.2.14" source = { editable = "." } dependencies = [ { name = "click" },