Merge pin safety fixes into testing/web-ui-features
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
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
Port of v0.4.1 pin safety work (#432) to v0.5-dev architecture: - normalize_pin() for int/str coercion at YAML boundary - resolve_pin() for label-to-number resolution - GraphViz port index fix for loops/shorts on non-sequential pins - Apollo review hardening (duplicate detection, min pin count, float/bool rejection) - Unique port names for multi-short rendering - 52 new tests (71 total)
This commit is contained in:
commit
4da9fe4886
@ -25,6 +25,7 @@ from wireviz.wv_utils import (
|
|||||||
aspect_ratio,
|
aspect_ratio,
|
||||||
awg_equiv,
|
awg_equiv,
|
||||||
mm2_equiv,
|
mm2_equiv,
|
||||||
|
normalize_pin,
|
||||||
parse_number_and_unit,
|
parse_number_and_unit,
|
||||||
remove_links,
|
remove_links,
|
||||||
)
|
)
|
||||||
@ -409,6 +410,13 @@ class Connector(TopLevelGraphicalComponent):
|
|||||||
elif self.strip is None:
|
elif self.strip is None:
|
||||||
self.strip = Strip()
|
self.strip = Strip()
|
||||||
|
|
||||||
|
# Normalize pin-like fields so int/str types are consistent
|
||||||
|
# regardless of YAML quoting (e.g. "1" vs 1).
|
||||||
|
if self.pins:
|
||||||
|
self.pins = [normalize_pin(p) for p in self.pins]
|
||||||
|
if self.pinlabels:
|
||||||
|
self.pinlabels = [normalize_pin(p) for p in self.pinlabels]
|
||||||
|
|
||||||
self.ports_left = False
|
self.ports_left = False
|
||||||
self.ports_right = False
|
self.ports_right = False
|
||||||
self.visible_pins = {}
|
self.visible_pins = {}
|
||||||
@ -462,7 +470,7 @@ class Connector(TopLevelGraphicalComponent):
|
|||||||
self.show_pincount = self.style != "simple"
|
self.show_pincount = self.style != "simple"
|
||||||
|
|
||||||
# Convert short List to Short Dict
|
# Convert short List to Short Dict
|
||||||
if type(self.shorts) == list:
|
if isinstance(self.shorts, list):
|
||||||
self.shorts_hide_lable = True
|
self.shorts_hide_lable = True
|
||||||
shDict = dict()
|
shDict = dict()
|
||||||
for shIndex in range(0, len(self.shorts)):
|
for shIndex in range(0, len(self.shorts)):
|
||||||
@ -471,48 +479,50 @@ class Connector(TopLevelGraphicalComponent):
|
|||||||
self.shorts = shDict
|
self.shorts = shDict
|
||||||
|
|
||||||
# Convert loop List to loop Dict
|
# Convert loop List to loop Dict
|
||||||
if type(self.loops) == list:
|
if isinstance(self.loops, list):
|
||||||
loDict = dict()
|
loDict = dict()
|
||||||
for loIndex in range(0, len(self.loops)):
|
for loIndex in range(0, len(self.loops)):
|
||||||
key = "AutoLO" + str(loIndex)
|
key = "AutoLO" + str(loIndex)
|
||||||
loDict[key] = self.loops[loIndex]
|
loDict[key] = self.loops[loIndex]
|
||||||
self.loops = loDict
|
self.loops = loDict
|
||||||
|
|
||||||
# TODO: allow using pin labels in addition to pin numbers,
|
# Resolve loop pins: normalize, resolve labels, validate
|
||||||
# just like when defining regular connections
|
for loopName, loPins in self.loops.items():
|
||||||
# TODO: include properties of wire used to create the loop
|
loPins = [normalize_pin(p) for p in loPins]
|
||||||
for loopName in self.loops:
|
resolved = [self.resolve_pin(p) for p in loPins]
|
||||||
for pin in self.loops[loopName]:
|
if len(resolved) < 2:
|
||||||
if pin not in self.pins:
|
raise Exception(
|
||||||
raise Exception(
|
f'Loop "{loopName}" in connector "{self.designator}" '
|
||||||
f'Unknown loop pin "{pin}" for connector "{self.designator}"!'
|
f'must connect at least 2 pins (got {len(resolved)}).'
|
||||||
)
|
)
|
||||||
# Make sure loop connected pins are not hidden.
|
if len(resolved) != len(set(resolved)):
|
||||||
self.activate_pin(pin, None)
|
duplicates = [p for p in set(resolved) if resolved.count(p) > 1]
|
||||||
for short in self.shorts:
|
raise Exception(
|
||||||
for pin in self.shorts[short]:
|
f'Loop "{loopName}" in connector "{self.designator}" '
|
||||||
if pin not in self.pins:
|
f'contains duplicate pin(s): {duplicates}.'
|
||||||
raise Exception(
|
)
|
||||||
f'Unknown loop pin "{pin}" for connector "{self.designator}"!'
|
for pin in resolved:
|
||||||
)
|
self.activate_pin(pin, None, is_connection=False)
|
||||||
# Make sure loop connected pins are not hidden.
|
self.loops[loopName] = resolved
|
||||||
self.activate_pin(pin, None)
|
|
||||||
|
|
||||||
# TODO: Remove the outcommented code here if it is no longer needed as reference
|
# Resolve short pins: normalize, resolve labels, validate
|
||||||
# for loop in self.loops:
|
for shortName, shPins in self.shorts.items():
|
||||||
# # TODO: allow using pin labels in addition to pin numbers,
|
shPins = [normalize_pin(p) for p in shPins]
|
||||||
# # just like when defining regular connections
|
resolved = [self.resolve_pin(p) for p in shPins]
|
||||||
# # TODO: include properties of wire used to create the loop
|
if len(resolved) < 2:
|
||||||
# if len(loop) != 2:
|
raise Exception(
|
||||||
# raise Exception("Loops must be between exactly two pins!")
|
f'Short "{shortName}" in connector "{self.designator}" '
|
||||||
# for pin in loop:
|
f'must connect at least 2 pins (got {len(resolved)}).'
|
||||||
# if pin not in self.pins:
|
)
|
||||||
# raise Exception(
|
if len(resolved) != len(set(resolved)):
|
||||||
# f'Unknown loop pin "{pin}" for connector "{self.name}"!'
|
duplicates = [p for p in set(resolved) if resolved.count(p) > 1]
|
||||||
# )
|
raise Exception(
|
||||||
# # Make sure loop connected pins are not hidden.
|
f'Short "{shortName}" in connector "{self.designator}" '
|
||||||
# # side=None, determine side to show loops during rendering
|
f'contains duplicate pin(s): {duplicates}.'
|
||||||
# self.activate_pin(pin, side=None, is_connection=True)
|
)
|
||||||
|
for pin in resolved:
|
||||||
|
self.activate_pin(pin, None, is_connection=False)
|
||||||
|
self.shorts[shortName] = resolved
|
||||||
|
|
||||||
for i, item in enumerate(self.additional_components):
|
for i, item in enumerate(self.additional_components):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
@ -526,6 +536,52 @@ class Connector(TopLevelGraphicalComponent):
|
|||||||
elif side == Side.RIGHT:
|
elif side == Side.RIGHT:
|
||||||
self.ports_right = True
|
self.ports_right = True
|
||||||
|
|
||||||
|
def resolve_pin(self, pin: Pin) -> Pin:
|
||||||
|
"""Resolve a pin identifier to its canonical pin number.
|
||||||
|
|
||||||
|
Given a value that may be either a pin number (from self.pins)
|
||||||
|
or a pin label (from self.pinlabels), returns the corresponding
|
||||||
|
pin number from self.pins.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Value in both pins and pinlabels at the same position -> return directly.
|
||||||
|
2. Value in both at different positions -> raise.
|
||||||
|
3. Value only in pinlabels -> return corresponding pin number.
|
||||||
|
4. Value only in pins -> return directly.
|
||||||
|
5. Not found -> raise.
|
||||||
|
"""
|
||||||
|
pin = normalize_pin(pin)
|
||||||
|
in_pins = pin in self.pins
|
||||||
|
in_labels = pin in self.pinlabels if self.pinlabels else False
|
||||||
|
|
||||||
|
if in_pins and in_labels:
|
||||||
|
if self.pinlabels.count(pin) > 1:
|
||||||
|
raise Exception(
|
||||||
|
f'Pin label "{pin}" in connector "{self.designator}" '
|
||||||
|
f"is defined more than once in pinlabels."
|
||||||
|
)
|
||||||
|
if self.pins.index(pin) != self.pinlabels.index(pin):
|
||||||
|
raise Exception(
|
||||||
|
f'"{pin}" in connector "{self.designator}" exists in both '
|
||||||
|
f"pins and pinlabels at different positions."
|
||||||
|
)
|
||||||
|
return pin
|
||||||
|
|
||||||
|
if in_labels:
|
||||||
|
if self.pinlabels.count(pin) > 1:
|
||||||
|
raise Exception(
|
||||||
|
f'Pin label "{pin}" in connector "{self.designator}" '
|
||||||
|
f"is defined more than once."
|
||||||
|
)
|
||||||
|
return self.pins[self.pinlabels.index(pin)]
|
||||||
|
|
||||||
|
if in_pins:
|
||||||
|
return pin
|
||||||
|
|
||||||
|
raise Exception(
|
||||||
|
f'Unknown pin "{pin}" for connector "{self.designator}"!'
|
||||||
|
)
|
||||||
|
|
||||||
def compute_qty_multipliers(self):
|
def compute_qty_multipliers(self):
|
||||||
# do not run before all connections in harness have been made!
|
# do not run before all connections in harness have been made!
|
||||||
num_populated_pins = len(
|
num_populated_pins = len(
|
||||||
@ -777,6 +833,7 @@ class Cable(TopLevelGraphicalComponent):
|
|||||||
self.wirecount = len(self.colors)
|
self.wirecount = len(self.colors)
|
||||||
|
|
||||||
if self.wirelabels:
|
if self.wirelabels:
|
||||||
|
self.wirelabels = [normalize_pin(w) for w in self.wirelabels]
|
||||||
if self.shield and "s" in self.wirelabels:
|
if self.shield and "s" in self.wirelabels:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
'"s" may not be used as a wire label for a shielded cable.'
|
'"s" may not be used as a wire label for a shielded cable.'
|
||||||
|
|||||||
@ -303,10 +303,12 @@ def gv_pin_table(component) -> Table:
|
|||||||
|
|
||||||
|
|
||||||
def gv_short_row_part(pin, connector) -> List:
|
def gv_short_row_part(pin, connector) -> List:
|
||||||
short_row = [] # Td("ADA"), Td("DAD")
|
short_row = []
|
||||||
for short, shPins in connector.shorts.items():
|
for sh_col, (short, shPins) in enumerate(connector.shorts.items()):
|
||||||
if pin.index + 1 in shPins:
|
if pin.id in shPins:
|
||||||
short_row.append(Td("", port=f"p{pin.index+1}j"))
|
# Port name encodes pin index + short column to stay unique
|
||||||
|
# when the same pin appears in multiple shorts.
|
||||||
|
short_row.append(Td("", port=f"p{pin.index+1}j{sh_col}"))
|
||||||
else:
|
else:
|
||||||
short_row.append(Td(""))
|
short_row.append(Td(""))
|
||||||
return short_row if len(short_row) > 0 else None
|
return short_row if len(short_row) > 0 else None
|
||||||
@ -344,8 +346,13 @@ def gv_connector_loops(connector: Connector) -> List:
|
|||||||
loColor = comp.color.html
|
loColor = comp.color.html
|
||||||
|
|
||||||
for i in range(1, len(loPins)):
|
for i in range(1, len(loPins)):
|
||||||
head = f"{connector.designator}:p{loPins[i - 1]}{loop_side}:{loop_dir}"
|
# Convert pin IDs to 1-based port indices.
|
||||||
tail = f"{connector.designator}:p{loPins[i]}{loop_side}:{loop_dir}"
|
# Ports are named p{index+1} in the connector table,
|
||||||
|
# not p{pin_id} — these differ for non-sequential pins.
|
||||||
|
idx_prev = connector.pin_objects[loPins[i - 1]].index + 1
|
||||||
|
idx_curr = connector.pin_objects[loPins[i]].index + 1
|
||||||
|
head = f"{connector.designator}:p{idx_prev}{loop_side}:{loop_dir}"
|
||||||
|
tail = f"{connector.designator}:p{idx_curr}{loop_side}:{loop_dir}"
|
||||||
loop_edges.append((head, tail, loColor))
|
loop_edges.append((head, tail, loColor))
|
||||||
return loop_edges
|
return loop_edges
|
||||||
|
|
||||||
@ -353,15 +360,19 @@ def gv_connector_loops(connector: Connector) -> List:
|
|||||||
def gv_connector_shorts(connector: Connector) -> List:
|
def gv_connector_shorts(connector: Connector) -> List:
|
||||||
short_edges = []
|
short_edges = []
|
||||||
|
|
||||||
for short, shPins in connector.shorts.items():
|
for sh_col, (short, shPins) in enumerate(connector.shorts.items()):
|
||||||
comp = getAddCompFromRef(short, connector)
|
comp = getAddCompFromRef(short, connector)
|
||||||
shColor = "#FFFFFF:#000000:#FFFFFF"
|
shColor = "#FFFFFF:#000000:#FFFFFF"
|
||||||
if comp != None and comp.color != None:
|
if comp != None and comp.color != None:
|
||||||
shColor = f"#FFFFFF:{comp.color.html}:#FFFFFF"
|
shColor = f"#FFFFFF:{comp.color.html}:#FFFFFF"
|
||||||
|
|
||||||
for i in range(1, len(shPins)):
|
for i in range(1, len(shPins)):
|
||||||
head = f"{connector.designator}:p{shPins[i - 1]}j:c"
|
# Convert pin IDs to 1-based port indices (same fix as loops).
|
||||||
tail = f"{connector.designator}:p{shPins[i]}j:c"
|
# Port name includes short column index for uniqueness.
|
||||||
|
idx_prev = connector.pin_objects[shPins[i - 1]].index + 1
|
||||||
|
idx_curr = connector.pin_objects[shPins[i]].index + 1
|
||||||
|
head = f"{connector.designator}:p{idx_prev}j{sh_col}:c"
|
||||||
|
tail = f"{connector.designator}:p{idx_curr}j{sh_col}:c"
|
||||||
short_edges.append((head, tail, shColor))
|
short_edges.append((head, tail, shColor))
|
||||||
return short_edges
|
return short_edges
|
||||||
|
|
||||||
|
|||||||
@ -236,30 +236,17 @@ class Harness:
|
|||||||
to_name: str,
|
to_name: str,
|
||||||
to_pin: Union[int, str],
|
to_pin: Union[int, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
# check from and to connectors
|
# resolve pin labels to pin numbers via Connector.resolve_pin()
|
||||||
for name, pin in zip([from_name, to_name], [from_pin, to_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:
|
if name is not None and name in self.connectors:
|
||||||
connector = self.connectors[name]
|
resolved = self.connectors[name].resolve_pin(pin)
|
||||||
# check if provided name is ambiguous
|
if is_from:
|
||||||
if pin in connector.pins and pin in connector.pinlabels:
|
from_pin = resolved
|
||||||
if connector.pins.index(pin) != connector.pinlabels.index(pin):
|
else:
|
||||||
raise Exception(
|
to_pin = resolved
|
||||||
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.")
|
|
||||||
|
|
||||||
# check via cable
|
# check via cable
|
||||||
if via_name in self.cables:
|
if via_name in self.cables:
|
||||||
|
|||||||
@ -37,6 +37,25 @@ def mm2_equiv(awg):
|
|||||||
return mm2_equiv_table.get(str(awg), "Unknown")
|
return mm2_equiv_table.get(str(awg), "Unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pin(value):
|
||||||
|
"""Normalize a pin value: try int() first, fall back to str().
|
||||||
|
|
||||||
|
Rejects bools and non-integer floats to prevent silent data loss.
|
||||||
|
Matches the coercion convention used by expand().
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError(f"Boolean {value!r} is not a valid pin identifier")
|
||||||
|
if isinstance(value, float) and not value.is_integer():
|
||||||
|
raise ValueError(
|
||||||
|
f"Float {value!r} is not a valid pin identifier "
|
||||||
|
f"(would be silently truncated to {int(value)})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return str(value) if value is not None else value
|
||||||
|
|
||||||
|
|
||||||
def expand(yaml_data):
|
def expand(yaml_data):
|
||||||
# yaml_data can be:
|
# yaml_data can be:
|
||||||
# - a singleton (normally str or int)
|
# - a singleton (normally str or int)
|
||||||
@ -60,15 +79,11 @@ def expand(yaml_data):
|
|||||||
output.append(x) # descending range
|
output.append(x) # descending range
|
||||||
else: # a == b
|
else: # a == b
|
||||||
output.append(a) # range of length 1
|
output.append(a) # range of length 1
|
||||||
except:
|
except (ValueError, TypeError):
|
||||||
# '-' was not a delimiter between two ints, pass e through unchanged
|
# '-' was not a delimiter between two ints, pass e through unchanged
|
||||||
output.append(e)
|
output.append(e)
|
||||||
else:
|
else:
|
||||||
try:
|
output.append(normalize_pin(e))
|
||||||
x = int(e) # single int
|
|
||||||
except Exception:
|
|
||||||
x = e # string
|
|
||||||
output.append(x)
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
526
tests/test_resolve_pin.py
Normal file
526
tests/test_resolve_pin.py
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
# -*- 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
|
||||||
Loading…
x
Reference in New Issue
Block a user