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