WireViz/tests/test_resolve_pin.py
Ryan Malloy 17ef5806f1
Some checks failed
Create Examples / build (ubuntu-22.04, 3.7) (push) Has been cancelled
Create Examples / build (ubuntu-22.04, 3.8) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.10) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.11) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.12) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.9) (push) Has been cancelled
Address Apollo review deferred items (S2, I4, S5)
S2: Replace type() == list with isinstance() for list-to-dict conversion.

I4: Fix duplicate GraphViz port names when a pin appears in multiple
shorts. Port names now encode short column index (p{idx}j{col}) so
each cell in the shorts grid has a unique port. Edge generation in
gv_connector_shorts() updated to match.

S5: Add rendering tests for multi-pin loop edge chains and multi-short
port name uniqueness.
2026-02-13 03:12:14 -07:00

527 lines
18 KiB
Python

# -*- 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 p1j0 and p2j0 (index + column 0),
# not p10j0 and p20j0 (pin IDs)
short_edges = re.findall(r"X1:p(\d+)j\d+:c -- X1:p(\d+)j\d+: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 p1/p2 (indices), got p{idx_a}/p{idx_b}"
)
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
def test_multi_pin_loop_renders_chain(self):
"""S5: >2 pin loop produces correct edge chain (p1->p2, p2->p3)."""
import re
harness = self._make_harness()
harness.add_connector(
"X1",
pins=[10, 20, 30, 40],
pinlabels=["A", "B", "C", "D"],
loops=[["A", "B", "C"]],
)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", "D", "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
loop_edges = re.findall(r"X1:p(\d+)\w:\w -- X1:p(\d+)\w:\w", gv)
# 3-pin loop should produce 2 edges: p1->p2 and p2->p3
assert len(loop_edges) == 2, (
f"Expected 2 loop edges for 3-pin loop, got {len(loop_edges)}"
)
assert loop_edges[0] == ("1", "2")
assert loop_edges[1] == ("2", "3")
def test_pin_in_multiple_shorts_unique_ports(self):
"""I4: pin in two shorts gets unique port names per column."""
import re
harness = self._make_harness()
harness.add_connector(
"X1",
pins=[1, 2, 3, 4],
shorts={"SH1": [1, 2], "SH2": [1, 3]},
)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 4, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# SH1 edges should use column 0 (j0), SH2 should use column 1 (j1)
sh1_edges = re.findall(r"X1:p(\d+)j0:c -- X1:p(\d+)j0:c", gv)
sh2_edges = re.findall(r"X1:p(\d+)j1:c -- X1:p(\d+)j1:c", gv)
assert len(sh1_edges) >= 1, f"Expected SH1 edge (j0), found none in:\n{gv}"
assert len(sh2_edges) >= 1, f"Expected SH2 edge (j1), found none in:\n{gv}"
# SH1: pin 1 (idx 1) -> pin 2 (idx 2)
assert sh1_edges[0] == ("1", "2")
# SH2: pin 1 (idx 1) -> pin 3 (idx 3)
assert sh2_edges[0] == ("1", "3")
# Verify no duplicate port names in the HTML table
port_names = re.findall(r'PORT="(p\d+j\d+)"', gv)
assert len(port_names) == len(set(port_names)), (
f"Duplicate port names found: {port_names}"
)
# --- 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