Harden pin resolution for safety-critical correctness
Some checks are pending
Create Examples / build (ubuntu-22.04, 3.7) (push) Waiting to run
Create Examples / build (ubuntu-22.04, 3.8) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.10) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.11) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.12) (push) Waiting to run
Create Examples / build (ubuntu-latest, 3.9) (push) Waiting to run

Code review fixes for the #432 loop-pin-labels feature:

- Fix loop rendering to use port indices instead of pin numbers
  (pre-existing bug: non-sequential pins produced wrong diagram)
- Add duplicate label check to the ambiguity branch in resolve_pin()
- Prevent self-referencing loops (pin looped to itself)
- Fix activate_pin() type annotation to accept Optional[Side]
- Deduplicate pin resolution: Harness.connect() now delegates to
  Connector.resolve_pin() instead of reimplementing the logic
- Add 21-test suite covering all resolution paths and error modes
This commit is contained in:
Ryan Malloy 2026-02-13 00:58:28 -07:00
parent f5c1b1c87f
commit 48377f3a8d
3 changed files with 327 additions and 25 deletions

View File

@ -213,6 +213,11 @@ class Connector:
resolved.append(pin)
# Make sure loop connected pins are not hidden.
self.activate_pin(pin, None)
if resolved[0] == resolved[1]:
raise Exception(
f'Loop in connector "{self.name}" connects pin '
f'"{resolved[0]}" to itself.'
)
self.loops[i] = resolved
for i, item in enumerate(self.additional_components):
@ -229,7 +234,13 @@ class Connector:
in_labels = pin in self.pinlabels if self.pinlabels else False
if in_pins and in_labels:
# present in both lists — check for ambiguity
# present in both lists — check for duplicate labels first
if self.pinlabels.count(pin) > 1:
raise Exception(
f'Pin label "{pin}" in connector "{self.name}" '
f"is defined more than once in pinlabels."
)
# then check for positional ambiguity
if self.pins.index(pin) != self.pinlabels.index(pin):
raise Exception(
f'"{pin}" in connector "{self.name}" exists in both '
@ -252,7 +263,7 @@ class Connector:
f'Unknown pin "{pin}" for connector "{self.name}"!'
)
def activate_pin(self, pin: Pin, side: Side) -> None:
def activate_pin(self, pin: Pin, side: Optional[Side]) -> None:
self.visible_pins[pin] = True
if side == Side.LEFT:
self.ports_left = True

View File

@ -105,28 +105,17 @@ class Harness:
to_name: str,
to_pin: (int, str),
) -> None:
# check from and to connectors
for name, pin in zip([from_name, to_name], [from_pin, to_pin]):
# resolve pin labels to pin numbers via Connector.resolve_pin()
for name, pin, is_from in [
(from_name, from_pin, True),
(to_name, to_pin, False),
]:
if name is not None and name in self.connectors:
connector = self.connectors[name]
# check if provided name is ambiguous
if pin in connector.pins and pin in connector.pinlabels:
if connector.pins.index(pin) != connector.pinlabels.index(pin):
raise Exception(
f"{name}:{pin} is defined both in pinlabels and pins, for different pins."
)
# TODO: Maybe issue a warning if present in both lists but referencing the same pin?
if pin in connector.pinlabels:
if connector.pinlabels.count(pin) > 1:
raise Exception(f"{name}:{pin} is defined more than once.")
index = connector.pinlabels.index(pin)
pin = connector.pins[index] # map pin name to pin number
if name == from_name:
from_pin = pin
if name == to_name:
to_pin = pin
if not pin in connector.pins:
raise Exception(f"{name}:{pin} not found.")
resolved = self.connectors[name].resolve_pin(pin)
if is_from:
from_pin = resolved
else:
to_pin = resolved
# check via cable
if via_name in self.cables:
@ -280,9 +269,14 @@ class Harness:
else:
raise Exception("No side for loops")
for loop in connector.loops:
# Convert pin numbers to 1-based port indices.
# Ports are named p{index+1} in the connector table,
# not p{pin_number} — these differ for non-sequential pins.
idx0 = connector.pins.index(loop[0]) + 1
idx1 = connector.pins.index(loop[1]) + 1
dot.edge(
f"{connector.name}:p{loop[0]}{loop_side}:{loop_dir}",
f"{connector.name}:p{loop[1]}{loop_side}:{loop_dir}",
f"{connector.name}:p{idx0}{loop_side}:{loop_dir}",
f"{connector.name}:p{idx1}{loop_side}:{loop_dir}",
label=" ", # Work-around to avoid over-sized loops.
)

297
tests/test_resolve_pin.py Normal file
View File

@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""Tests for Connector.resolve_pin() and loop pin resolution.
Covers the fix for https://github.com/wireviz/WireViz/issues/432
and related safety-critical pin resolution correctness.
"""
import pytest
from wireviz.DataClasses import Connector
def make_connector(pins=None, pinlabels=None, loops=None, **kwargs):
"""Helper to build a Connector with minimal required fields."""
args = {"name": "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
args.update(kwargs)
return Connector(**args)
# --- resolve_pin() happy paths ---
class TestResolvePinHappyPaths:
def test_pin_number_passthrough(self):
"""Pin number that exists in self.pins returns unchanged."""
c = make_connector(pins=[1, 2, 3])
assert c.resolve_pin(2) == 2
def test_label_resolves_to_pin_number(self):
"""Pin label resolves to the corresponding pin number."""
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):
"""Labels work correctly even when pin numbers are non-sequential."""
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):
"""Value that exists in both pins and pinlabels at the same index."""
c = make_connector(pins=["A", "B", "C"], pinlabels=["A", "X", "Y"])
assert c.resolve_pin("A") == "A"
def test_empty_pinlabels_falls_through(self):
"""When pinlabels is empty, pin numbers still resolve."""
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):
"""Resolving a pin that doesn't exist in either list raises."""
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):
"""Resolving a pin number not in self.pins raises."""
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):
"""Value in both pins and pinlabels at different positions raises."""
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):
"""Duplicate label in pinlabels (label-only path) raises."""
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):
"""Duplicate label detected even when value also exists in pins (C-2 fix)."""
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 ---
class TestLoopResolution:
def test_loop_with_labels(self):
"""Loops specified with pin labels resolve to pin numbers."""
c = make_connector(
pins=[1, 2, 3, 4],
pinlabels=["VCC", "GND", "TX", "RX"],
loops=[["VCC", "GND"]],
)
assert c.loops == [[1, 2]]
def test_loop_with_mixed_number_and_label(self):
"""Loop with one pin number and one label resolves correctly."""
c = make_connector(
pins=[1, 2, 3, 4],
pinlabels=["VCC", "GND", "TX", "RX"],
loops=[[1, "GND"]],
)
assert c.loops == [[1, 2]]
def test_loop_with_non_sequential_pins(self):
"""Loops resolve correctly with non-sequential pin numbering."""
c = make_connector(
pins=[10, 20, 30, 40],
pinlabels=["VCC", "GND", "TX", "RX"],
loops=[["VCC", "GND"]],
)
assert c.loops == [[10, 20]]
def test_loop_self_reference_raises(self):
"""A loop from a pin to itself raises (I-2 fix)."""
with pytest.raises(Exception, match="to itself"):
make_connector(
pins=[1, 2, 3],
pinlabels=["A", "B", "C"],
loops=[["A", "A"]],
)
def test_loop_self_reference_via_label_and_number(self):
"""Self-loop detected even when specified as label + number."""
with pytest.raises(Exception, match="to itself"):
make_connector(
pins=[1, 2, 3],
pinlabels=["A", "B", "C"],
loops=[[2, "B"]],
)
def test_loop_activates_pins(self):
"""Loop-connected pins are marked visible (for hide_disconnected_pins)."""
c = make_connector(
pins=[1, 2, 3, 4],
pinlabels=["VCC", "GND", "TX", "RX"],
loops=[["TX", "RX"]],
)
assert c.visible_pins.get(3) is True
assert c.visible_pins.get(4) is True
# Unconnected pins should not be in visible_pins
assert 1 not in c.visible_pins
def test_multiple_loops(self):
"""Multiple loops on the same connector all resolve correctly."""
c = make_connector(
pins=[1, 2, 3, 4, 5, 6],
pinlabels=["A", "B", "C", "D", "E", "F"],
loops=[["A", "B"], ["E", "F"]],
)
assert c.loops == [[1, 2], [5, 6]]
# --- Loop rendering: non-sequential pins (C-1 fix) ---
class TestLoopRendering:
"""Verify that the GraphViz output uses port indices, not pin numbers.
This is the critical C-1 fix: Harness.py must convert resolved pin
numbers to 1-based position indices for GraphViz port references.
"""
def _render_gv(self, yaml_str):
"""Parse a YAML string through WireViz and return the GV source."""
import yaml
from wireviz.DataClasses import Metadata, Options, Tweak
from wireviz.Harness import Harness
data = yaml.safe_load(yaml_str)
harness = Harness(
metadata=Metadata(data.get("metadata", {})),
options=Options(**data.get("options", {})),
tweak=Tweak(**data.get("tweak", {})),
)
for name, conn_data in data.get("connectors", {}).items():
harness.add_connector(name, **conn_data)
for name, cable_data in data.get("cables", {}).items():
harness.add_cable(name, **cable_data)
graph = harness.create_graph()
return graph.source
def test_non_sequential_pins_use_indices(self):
"""C-1 regression test: loops must use port indices, not pin numbers.
With pins: [10, 20, 30, 40], a loop between pins 10 and 20
must produce port references p1 and p2 (1-based indices),
NOT p10 and p20 (pin numbers).
"""
yaml_str = """
connectors:
X1:
pins: [10, 20, 30, 40]
pinlabels: [VCC, GND, TX, RX]
loops:
- [VCC, GND]
cables:
W1:
wirecount: 2
colors: [BK, RD]
connections:
-
- X1: [TX, RX]
- W1: [1-2]
"""
gv = self._render_gv(yaml_str)
# The loop should reference p1 and p2 (positions), not p10 and p20.
# Look specifically for the loop edge pattern with port names.
import re
loop_edges = re.findall(r"X1:p(\d+)\w:\w -- X1:p(\d+)\w:\w", gv)
assert len(loop_edges) == 1, f"Expected 1 loop edge, found {len(loop_edges)}"
idx_a, idx_b = loop_edges[0]
# Pin 10 is at index 1, pin 20 is at index 2
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_sequential_pins_still_work(self):
"""Sequential pins (the common case) continue to work correctly."""
yaml_str = """
connectors:
X1:
pinlabels: [VCC, GND, TX, RX]
loops:
- [VCC, GND]
cables:
W1:
wirecount: 2
colors: [BK, RD]
connections:
-
- X1: [TX, RX]
- W1: [1-2]
"""
gv = self._render_gv(yaml_str)
# With sequential pins [1,2,3,4], p1/p2 are both the index and number
assert ":p1" in gv
assert ":p2" in gv
# --- Harness.connect() delegation (I-1 fix) ---
class TestHarnessConnectDelegation:
"""Verify Harness.connect() now delegates to Connector.resolve_pin()."""
def test_connect_resolves_labels(self):
"""Wire connections using pin labels resolve correctly."""
from wireviz.DataClasses import Cable, Metadata, Options, Tweak
from wireviz.Harness import Harness
harness = Harness(
metadata=Metadata({}),
options=Options(),
tweak=Tweak(),
)
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"])
# Connect using a label on the from side
harness.connect("X1", "SIG", "W1", 1, "X2", 1)
# The connection should store the resolved pin number (3), not the label
conn = harness.cables["W1"].connections[0]
assert conn.from_pin == 3
def test_connect_rejects_unknown_pin(self):
"""Wire connections with unknown pins raise via resolve_pin()."""
from wireviz.DataClasses import Metadata, Options, Tweak
from wireviz.Harness import Harness
harness = Harness(
metadata=Metadata({}),
options=Options(),
tweak=Tweak(),
)
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)