"""Tests for the single-module mapper.""" from pathlib import Path import pytest from spice2wireviz.filter import FilterConfig from spice2wireviz.mapper.single_module import ( _optimize_single_layout, _remap_pins, map_single_module, ) from spice2wireviz.parser.netlist import parse_netlist FIXTURES = Path(__file__).parent / "fixtures" class TestSingleModuleMapping: def test_simple_board_connectors(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") connectors = result["connectors"] assert "amplifier_board" in connectors # module header assert "J1" in connectors assert "J2" in connectors assert "TP1" in connectors def test_module_header_pinlabels(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") header = result["connectors"]["amplifier_board"] assert header["pinlabels"] == ["VIN", "GND", "VOUT", "SIGNAL_IN"] assert header["type"] == "Module Interface" def test_connector_pinlabels(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") j1 = result["connectors"]["J1"] assert j1["pinlabels"] == ["VIN", "GND"] def test_test_point_style(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") tp1 = result["connectors"]["TP1"] assert tp1.get("style") == "simple" def test_cables_created(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") cables = result["cables"] assert len(cables) >= 2 # At least cables for J1 and J2 connections def test_connections_created(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") connections = result["connections"] assert len(connections) >= 2 def test_traceability_notes(self): netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") header = result["connectors"]["amplifier_board"] assert "SPICE subcircuit" in header["notes"] def test_nonexistent_subcircuit_raises(self): netlist = parse_netlist(FIXTURES / "simple_board.net") with pytest.raises(ValueError, match="not found"): map_single_module(netlist, "nonexistent") def test_metadata_passthrough(self): netlist = parse_netlist(FIXTURES / "simple_board.net") meta = {"title": "Test Diagram", "author": "Test"} result = map_single_module(netlist, "amplifier_board", metadata=meta) assert result["metadata"]["title"] == "Test Diagram" class TestSingleModuleFiltering: def test_no_ground_filter(self): netlist = parse_netlist(FIXTURES / "simple_board.net") config = FilterConfig(show_ground=False) result = map_single_module(netlist, "amplifier_board", config) header = result["connectors"]["amplifier_board"] assert "GND" not in header["pinlabels"] def test_no_power_filter(self): """When power is hidden, VIN should still appear as it's a port name, not a power net.""" netlist = parse_netlist(FIXTURES / "simple_board.net") config = FilterConfig(show_power=False) result = map_single_module(netlist, "amplifier_board", config) # VIN is a port name — it's not VCC/VDD so it stays header = result["connectors"]["amplifier_board"] assert "VIN" in header["pinlabels"] def test_exclude_component_ref(self): netlist = parse_netlist(FIXTURES / "simple_board.net") config = FilterConfig(exclude_refs=["TP1"]) result = map_single_module(netlist, "amplifier_board", config) assert "TP1" not in result["connectors"] class TestSingleModuleLayoutOptimization: """Tests for the star-topology layout optimization.""" def test_boundary_component_order(self): """Boundary components should appear in header pin order.""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") names = list(result["connectors"].keys()) # Header first, then J1 (avg pin 1.5), then J2 (avg pin 3.5), then TP1 assert names[0] == "amplifier_board" assert names.index("J1") < names.index("J2") def test_j2_pins_reordered_to_eliminate_crossing(self): """J2 pins should be [VOUT, SIGNAL_IN] to match header order.""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") j2 = result["connectors"]["J2"] # Header has VOUT@3, SIGNAL_IN@4 — J2 must match: VOUT first assert j2["pinlabels"] == ["VOUT", "SIGNAL_IN"] def test_connections_parallel_after_optimization(self): """After optimization, cable wires should not cross (pins monotonic).""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") for conn in result["connections"]: left_pins = conn[0][next(iter(conn[0]))] right_pins = conn[2][next(iter(conn[2]))] l_list = [left_pins] if isinstance(left_pins, int) else left_pins r_list = [right_pins] if isinstance(right_pins, int) else right_pins assert l_list == sorted(l_list), f"Left pins not sorted: {l_list}" assert r_list == sorted(r_list), f"Right pins not sorted: {r_list}" def test_connection_pins_valid(self): """All pin indices must be within the connector's pin count.""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") connectors = result["connectors"] for conn in result["connections"]: for entry in [conn[0], conn[2]]: name = next(iter(entry)) pins = entry[name] pin_list = [pins] if isinstance(pins, int) else pins n_pins = len(connectors[name].get("pinlabels", [])) if n_pins == 0: n_pins = connectors[name].get("pincount", 0) for p in pin_list: assert 1 <= p <= n_pins, ( f"Pin {p} out of range for {name} ({n_pins} pins)" ) def test_optimize_no_crash_single_connector(self): """Single connector (no boundary) should pass through.""" connectors = {"header": {"pinlabels": ["A", "B"]}} result_c, _cb, _cn = _optimize_single_layout("header", connectors, {}, []) assert result_c == connectors def test_optimize_no_crash_empty(self): """Empty inputs should pass through.""" result_c, _cb, _cn = _optimize_single_layout("header", {}, {}, []) assert result_c == {} def test_header_pin_regrouping(self): """Header pins connecting to the same component should be grouped.""" # Synthetic: header has interleaved pins # Pin 1 → compA, Pin 2 → compB, Pin 3 → compA, Pin 4 → compB connectors = { "H": {"pinlabels": ["A1", "B1", "A2", "B2"]}, "CA": {"pinlabels": ["X", "Y"]}, "CB": {"pinlabels": ["M", "N"]}, } connections = [ [{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}], [{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}], ] result_c, _, result_cn = _optimize_single_layout( "H", connectors, {}, connections ) # Header should be regrouped: [A1, A2, B1, B2] assert result_c["H"]["pinlabels"] == ["A1", "A2", "B1", "B2"] # CA should come before CB names = list(result_c.keys()) assert names.index("CA") < names.index("CB") def test_header_regrouping_updates_connections(self): """After header regrouping, connection pin indices must be updated.""" connectors = { "H": {"pinlabels": ["A1", "B1", "A2", "B2"]}, "CA": {"pinlabels": ["X", "Y"]}, "CB": {"pinlabels": ["M", "N"]}, } connections = [ [{"H": [1, 3]}, {"W1": [1, 2]}, {"CA": [1, 2]}], [{"H": [2, 4]}, {"W2": [1, 2]}, {"CB": [1, 2]}], ] _, _, result_cn = _optimize_single_layout("H", connectors, {}, connections) # After regrouping: H pins [A1,A2,B1,B2] → old 1→new 1, old 3→new 2, # old 2→new 3, old 4→new 4 # Connection 1: H:[1,3] should become H:[1,2] h_pins_0 = result_cn[0][0]["H"] assert h_pins_0 == [1, 2] # Connection 2: H:[2,4] should become H:[3,4] h_pins_1 = result_cn[1][0]["H"] assert h_pins_1 == [3, 4] def test_comp_pin_reorder_eliminates_crossing(self): """Component pins should be reordered to match header pin order.""" # Header: [A, B], CompX: [Y, X] where A→Y and B→X # This means header pin 1→comp pin 2, header pin 2→comp pin 1 (crossing!) connectors = { "H": {"pinlabels": ["A", "B"]}, "CX": {"pinlabels": ["Y", "X"]}, } connections = [ [{"H": [1, 2]}, {"W1": [1, 2]}, {"CX": [2, 1]}], ] result_c, _, result_cn = _optimize_single_layout( "H", connectors, {}, connections ) # CX should be reordered to [X, Y] so pin 1→pin 1, pin 2→pin 2 assert result_c["CX"]["pinlabels"] == ["X", "Y"] # Connection should now be CX:[1,2] (no crossing) assert result_cn[0][2]["CX"] == [1, 2] def test_disconnected_test_point_preserved(self): """Test points with no header connection should appear last.""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") names = list(result["connectors"].keys()) # TP1 connects to internal net N001 (not a port), so it's disconnected # from the header. It should appear after connected components. assert names[-1] == "TP1" def test_connections_sorted_by_header_pin(self): """Connections should be sorted by their minimum header pin.""" netlist = parse_netlist(FIXTURES / "simple_board.net") result = map_single_module(netlist, "amplifier_board") prev_min = 0 for conn in result["connections"]: # Header is always on the left in single-module connections left_name = next(iter(conn[0])) h_pins = conn[0][left_name] pin_list = [h_pins] if isinstance(h_pins, int) else h_pins min_pin = min(pin_list) assert min_pin >= prev_min, ( f"Connection order wrong: min pin {min_pin} < previous {prev_min}" ) prev_min = min_pin def test_shared_header_pin_deduplication(self): """Header pin shared by two components should not corrupt mapping.""" # Pin 2 of header connects to both CA and CB (e.g., GND fan-out) connectors = { "H": {"pinlabels": ["VIN", "GND", "VOUT"]}, "CA": {"pinlabels": ["X", "Y"]}, "CB": {"pinlabels": ["M", "N"]}, } connections = [ [{"H": [1, 2]}, {"W1": [1, 2]}, {"CA": [1, 2]}], [{"H": [2, 3]}, {"W2": [1, 2]}, {"CB": [1, 2]}], ] result_c, _, result_cn = _optimize_single_layout( "H", connectors, {}, connections ) # Should not crash, and all pin indices must be valid h_labels = result_c["H"]["pinlabels"] assert len(h_labels) == 3 # All connections should have valid pin references for conn in result_cn: h_pins = conn[0]["H"] pin_list = [h_pins] if isinstance(h_pins, int) else h_pins for p in pin_list: assert 1 <= p <= 3, f"Header pin {p} out of range" def test_remap_pins_missing_key_raises(self): """_remap_pins should raise ValueError with context on missing pin.""" mapping = {1: 2, 2: 1} with pytest.raises(ValueError, match="Pin remapping failed for J1"): _remap_pins("J1", [1, 3], mapping) def test_remap_pins_scalar(self): """_remap_pins should handle scalar pin input.""" mapping = {1: 3, 2: 1, 3: 2} assert _remap_pins("X", 2, mapping) == 1 def test_comp_pin_fanout_averaging(self): """Component pin connecting to multiple header pins should use average.""" # CX pin 1 connects to header pins 4 and 5 → remapped avg = 2.5 # CX pin 2 connects to header pin 1 → remapped pos = 1.0 # After header regrouping [1,4,5] → positions [1,2,3]: # pin 1: avg(2,3) = 2.5, pin 2: pos(1) = 1.0 # So pin 2 should come before pin 1 → [P2, P1] connectors = { "H": {"pinlabels": ["A", "B", "C", "D", "E"]}, "CX": {"pinlabels": ["P1", "P2"]}, } connections = [ [{"H": 4}, {"W1": 1}, {"CX": 1}], [{"H": 5}, {"W2": 1}, {"CX": 1}], [{"H": 1}, {"W3": 1}, {"CX": 2}], ] result_c, _, _ = _optimize_single_layout("H", connectors, {}, connections) # Pin 2 (remapped h_pos=1.0) before Pin 1 (avg remapped h_pos=2.5) assert result_c["CX"]["pinlabels"] == ["P2", "P1"]