"""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 @patch("spice2wireviz.parser.asc.Simulator", create=True) def test_ltspice_generation_success(self, mock_sim_cls, 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 import path inside _try_ltspice_generation 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