# -*- coding: utf-8 -*- """Tests for Connector.resolve_pin() and loop/short pin resolution. Port of the v0.4.1 test suite (issue #432) adapted for v0.5-dev architecture: - designator= instead of name= - loops/shorts are dicts (auto-keyed from lists) - pin_objects dict with PinClass instances - _num_connections instead of visible_pins - activate_pin() has is_connection parameter """ import pytest from wireviz.wv_dataclasses import Cable, Connector def make_connector(pins=None, pinlabels=None, loops=None, shorts=None, **kwargs): """Helper to build a Connector with minimal required fields.""" args = {"designator": "X1"} if pins is not None: args["pins"] = pins if pinlabels is not None: args["pinlabels"] = pinlabels if loops is not None: args["loops"] = loops if shorts is not None: args["shorts"] = shorts args.update(kwargs) return Connector(**args) # --- resolve_pin() happy paths --- class TestResolvePinHappyPaths: def test_pin_number_passthrough(self): c = make_connector(pins=[1, 2, 3]) assert c.resolve_pin(2) == 2 def test_label_resolves_to_pin_number(self): c = make_connector(pins=[1, 2, 3], pinlabels=["VCC", "GND", "SIG"]) assert c.resolve_pin("GND") == 2 def test_label_with_non_sequential_pins(self): c = make_connector(pins=[10, 20, 30], pinlabels=["A", "B", "C"]) assert c.resolve_pin("B") == 20 def test_value_in_both_lists_same_position(self): c = make_connector(pins=["A", "B", "C"], pinlabels=["A", "X", "Y"]) assert c.resolve_pin("A") == "A" def test_empty_pinlabels_falls_through(self): c = make_connector(pins=[1, 2, 3], pinlabels=[]) assert c.resolve_pin(3) == 3 # --- resolve_pin() error paths --- class TestResolvePinErrors: def test_unknown_pin_raises(self): c = make_connector(pins=[1, 2, 3], pinlabels=["A", "B", "C"]) with pytest.raises(Exception, match="Unknown pin"): c.resolve_pin("NONEXISTENT") def test_unknown_number_raises(self): c = make_connector(pins=[1, 2, 3]) with pytest.raises(Exception, match="Unknown pin"): c.resolve_pin(99) def test_ambiguous_pin_different_positions(self): c = make_connector(pins=["A", "B", "C"], pinlabels=["X", "A", "Y"]) with pytest.raises(Exception, match="exists in both"): c.resolve_pin("A") def test_duplicate_label_only_in_labels(self): c = make_connector(pins=[1, 2, 3], pinlabels=["A", "B", "A"]) with pytest.raises(Exception, match="defined more than once"): c.resolve_pin("A") def test_duplicate_label_in_both_lists(self): c = make_connector(pins=["A", "B", "C"], pinlabels=["A", "X", "A"]) with pytest.raises(Exception, match="defined more than once"): c.resolve_pin("A") # --- Loop resolution (dict format in v0.5) --- class TestLoopResolution: def test_loop_with_labels(self): c = make_connector( pins=[1, 2, 3, 4], pinlabels=["VCC", "GND", "TX", "RX"], loops=[["VCC", "GND"]], ) # v0.5 auto-converts list to dict with AutoLO keys assert list(c.loops.values())[0] == [1, 2] def test_loop_with_mixed_number_and_label(self): c = make_connector( pins=[1, 2, 3, 4], pinlabels=["VCC", "GND", "TX", "RX"], loops=[[1, "GND"]], ) assert list(c.loops.values())[0] == [1, 2] def test_loop_with_non_sequential_pins(self): c = make_connector( pins=[10, 20, 30, 40], pinlabels=["VCC", "GND", "TX", "RX"], loops=[["VCC", "GND"]], ) assert list(c.loops.values())[0] == [10, 20] def test_loop_self_reference_raises(self): with pytest.raises(Exception, match="duplicate pin"): make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], loops=[["A", "A"]], ) def test_loop_self_reference_via_label_and_number(self): with pytest.raises(Exception, match="duplicate pin"): make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], loops=[[2, "B"]], ) def test_loop_activates_pins(self): c = make_connector( pins=[1, 2, 3, 4], pinlabels=["VCC", "GND", "TX", "RX"], loops=[["TX", "RX"]], ) # In v0.5, activation is tracked via _num_connections on pin_objects. # Loops use is_connection=False, so _num_connections stays 0, # but the pin should still be accessible (not hidden). # The key check: pins are found in pin_objects (always true), # and the loop was processed without error. assert 3 in c.pin_objects assert 4 in c.pin_objects def test_multiple_loops(self): c = make_connector( pins=[1, 2, 3, 4, 5, 6], pinlabels=["A", "B", "C", "D", "E", "F"], loops=[["A", "B"], ["E", "F"]], ) values = list(c.loops.values()) assert values[0] == [1, 2] assert values[1] == [5, 6] def test_loop_dict_format(self): """Loops can be specified as dicts directly (named loops).""" c = make_connector( pins=[1, 2, 3, 4], pinlabels=["VCC", "GND", "TX", "RX"], loops={"MyLoop": ["VCC", "GND"]}, ) assert c.loops["MyLoop"] == [1, 2] def test_multi_pin_loop(self): """v0.5 allows >2 pins per loop.""" c = make_connector( pins=[1, 2, 3, 4], pinlabels=["A", "B", "C", "D"], loops=[["A", "B", "C"]], ) assert list(c.loops.values())[0] == [1, 2, 3] # --- Short resolution (new in v0.5) --- class TestShortResolution: def test_short_with_labels(self): c = make_connector( pins=[1, 2, 3, 4], pinlabels=["VCC", "GND", "TX", "RX"], shorts=[["VCC", "GND"]], ) assert list(c.shorts.values())[0] == [1, 2] def test_short_with_non_sequential_pins(self): c = make_connector( pins=[10, 20, 30, 40], pinlabels=["A", "B", "C", "D"], shorts=[["A", "C"]], ) assert list(c.shorts.values())[0] == [10, 30] def test_short_self_reference_raises(self): with pytest.raises(Exception, match="duplicate pin"): make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], shorts=[["A", "A"]], ) def test_short_dict_format(self): c = make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], shorts={"MyShort": ["A", "C"]}, ) assert c.shorts["MyShort"] == [1, 3] def test_multiple_shorts(self): c = make_connector( pins=[1, 2, 3, 4], pinlabels=["A", "B", "C", "D"], shorts=[["A", "B"], ["C", "D"]], ) values = list(c.shorts.values()) assert values[0] == [1, 2] assert values[1] == [3, 4] # --- Pin type coercion --- class TestPinTypeCoercion: def test_str_numeric_pins_normalize_to_int(self): c = make_connector(pins=["1", "2", "3"]) assert c.pins == [1, 2, 3] assert all(isinstance(p, int) for p in c.pins) def test_mixed_type_pins_normalize(self): c = make_connector(pins=[1, "2", 3]) assert c.pins == [1, 2, 3] def test_leading_zeros_normalize(self): c = make_connector(pins=["01", "02", "03"]) assert c.pins == [1, 2, 3] def test_non_numeric_pins_stay_str(self): c = make_connector(pins=["A", "B", "C"]) assert c.pins == ["A", "B", "C"] assert all(isinstance(p, str) for p in c.pins) def test_duplicate_after_normalization_raises(self): with pytest.raises(Exception, match="Pins are not unique"): make_connector(pins=[1, "1"]) def test_pinlabels_normalize(self): c = make_connector(pins=[1, 2], pinlabels=["10", "20"]) assert c.pinlabels == [10, 20] def test_loop_pins_normalize(self): c = make_connector( pins=[1, 2, 3, 4], loops=[["1", "2"]], ) assert list(c.loops.values())[0] == [1, 2] def test_str_loop_pins_match_auto_generated_int_pins(self): """String loop pins match auto-generated sequential int pins.""" c = make_connector( pincount=4, loops=[["1", "3"]], ) assert list(c.loops.values())[0] == [1, 3] def test_short_pins_normalize(self): c = make_connector( pins=[1, 2, 3, 4], shorts=[["1", "2"]], ) assert list(c.shorts.values())[0] == [1, 2] def test_wirelabels_normalize(self): cable = Cable( designator="W1", wirecount=3, colors=["BK", "RD", "GN"], wirelabels=["1", "2", "3"], ) assert cable.wirelabels == [1, 2, 3] # --- Loop/Short rendering: non-sequential pins --- class TestRendering: """Verify GraphViz output uses port indices, not pin numbers.""" def _make_harness(self): from wireviz.wv_dataclasses import Metadata, Options, Tweak from wireviz.wv_harness import Harness return Harness( metadata=Metadata({}), options=Options(), tweak=Tweak(), ) def test_loop_non_sequential_pins_use_indices(self): import re harness = self._make_harness() harness.add_connector( "X1", pins=[10, 20, 30, 40], pinlabels=["VCC", "GND", "TX", "RX"], loops=[["VCC", "GND"]], ) harness.add_cable("W1", wirecount=2, colors=["BK", "RD"]) harness.connect("X1", "TX", "W1", 1, None, None) harness.connect(None, None, "W1", 2, "X1", "RX") graph = harness.create_graph() gv = graph.source # Loop should reference p1 and p2 (indices), not p10 and p20 loop_edges = re.findall(r"X1:p(\d+)\w:\w -- X1:p(\d+)\w:\w", gv) assert len(loop_edges) >= 1, f"Expected loop edge, found none in:\n{gv}" idx_a, idx_b = loop_edges[0] assert idx_a == "1" and idx_b == "2", ( f"Loop ports should be p1/p2 (indices), got p{idx_a}/p{idx_b}" ) def test_short_non_sequential_pins_use_indices(self): import re harness = self._make_harness() harness.add_connector( "X1", pins=[10, 20, 30, 40], pinlabels=["VCC", "GND", "TX", "RX"], shorts=[["VCC", "GND"]], ) harness.add_cable("W1", wirecount=2, colors=["BK", "RD"]) harness.connect("X1", "TX", "W1", 1, None, None) harness.connect(None, None, "W1", 2, "X1", "RX") graph = harness.create_graph() gv = graph.source # Short should reference p1j and p2j (indices), not p10j and p20j short_edges = re.findall(r"X1:p(\d+)j:c -- X1:p(\d+)j:c", gv) assert len(short_edges) >= 1, f"Expected short edge, found none in:\n{gv}" idx_a, idx_b = short_edges[0] assert idx_a == "1" and idx_b == "2", ( f"Short ports should be p1j/p2j (indices), got p{idx_a}j/p{idx_b}j" ) def test_sequential_pins_still_work(self): harness = self._make_harness() harness.add_connector( "X1", pinlabels=["VCC", "GND", "TX", "RX"], loops=[["VCC", "GND"]], ) harness.add_cable("W1", wirecount=2, colors=["BK", "RD"]) harness.connect("X1", "TX", "W1", 1, None, None) harness.connect(None, None, "W1", 2, "X1", "RX") graph = harness.create_graph() gv = graph.source assert ":p1" in gv assert ":p2" in gv # --- Harness.connect() delegation --- class TestHarnessConnectDelegation: def _make_harness(self): from wireviz.wv_dataclasses import Metadata, Options, Tweak from wireviz.wv_harness import Harness return Harness( metadata=Metadata({}), options=Options(), tweak=Tweak(), ) def test_connect_resolves_labels(self): harness = self._make_harness() harness.add_connector( "X1", pins=[1, 2, 3], pinlabels=["VCC", "GND", "SIG"] ) harness.add_connector("X2", pins=[1, 2, 3]) harness.add_cable("W1", wirecount=1, colors=["BK"]) harness.connect("X1", "SIG", "W1", 1, "X2", 1) # The connection should store a PinClass with the resolved pin id (3) conn = harness.cables["W1"]._connections[0] assert conn.from_.id == 3 def test_connect_rejects_unknown_pin(self): harness = self._make_harness() harness.add_connector("X1", pins=[1, 2, 3]) harness.add_connector("X2", pins=[1, 2, 3]) harness.add_cable("W1", wirecount=1, colors=["BK"]) with pytest.raises(Exception, match="Unknown pin"): harness.connect("X1", 99, "W1", 1, "X2", 1) def test_connect_normalizes_string_pin(self): """Programmatic callers passing str '3' should match normalized int 3.""" harness = self._make_harness() harness.add_connector("X1", pins=[1, 2, 3]) harness.add_connector("X2", pins=[1, 2, 3]) harness.add_cable("W1", wirecount=1, colors=["BK"]) harness.connect("X1", "3", "W1", 1, "X2", 1) conn = harness.cables["W1"]._connections[0] assert conn.from_.id == 3 # --- Apollo review: validation edge cases --- class TestApolloValidation: """Tests added per Apollo code review findings.""" def test_partial_duplicate_loop_raises(self): """C1: Loop [A, B, A] -> [1, 2, 1] has duplicate pin 1.""" with pytest.raises(Exception, match="duplicate pin"): make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], loops=[["A", "B", "A"]], ) def test_partial_duplicate_short_raises(self): """C1: Short with partial duplicates detected.""" with pytest.raises(Exception, match="duplicate pin"): make_connector( pins=[1, 2, 3], pinlabels=["A", "B", "C"], shorts=[["A", "B", "A"]], ) def test_empty_loop_raises(self): """C2: Empty loop has no physical meaning.""" with pytest.raises(Exception, match="at least 2 pins"): make_connector(pins=[1, 2, 3], loops=[[]]) def test_single_pin_loop_raises(self): """C2: Single-pin loop has no physical meaning.""" with pytest.raises(Exception, match="at least 2 pins"): make_connector(pins=[1, 2, 3], loops=[[1]]) def test_empty_short_raises(self): """C2: Empty short has no physical meaning.""" with pytest.raises(Exception, match="at least 2 pins"): make_connector(pins=[1, 2, 3], shorts=[[]]) def test_single_pin_short_raises(self): """C2: Single-pin short has no physical meaning.""" with pytest.raises(Exception, match="at least 2 pins"): make_connector(pins=[1, 2, 3], shorts=[[1]]) def test_float_pin_raises(self): """C3: Non-integer float pin should be rejected, not truncated.""" with pytest.raises(ValueError, match="Float"): make_connector(pins=[3.5, 2, 3]) def test_bool_pin_raises(self): """C3: Boolean pin should be rejected, not coerced to int.""" with pytest.raises(ValueError, match="Boolean"): make_connector(pins=[True, 2, 3]) def test_integer_float_pin_accepted(self): """C3: Integer-valued float (e.g. 3.0) should normalize to int 3.""" c = make_connector(pins=[1.0, 2.0, 3.0]) assert c.pins == [1, 2, 3] assert all(isinstance(p, int) for p in c.pins) def test_resolve_pin_normalizes_input(self): """I1: resolve_pin() normalizes at its own boundary.""" c = make_connector(pins=[1, 2, 3]) assert c.resolve_pin("2") == 2