WireViz/tests/test_url_href.py
Ryan Malloy cfc25dfabf
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
Add url/href support for clickable SVG nodes and BOM links (port of upstream PR #168)
Add `url` field to Component, WireClass, and Cable with per-wire URL
support for bundles. URLs produce clickable href attributes on GraphViz
nodes/edges and appear as a linked column in HTML BOM output.
2026-02-13 05:06:51 -07:00

315 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for URL/href support (port of upstream PR #168).
Covers:
- url field on Connector, Cable, AdditionalBomItem, WireClass
- BOM hash differentiation by URL
- BOM list URL column presence/absence
- GraphViz href on nodes and edges
- HTML output <a> tag wrapping
"""
import re
import pytest
from wireviz.wv_bom import BomHash, bom_list
from wireviz.wv_dataclasses import (
AdditionalBomItem,
Cable,
Connector,
Metadata,
Options,
Tweak,
)
def make_connector(**kwargs):
args = {"designator": "X1", "pins": [1, 2, 3]}
args.update(kwargs)
return Connector(**args)
def make_cable(**kwargs):
args = {"designator": "W1", "wirecount": 3, "colors": ["BK", "RD", "GN"]}
args.update(kwargs)
return Cable(**args)
def make_harness():
from wireviz.wv_harness import Harness
return Harness(
metadata=Metadata({}),
options=Options(),
tweak=Tweak(),
)
# --- Data model ---
class TestUrlDataModel:
def test_connector_url_default_none(self):
c = make_connector()
assert c.url is None
def test_connector_url_scalar(self):
c = make_connector(url="https://example.com/connector")
assert c.url == "https://example.com/connector"
def test_cable_url_default_none(self):
c = make_cable()
assert c.url is None
def test_cable_url_scalar(self):
c = make_cable(url="https://example.com/cable")
assert c.url == "https://example.com/cable"
def test_bundle_url_list(self):
urls = [
"https://example.com/wire1",
"https://example.com/wire2",
"https://example.com/wire3",
]
c = make_cable(category="bundle", url=urls)
assert c.url == urls
# per-wire URLs should be propagated to WireClass objects
for i, wire in enumerate(c.wire_objects.values()):
if hasattr(wire, "url"):
assert wire.url == urls[i]
def test_bundle_url_scalar_applies_to_all_wires(self):
c = make_cable(category="bundle", url="https://example.com/all")
for wire in c.wire_objects.values():
if hasattr(wire, "url"):
assert wire.url == "https://example.com/all"
def test_bundle_url_list_wrong_length_raises(self):
with pytest.raises(Exception, match="URL list length must match wirecount"):
make_cable(category="bundle", url=["a", "b"]) # wirecount=3
def test_non_bundle_url_list_raises(self):
with pytest.raises(Exception, match="URL lists are only supported for bundles"):
make_cable(url=["a", "b", "c"]) # not a bundle
def test_additional_bom_item_url(self):
item = AdditionalBomItem(
type="Heatshrink",
url="https://example.com/heatshrink",
)
assert item.url == "https://example.com/heatshrink"
# --- BOM hashing ---
class TestUrlBomHash:
def test_different_urls_different_hashes(self):
c1 = make_connector(url="https://example.com/a")
c2 = make_connector(url="https://example.com/b")
assert c1.bom_hash != c2.bom_hash
def test_same_urls_same_hashes(self):
c1 = make_connector(url="https://example.com/same")
c2 = make_connector(url="https://example.com/same")
assert c1.bom_hash == c2.bom_hash
def test_url_vs_no_url_different_hashes(self):
c1 = make_connector(url="https://example.com/a")
c2 = make_connector()
assert c1.bom_hash != c2.bom_hash
def test_wire_class_url_in_hash(self):
c1 = make_cable(
category="bundle",
url=["https://a.com", "https://b.com", "https://c.com"],
)
c2 = make_cable(
category="bundle",
url=["https://x.com", "https://b.com", "https://c.com"],
)
# First wire has different URL, so first wire's hash should differ
wire1_c1 = list(c1.wire_objects.values())[0]
wire1_c2 = list(c2.wire_objects.values())[0]
assert wire1_c1.bom_hash != wire1_c2.bom_hash
# Second wire has same URL, should match
wire2_c1 = list(c1.wire_objects.values())[1]
wire2_c2 = list(c2.wire_objects.values())[1]
assert wire2_c1.bom_hash == wire2_c2.bom_hash
# --- BOM list ---
class TestUrlBomList:
def _make_bom_with_url(self, url=None):
harness = make_harness()
harness.add_connector("X1", pins=[1], url=url)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
harness.populate_bom()
return bom_list(harness.bom)
def test_url_column_present_when_used(self):
rows = self._make_bom_with_url("https://example.com")
headers = rows[0]
assert "URL" in headers
url_idx = headers.index("URL")
# at least one row should have the URL value
assert any(row[url_idx] == "https://example.com" for row in rows[1:])
def test_url_column_absent_when_unused(self):
rows = self._make_bom_with_url(None)
headers = rows[0]
assert "URL" not in headers
# --- GraphViz href ---
class TestUrlGraphviz:
def test_connector_node_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/x1")
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
assert 'href="https://example.com/x1"' in gv
def test_cable_node_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"], url="https://example.com/w1")
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
assert 'href="https://example.com/w1"' in gv
def test_no_url_produces_empty_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# all href attributes should be empty (no real URLs in href values)
hrefs = re.findall(r'href="([^"]*)"', gv)
assert all(h == '' for h in hrefs), f"Expected all empty hrefs, got: {hrefs}"
def test_wire_edge_href_scalar(self):
harness = make_harness()
harness.add_connector("X1", pins=[1])
harness.add_cable("W1", wirecount=1, colors=["BK"], url="https://example.com/wire")
harness.connect("X1", 1, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# The wire edge should have the cable URL as href
# Look for href in edge attributes
edge_hrefs = re.findall(r'href="(https://example\.com/wire)"', gv)
assert len(edge_hrefs) >= 1
def test_loop_edges_clear_href(self):
harness = make_harness()
harness.add_connector(
"X1",
pins=[1, 2, 3],
url="https://example.com/x1",
loops=[[1, 2]],
)
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 3, "W1", 1, None, None)
graph = harness.create_graph()
gv = graph.source
# Loop edge attr should clear href
# The connector node should have the real href
assert 'href="https://example.com/x1"' in gv
def test_mate_edges_clear_href(self):
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/x1")
harness.add_connector("X2", pins=[1], url="https://example.com/x2")
harness.add_mate_component("X1", "X2", "->")
graph = harness.create_graph()
gv = graph.source
# Both connectors should have hrefs, mate edge should not leak them
assert 'href="https://example.com/x1"' in gv
assert 'href="https://example.com/x2"' in gv
# --- HTML output ---
class TestUrlHtmlOutput:
def test_url_wrapped_in_anchor(self):
"""URL values in BOM should be wrapped in <a> tags in HTML."""
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/part")
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, None, None)
harness.populate_bom()
bomlist = bom_list(harness.bom)
# Find URL column index
headers = bomlist[0]
assert "URL" in headers
url_idx = headers.index("URL")
# The connector row should have the URL
url_values = [row[url_idx] for row in bomlist[1:] if row[url_idx]]
assert "https://example.com/part" in url_values
def test_empty_url_not_wrapped(self):
"""Rows without URLs should not get anchor tags."""
harness = make_harness()
harness.add_connector("X1", pins=[1], url="https://example.com/part")
harness.add_connector("X2", pins=[1]) # no URL
harness.add_cable("W1", wirecount=1, colors=["BK"])
harness.connect("X1", 1, "W1", 1, "X2", 1)
harness.populate_bom()
bomlist = bom_list(harness.bom)
headers = bomlist[0]
if "URL" in headers:
url_idx = headers.index("URL")
# At least one row should have None/empty URL
null_urls = [row[url_idx] for row in bomlist[1:] if not row[url_idx]]
assert len(null_urls) >= 1
# --- Backward compatibility ---
class TestBackwardCompatibility:
def test_connector_without_url(self):
c = make_connector()
assert c.bom_hash is not None
assert c.url is None
def test_cable_without_url(self):
c = make_cable()
assert c.bom_hash is not None
assert c.url is None
def test_bundle_without_url(self):
c = make_cable(category="bundle")
for wire in c.wire_objects.values():
assert wire.bom_hash is not None
if hasattr(wire, "url"):
assert wire.url is None
def test_full_harness_without_urls(self):
harness = make_harness()
harness.add_connector("X1", pins=[1, 2])
harness.add_connector("X2", pins=[1, 2])
harness.add_cable("W1", wirecount=2, colors=["BK", "RD"])
harness.connect("X1", 1, "W1", 1, "X2", 1)
harness.connect("X1", 2, "W1", 2, "X2", 2)
harness.populate_bom()
bomlist = bom_list(harness.bom)
headers = bomlist[0]
# URL column should be auto-removed when no URLs are used
assert "URL" not in headers