spice2wireviz/tests/test_asc.py
Ryan Malloy 5a5337566c 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
2026-02-13 07:00:39 -07:00

367 lines
14 KiB
Python

"""Tests for the tiered .asc file parser."""
from pathlib import Path
from unittest.mock import patch
import pytest
from spice2wireviz.parser.asc import (
AscParseResult,
DataCompleteness,
_AscMetadata,
_build_metadata_only_netlist,
_enrich_netlist_with_metadata,
_try_companion_netlist,
parse_asc,
)
from spice2wireviz.parser.models import ParsedNetlist, SpiceComponent
FIXTURES = Path(__file__).parent / "fixtures"
class TestCompanionNetlistDetection:
"""Tier 1: find companion .net beside the .asc and get FULL connectivity."""
def test_finds_companion_net(self):
"""simple_board.asc has simple_board.net in the same directory."""
result = parse_asc(FIXTURES / "simple_board.asc")
assert result.completeness == DataCompleteness.FULL
assert result.source_net is not None
assert result.source_net.name == "simple_board.net"
def test_full_connectivity_from_companion(self):
"""The companion .net provides real subcircuit defs and connectivity."""
result = parse_asc(FIXTURES / "simple_board.asc")
netlist = result.netlist
assert "amplifier_board" in netlist.subcircuit_defs
subckt = netlist.subcircuit_defs["amplifier_board"]
assert len(subckt.port_names) == 4
assert len(subckt.boundary_components) >= 2 # J1, J2, TP1
def test_companion_priority_net_over_cir(self, tmp_path):
"""When both .net and .cir exist, .net wins (first in priority)."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
net = tmp_path / "test.net"
net.write_text(
"* Test\n"
".subckt mymod A B\n"
"J1 A B CONN\n"
".ends mymod\n"
)
cir = tmp_path / "test.cir"
cir.write_text("* Different file\n.end\n")
result = parse_asc(asc)
assert result.source_net == net
def test_companion_cir_fallback(self, tmp_path):
"""When only .cir exists (no .net), it's used as companion."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
cir = tmp_path / "test.cir"
cir.write_text(
"* Test\n"
".subckt mymod A\n"
"J1 A CONN\n"
".ends mymod\n"
)
result = parse_asc(asc)
assert result.completeness == DataCompleteness.FULL
assert result.source_net == cir
class TestMetadataEnrichment:
"""Verify .asc values/attrs merge into .net parse (additive only)."""
def test_enrichment_adds_attributes(self):
"""Metadata from .asc should add attributes to the netlist."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", value="", nodes=["VIN", "GND"]),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "PWR_CONN", "SpiceModel": "DB9"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
j1 = netlist.top_level_components[0]
assert j1.value == "PWR_CONN" # filled empty value
assert j1.attributes["SpiceModel"] == "DB9" # new attribute added
def test_enrichment_never_overwrites_value(self):
"""If the .net parse already has a value, .asc should not overwrite it."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", value="EXISTING", nodes=["A"]),
],
)
metadata = _AscMetadata(
components=[{"ref": "J1", "prefix": "J", "value": "DIFFERENT"}]
)
_enrich_netlist_with_metadata(netlist, metadata)
assert netlist.top_level_components[0].value == "EXISTING"
def test_enrichment_never_overwrites_existing_attrs(self):
"""Existing attributes from .net parse must not be overwritten."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(
reference="J1", prefix="J", nodes=["A"],
attributes={"Footprint": "from-net"},
),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "", "Footprint": "from-asc", "NewAttr": "v"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
j1 = netlist.top_level_components[0]
assert j1.attributes["Footprint"] == "from-net" # not overwritten
assert j1.attributes["NewAttr"] == "v" # new one added
def test_enrichment_handles_unmatched_refs(self):
"""Components in .asc that aren't in .net are silently skipped."""
netlist = ParsedNetlist(
top_level_components=[
SpiceComponent(reference="J1", prefix="J", nodes=["A"]),
],
)
metadata = _AscMetadata(
components=[
{"ref": "J99", "prefix": "J", "value": "PHANTOM"},
]
)
_enrich_netlist_with_metadata(netlist, metadata)
assert netlist.top_level_components[0].value == "" # unchanged
class TestMetadataOnlyParsing:
"""Tier 3: .asc with no companion .net produces METADATA_ONLY."""
def test_standalone_is_metadata_only(self):
"""standalone.asc has no companion .net — should be METADATA_ONLY."""
result = parse_asc(FIXTURES / "standalone.asc")
assert result.completeness == DataCompleteness.METADATA_ONLY
assert result.source_net is None
def test_metadata_only_has_warnings(self):
"""METADATA_ONLY results must include a warning about missing connectivity."""
result = parse_asc(FIXTURES / "standalone.asc")
warning_texts = " ".join(result.warnings)
assert "connectivity" in warning_texts.lower() or "companion" in warning_texts.lower()
def test_metadata_only_netlist_has_no_connectivity(self):
"""A metadata-only netlist should have empty connectivity fields."""
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "DB9"},
{"ref": "X1", "prefix": "X", "value": "OpAmpBoard"},
]
)
warnings: list[str] = []
netlist = _build_metadata_only_netlist(metadata, warnings)
assert len(netlist.top_level_components) == 1
assert netlist.top_level_components[0].reference == "J1"
assert netlist.top_level_components[0].nodes == []
assert netlist.top_level_components[0].pins == []
assert len(netlist.instances) == 1
assert netlist.instances[0].port_to_net == {}
def test_metadata_only_preserves_attributes(self):
"""Extra attributes from .asc should appear in the metadata-only netlist."""
metadata = _AscMetadata(
components=[
{"ref": "J1", "prefix": "J", "value": "DB9", "Footprint": "DSUB9"},
]
)
warnings: list[str] = []
netlist = _build_metadata_only_netlist(metadata, warnings)
j1 = netlist.top_level_components[0]
assert j1.attributes["Footprint"] == "DSUB9"
class TestLTspiceGeneration:
"""Tier 2: LTspice-generated netlist (mocked — no real LTspice in CI)."""
def test_ltspice_generation_opt_in(self, tmp_path):
"""LTspice generation is skipped unless allow_ltspice_generation=True."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
# Without opt-in, should fall through to Tier 3
result = parse_asc(asc)
assert result.completeness == DataCompleteness.METADATA_ONLY
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")
net = tmp_path / "test.net"
net.write_text(
"* Generated\n"
".subckt gen_mod A B\n"
"J1 A B CONN\n"
".ends gen_mod\n"
)
# Mock the whole Tier 2 function
with patch(
"spice2wireviz.parser.asc._try_ltspice_generation"
) as mock_tier2:
mock_tier2.return_value = AscParseResult(
netlist=ParsedNetlist(),
completeness=DataCompleteness.FULL,
source_net=net,
)
result = parse_asc(asc, allow_ltspice_generation=True)
assert result.completeness == DataCompleteness.FULL
def test_ltspice_generation_fallback_on_failure(self, tmp_path):
"""If LTspice invocation fails, falls through to Tier 3."""
asc = tmp_path / "test.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
# No LTspice binary available, so Tier 2 returns None
result = parse_asc(asc, allow_ltspice_generation=True)
assert result.completeness == DataCompleteness.METADATA_ONLY
class TestErrorHandling:
def test_nonexistent_file(self):
with pytest.raises(FileNotFoundError, match="ASC file not found"):
parse_asc("/nonexistent/path/board.asc")
def test_wrong_extension(self, tmp_path):
net = tmp_path / "board.net"
net.write_text("* netlist\n")
with pytest.raises(ValueError, match=r"Expected \.asc file"):
parse_asc(net)
def test_spicelib_not_required_for_companion(self):
"""Tier 1 works even without spicelib installed."""
# simple_board.asc has a companion .net — spicelib is only needed
# for enrichment, which gracefully degrades
result = parse_asc(FIXTURES / "simple_board.asc")
assert result.completeness == DataCompleteness.FULL
class TestCompanionNetlistInternalHelper:
"""Direct tests for _try_companion_netlist."""
def test_returns_none_when_no_companion(self, tmp_path):
asc = tmp_path / "isolated.asc"
asc.write_text("Version 4\nSHEET 1 880 680\n")
assert _try_companion_netlist(asc) is None
def test_returns_result_with_source_net(self, tmp_path):
asc = tmp_path / "board.asc"
asc.write_text("Version 4\n")
net = tmp_path / "board.net"
net.write_text("* test\n.subckt s A\nJ1 A C\n.ends s\n")
result = _try_companion_netlist(asc)
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