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
This commit is contained in:
Ryan Malloy 2026-02-13 07:00:39 -07:00
parent 08c92bfefb
commit 5a5337566c
11 changed files with 607 additions and 14 deletions

View File

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

View File

@ -1,3 +1,3 @@
"""spice2wireviz — Convert LTspice SPICE netlists to WireViz wiring diagrams."""
__version__ = "2026.2.13"
__version__ = "2026.2.14"

View File

@ -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", {}))

View File

@ -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()

View File

@ -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}",

96
tests/fixtures/1002A.asc vendored Normal file
View File

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

19
tests/fixtures/1002A.net vendored Normal file
View File

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

View File

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

222
tests/test_bom_emitter.py Normal file
View File

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

View File

@ -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()

2
uv.lock generated
View File

@ -994,7 +994,7 @@ wheels = [
[[package]]
name = "spice2wireviz"
version = "2026.2.13"
version = "2026.2.14"
source = { editable = "." }
dependencies = [
{ name = "click" },