"""Tests for the inter-module mapper.""" from pathlib import Path from spice2wireviz.filter import FilterConfig from spice2wireviz.mapper.inter_module import ( _build_adjacency, _compute_layer_ordering, _optimize_layout, _optimize_pin_order, _orient_connections, map_inter_module, ) from spice2wireviz.parser.netlist import parse_netlist FIXTURES = Path(__file__).parent / "fixtures" class TestInterModuleMapping: def test_multi_board_connectors(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) connectors = result["connectors"] # Should have connectors for each X instance and top-level components assert "X1" in connectors assert "X2" in connectors assert "X3" in connectors assert "J_CHASSIS" in connectors assert "TP_VCC" in connectors def test_instance_connector_type(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) x1 = result["connectors"]["X1"] assert x1["type"] == "power_supply" def test_instance_pinlabels(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) x2 = result["connectors"]["X2"] assert "VIN" in x2["pinlabels"] assert "GND" in x2["pinlabels"] assert "VOUT" in x2["pinlabels"] def test_cables_for_shared_nets(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) # Should have cables connecting modules via shared nets cables = result["cables"] assert len(cables) >= 1 def test_connections_exist(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) connections = result["connections"] assert len(connections) >= 1 def test_traceability_notes(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) x1 = result["connectors"]["X1"] assert "SPICE instance" in x1["notes"] def test_top_level_test_point_style(self): netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) tp = result["connectors"]["TP_VCC"] assert tp.get("style") == "simple" class TestInterModuleFiltering: def test_exclude_instance(self): netlist = parse_netlist(FIXTURES / "multi_board.net") config = FilterConfig(exclude_refs=["X1"]) result = map_inter_module(netlist, config) assert "X1" not in result["connectors"] assert "X2" in result["connectors"] def test_exclude_subcircuit(self): netlist = parse_netlist(FIXTURES / "multi_board.net") config = FilterConfig(exclude_subcircuits=["power_supply"]) result = map_inter_module(netlist, config) assert "X1" not in result["connectors"] def test_no_ground_filter(self): netlist = parse_netlist(FIXTURES / "multi_board.net") config = FilterConfig(show_ground=False) result = map_inter_module(netlist, config) # GND should not appear in any connector's pinlabels for name, conn in result["connectors"].items(): if "pinlabels" in conn: assert "GND" not in conn["pinlabels"], f"GND found in {name}" def test_no_power_filter(self): netlist = parse_netlist(FIXTURES / "multi_board.net") config = FilterConfig(show_power=False) result = map_inter_module(netlist, config) for name, conn in result["connectors"].items(): if "pinlabels" in conn: assert "VCC" not in conn["pinlabels"], f"VCC found in {name}" class TestHierarchicalInterModule: def test_hierarchical_instances(self): netlist = parse_netlist(FIXTURES / "hierarchical.net") result = map_inter_module(netlist) connectors = result["connectors"] assert "X_REG" in connectors assert "X_SENSOR" in connectors assert "X_MAIN" in connectors def test_external_connectors(self): netlist = parse_netlist(FIXTURES / "hierarchical.net") result = map_inter_module(netlist) connectors = result["connectors"] assert "J_PWR" in connectors assert "J_USB" in connectors assert "TP_3V3" in connectors def test_grouped_parallel_wires(self): """Parallel wires between same modules should be grouped.""" netlist = parse_netlist(FIXTURES / "hierarchical.net") config = FilterConfig(group_parallel_wires=True) result = map_inter_module(netlist, config) # With grouping, fewer cables than total net connections cables = result["cables"] connections = result["connections"] assert len(cables) == len(connections) def test_ungrouped_wires(self): """Without grouping, each wire gets its own cable.""" netlist = parse_netlist(FIXTURES / "hierarchical.net") config = FilterConfig(group_parallel_wires=False) result = map_inter_module(netlist, config) # Each cable should have wirecount=1 for cable in result["cables"].values(): assert cable.get("wirecount", 1) == 1 or len(cable.get("colors", [])) == 1 class TestLayoutOptimization: """Tests for the Sugiyama-lite layout optimization.""" def test_build_adjacency_weights(self): """Adjacency weights count wires between connector pairs.""" connections = [ [{"A": [1, 2]}, {"W1": [1, 2]}, {"B": [1, 2]}], [{"A": 3}, {"W2": 1}, {"C": 1}], ] adj = _build_adjacency(connections) assert adj["A"]["B"] == 2 # 2-wire cable assert adj["A"]["C"] == 1 # 1-wire cable assert adj["B"]["A"] == 2 # symmetric def test_layer_ordering_externals_first(self): """External connectors (J*, TP*) should be placed at layer 0 (leftmost).""" adj = { "J1": {"X1": 2}, "TP1": {"X1": 1}, "X1": {"J1": 2, "TP1": 1, "X2": 3}, "X2": {"X1": 3}, } order = _compute_layer_ordering(["X1", "X2", "J1", "TP1"], adj) # Externals before modules j1_pos = order.index("J1") tp1_pos = order.index("TP1") x1_pos = order.index("X1") x2_pos = order.index("X2") assert j1_pos < x1_pos assert tp1_pos < x1_pos assert x1_pos < x2_pos def test_layer_ordering_hierarchical(self): """Hierarchical fixture: externals -> X_REG -> X_MAIN -> X_SENSOR.""" netlist = parse_netlist(FIXTURES / "hierarchical.net") result = map_inter_module(netlist) conn_names = list(result["connectors"].keys()) # External connectors should come before all X* instances x_reg_pos = conn_names.index("X_REG") x_main_pos = conn_names.index("X_MAIN") x_sensor_pos = conn_names.index("X_SENSOR") for ext in ("J_PWR", "J_USB", "TP_3V3"): assert conn_names.index(ext) < x_reg_pos, f"{ext} should be before X_REG" # Module ordering by depth assert x_reg_pos < x_main_pos, "X_REG should be before X_MAIN" assert x_main_pos < x_sensor_pos, "X_MAIN should be before X_SENSOR" def test_layer_ordering_multi_board(self): """Multi-board fixture: externals -> X1 -> X2 -> X3.""" netlist = parse_netlist(FIXTURES / "multi_board.net") result = map_inter_module(netlist) conn_names = list(result["connectors"].keys()) x1_pos = conn_names.index("X1") x2_pos = conn_names.index("X2") x3_pos = conn_names.index("X3") for ext in ("J_CHASSIS", "TP_VCC"): assert conn_names.index(ext) < x1_pos, f"{ext} should be before X1" assert x1_pos < x2_pos, "X1 should be before X2" assert x2_pos < x3_pos, "X2 should be before X3" def test_orient_connections_left_to_right(self): """Connections should be oriented so the earlier connector is on the left.""" connections = [ [{"B": 1}, {"W1": 1}, {"A": 1}], # B is after A — should swap ] positions = {"A": 0, "B": 1} oriented = _orient_connections(connections, positions) left_name = next(iter(oriented[0][0])) right_name = next(iter(oriented[0][2])) assert left_name == "A" assert right_name == "B" def test_orient_preserves_correct_direction(self): """Already-correct orientation should be preserved.""" connections = [ [{"A": 1}, {"W1": 1}, {"B": 1}], ] positions = {"A": 0, "B": 1} oriented = _orient_connections(connections, positions) assert next(iter(oriented[0][0])) == "A" def test_pin_reorder_groups_by_neighbor(self): """Pins connecting to earlier neighbors should come before later ones.""" # Connector C at position 2 connects to A(0) via pin 3 and B(3) via pin 1 connections = [ [{"C": 3}, {"W1": 1}, {"A": 1}], # pin 3 → A(pos 0) [{"C": 1}, {"W2": 1}, {"B": 1}], # pin 1 → B(pos 3) ] positions = {"A": 0, "C": 2, "B": 3} new_labels, mapping = _optimize_pin_order( "C", ["P1", "P2", "P3"], connections, positions ) assert mapping is not None # Pin 3 (connecting to A at pos 0) should come first assert new_labels[0] == "P3" # Pin 1 (connecting to B at pos 3) should come after assert new_labels[-1] == "P1" or new_labels.index("P1") > new_labels.index("P3") def test_pin_reorder_averaging_star_topology(self): """A pin connected to multiple neighbors should use average position.""" # Pin 1 of C connects to A(pos 0) AND D(pos 4) — avg = 2 # Pin 2 of C connects to B(pos 1) connections = [ [{"C": 1}, {"W1": 1}, {"A": 1}], # pin 1 → A(pos 0) [{"C": 1}, {"W2": 1}, {"D": 1}], # pin 1 → D(pos 4) [{"C": 2}, {"W3": 1}, {"B": 1}], # pin 2 → B(pos 1) ] positions = {"A": 0, "B": 1, "C": 2, "D": 4} new_labels, mapping = _optimize_pin_order( "C", ["P1", "P2"], connections, positions ) # Pin 2 (avg neighbor pos = 1) should come before Pin 1 (avg = (0+4)/2 = 2) assert mapping is not None assert new_labels == ["P2", "P1"] def test_optimize_layout_no_crash_on_single_connector(self): """Single connector should pass through unchanged.""" connectors = {"X1": {"pinlabels": ["A", "B"]}} cables: dict = {} connections: list = [] result_c, _result_cb, _result_cn = _optimize_layout(connectors, cables, connections) assert result_c == connectors def test_optimize_layout_no_crash_on_empty(self): """Empty inputs should pass through.""" result_c, _result_cb, _result_cn = _optimize_layout({}, {}, []) assert result_c == {} def test_all_connections_reference_valid_pins(self): """After layout optimization, all connection pin indices must be valid.""" netlist = parse_netlist(FIXTURES / "hierarchical.net") result = map_inter_module(netlist) connectors = result["connectors"] for conn_set in result["connections"]: for entry in [conn_set[0], conn_set[2]]: # left and right connectors name = next(iter(entry)) pins = entry[name] if isinstance(pins, int): pins = [pins] n_pins = len(connectors[name].get("pinlabels", [])) if n_pins == 0: n_pins = connectors[name].get("pincount", 0) for p in pins: assert 1 <= p <= n_pins, ( f"Pin {p} out of range for {name} ({n_pins} pins)" ) def test_connections_sorted_by_distance(self): """Shorter connections (adjacent pairs) should come before longer ones.""" netlist = parse_netlist(FIXTURES / "hierarchical.net") result = map_inter_module(netlist) conn_names = list(result["connectors"].keys()) positions = {name: i for i, name in enumerate(conn_names)} prev_dist = 0 for conn_set in result["connections"]: left = next(iter(conn_set[0])) right = next(iter(conn_set[2])) dist = abs(positions.get(right, 0) - positions.get(left, 0)) assert dist >= prev_dist or dist == prev_dist, ( f"Connection {left}->{right} (dist={dist}) comes after dist={prev_dist}" ) prev_dist = dist def test_layer_ordering_all_disconnected(self): """All connectors disconnected — all assigned to the same layer.""" adj: dict[str, dict[str, int]] = {} order = _compute_layer_ordering(["X3", "X1", "X2"], adj) assert set(order) == {"X1", "X2", "X3"} def test_layer_ordering_cycle(self): """Cycle in connectivity — BFS should not infinite loop.""" adj = { "J1": {"X1": 1}, "X1": {"J1": 1, "X2": 1}, "X2": {"X1": 1, "X3": 1}, "X3": {"X2": 1, "J1": 1}, } order = _compute_layer_ordering(["J1", "X1", "X2", "X3"], adj) assert len(order) == 4 # J1 is external, should be at layer 0 (first) assert order[0] == "J1" def test_pin_reorder_weighted_star(self): """In star topology, heavier connections should pull pin position more.""" # Pin 1 connects to A(pos 0, 3-wire cable) and D(pos 6, 1-wire cable) # Weighted avg = (0*3 + 6*1) / (3+1) = 1.5 # Pin 2 connects to B(pos 3, 1-wire cable) # Avg = 3.0 # So pin 1 (1.5) should come before pin 2 (3.0) connections = [ [{"C": [1, 1, 1]}, {"W1": [1, 2, 3]}, {"A": [1, 2, 3]}], # 3-wire to A [{"C": 1}, {"W2": 1}, {"D": 1}], # 1-wire to D [{"C": 2}, {"W3": 1}, {"B": 1}], # 1-wire to B ] positions = {"A": 0, "B": 3, "C": 2, "D": 6} new_labels, _mapping = _optimize_pin_order( "C", ["P1", "P2"], connections, positions ) # Pin 1 weighted avg ≈ 1.5, pin 2 avg = 3.0 → pin 1 first (no reorder needed) assert new_labels[0] == "P1"