Add url/href support for clickable SVG nodes and BOM links (port of upstream PR #168)
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
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.
This commit is contained in:
parent
3d639f6ec2
commit
cfc25dfabf
@ -9,7 +9,7 @@ import tabulate as tabulate_module
|
|||||||
|
|
||||||
from wireviz.wv_utils import html_line_breaks
|
from wireviz.wv_utils import html_line_breaks
|
||||||
|
|
||||||
BOM_HASH_FIELDS = "description qty_unit amount partnumbers"
|
BOM_HASH_FIELDS = "description qty_unit amount partnumbers url"
|
||||||
|
|
||||||
|
|
||||||
BomEntry = namedtuple("BomEntry", "category qty designators")
|
BomEntry = namedtuple("BomEntry", "category qty designators")
|
||||||
@ -98,7 +98,7 @@ def pn_info_string(
|
|||||||
def bom_list(bom):
|
def bom_list(bom):
|
||||||
headers = (
|
headers = (
|
||||||
"# Qty Unit Description Amount Unit Designators "
|
"# Qty Unit Description Amount Unit Designators "
|
||||||
"P/N Manufacturer MPN Supplier SPN Category".split(" ")
|
"P/N Manufacturer MPN Supplier SPN URL Category".split(" ")
|
||||||
)
|
)
|
||||||
rows = []
|
rows = []
|
||||||
rows.append(headers)
|
rows.append(headers)
|
||||||
@ -125,6 +125,7 @@ def bom_list(bom):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cells.extend([None, None, None, None, None])
|
cells.extend([None, None, None, None, None])
|
||||||
|
cells.append(hash.url)
|
||||||
# cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging
|
# cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging
|
||||||
rows.append(cells)
|
rows.append(cells)
|
||||||
# remove empty columns
|
# remove empty columns
|
||||||
|
|||||||
@ -191,6 +191,8 @@ class Component:
|
|||||||
mpn: str = None
|
mpn: str = None
|
||||||
supplier: str = None
|
supplier: str = None
|
||||||
spn: str = None
|
spn: str = None
|
||||||
|
# URL (makes nodes/edges clickable in SVG, appears in BOM)
|
||||||
|
url: Optional[Union[str, List[str]]] = None
|
||||||
# BOM info
|
# BOM info
|
||||||
qty: Optional[Union[None, int, float]] = None
|
qty: Optional[Union[None, int, float]] = None
|
||||||
amount: Optional[NumberAndUnit] = None
|
amount: Optional[NumberAndUnit] = None
|
||||||
@ -212,12 +214,15 @@ class Component:
|
|||||||
else:
|
else:
|
||||||
_amount = self.amount
|
_amount = self.amount
|
||||||
|
|
||||||
|
# For BOM hashing, use scalar url only (lists are per-wire, handled by WireClass)
|
||||||
|
_url = self.url if isinstance(self.url, str) else None
|
||||||
if self.sum_amounts_in_bom:
|
if self.sum_amounts_in_bom:
|
||||||
_hash = BomHash(
|
_hash = BomHash(
|
||||||
description=self.description,
|
description=self.description,
|
||||||
qty_unit=_amount.unit if _amount else None,
|
qty_unit=_amount.unit if _amount else None,
|
||||||
amount=None,
|
amount=None,
|
||||||
partnumbers=self.partnumbers,
|
partnumbers=self.partnumbers,
|
||||||
|
url=_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_hash = BomHash(
|
_hash = BomHash(
|
||||||
@ -225,6 +230,7 @@ class Component:
|
|||||||
qty_unit=None,
|
qty_unit=None,
|
||||||
amount=_amount,
|
amount=_amount,
|
||||||
partnumbers=self.partnumbers,
|
partnumbers=self.partnumbers,
|
||||||
|
url=_url,
|
||||||
)
|
)
|
||||||
return _hash
|
return _hash
|
||||||
|
|
||||||
@ -630,6 +636,7 @@ class WireClass:
|
|||||||
ignore_in_bom: Optional[bool] = False
|
ignore_in_bom: Optional[bool] = False
|
||||||
sum_amounts_in_bom: bool = True
|
sum_amounts_in_bom: bool = True
|
||||||
partnumbers: PartNumberInfo = None
|
partnumbers: PartNumberInfo = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bom_hash(self) -> BomHash:
|
def bom_hash(self) -> BomHash:
|
||||||
@ -639,6 +646,7 @@ class WireClass:
|
|||||||
qty_unit=self.length.unit if self.length else None,
|
qty_unit=self.length.unit if self.length else None,
|
||||||
amount=None,
|
amount=None,
|
||||||
partnumbers=self.partnumbers,
|
partnumbers=self.partnumbers,
|
||||||
|
url=self.url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_hash = BomHash(
|
_hash = BomHash(
|
||||||
@ -646,6 +654,7 @@ class WireClass:
|
|||||||
qty_unit=None,
|
qty_unit=None,
|
||||||
amount=self.length,
|
amount=self.length,
|
||||||
partnumbers=self.partnumbers,
|
partnumbers=self.partnumbers,
|
||||||
|
url=self.url,
|
||||||
)
|
)
|
||||||
return _hash
|
return _hash
|
||||||
|
|
||||||
@ -786,6 +795,14 @@ class Cable(TopLevelGraphicalComponent):
|
|||||||
else:
|
else:
|
||||||
return None # non-bundles do not support lists of part data
|
return None # non-bundles do not support lists of part data
|
||||||
|
|
||||||
|
def _get_wire_url(self, idx) -> Optional[str]:
|
||||||
|
if self.category == "bundle" and isinstance(self.url, list):
|
||||||
|
return self.url[idx] if idx < len(self.url) else None
|
||||||
|
elif self.category == "bundle" and isinstance(self.url, str):
|
||||||
|
return self.url # scalar URL applies to all wires
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
super().__post_init__()
|
super().__post_init__()
|
||||||
|
|
||||||
@ -851,6 +868,14 @@ class Cable(TopLevelGraphicalComponent):
|
|||||||
else:
|
else:
|
||||||
raise Exception("lists of part data are only supported for bundles")
|
raise Exception("lists of part data are only supported for bundles")
|
||||||
|
|
||||||
|
# validate URL lists
|
||||||
|
if isinstance(self.url, list):
|
||||||
|
if self.category == "bundle":
|
||||||
|
if len(self.url) != self.wirecount:
|
||||||
|
raise Exception("URL list length must match wirecount")
|
||||||
|
else:
|
||||||
|
raise Exception("URL lists are only supported for bundles")
|
||||||
|
|
||||||
# all checks have passed
|
# all checks have passed
|
||||||
wire_tuples = zip_longest(
|
wire_tuples = zip_longest(
|
||||||
# TODO: self.wire_ids
|
# TODO: self.wire_ids
|
||||||
@ -874,6 +899,7 @@ class Cable(TopLevelGraphicalComponent):
|
|||||||
sum_amounts_in_bom=self.sum_amounts_in_bom,
|
sum_amounts_in_bom=self.sum_amounts_in_bom,
|
||||||
ignore_in_bom=self.ignore_in_bom,
|
ignore_in_bom=self.ignore_in_bom,
|
||||||
partnumbers=self._get_wire_partnumber(wire_index),
|
partnumbers=self._get_wire_partnumber(wire_index),
|
||||||
|
url=self._get_wire_url(wire_index),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.shield:
|
if self.shield:
|
||||||
|
|||||||
@ -309,17 +309,18 @@ class Harness:
|
|||||||
label=f"<\n{gv_html}\n>",
|
label=f"<\n{gv_html}\n>",
|
||||||
shape="box",
|
shape="box",
|
||||||
style="filled",
|
style="filled",
|
||||||
|
href=connector.url if isinstance(connector.url, str) else '',
|
||||||
)
|
)
|
||||||
# generate edges for connector loops
|
# generate edges for connector loops
|
||||||
if len(connector.loops) > 0:
|
if len(connector.loops) > 0:
|
||||||
dot.attr("edge", color="#000000")
|
dot.attr("edge", color="#000000", href='')
|
||||||
loops = gv_connector_loops(connector)
|
loops = gv_connector_loops(connector)
|
||||||
for head, tail, color in loops:
|
for head, tail, color in loops:
|
||||||
dot.edge(head, tail, color = color, label = " ", noLabel="noLabel")
|
dot.edge(head, tail, color = color, label = " ", noLabel="noLabel")
|
||||||
|
|
||||||
# generate edges for connector shorts
|
# generate edges for connector shorts
|
||||||
if len(connector.shorts) > 0:
|
if len(connector.shorts) > 0:
|
||||||
dot.attr("edge", color="#000000")
|
dot.attr("edge", color="#000000", href='')
|
||||||
shorts = gv_connector_shorts(connector)
|
shorts = gv_connector_shorts(connector)
|
||||||
for head, tail, color in shorts:
|
for head, tail, color in shorts:
|
||||||
dot.edge(head, tail,
|
dot.edge(head, tail,
|
||||||
@ -352,12 +353,22 @@ class Harness:
|
|||||||
label=f"<\n{gv_html}\n>",
|
label=f"<\n{gv_html}\n>",
|
||||||
shape="box",
|
shape="box",
|
||||||
style=style,
|
style=style,
|
||||||
|
href=cable.url if isinstance(cable.url, str) else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
# generate wire edges between component nodes and cable nodes
|
# generate wire edges between component nodes and cable nodes
|
||||||
for connection in cable._connections:
|
for connection in cable._connections:
|
||||||
color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection)
|
color, l1, l2, r1, r2 = gv_edge_wire(self, cable, connection)
|
||||||
dot.attr("edge", color=color)
|
# determine per-wire URL for clickable edges
|
||||||
|
wire_url = ''
|
||||||
|
if connection.via is not None:
|
||||||
|
wire_idx = connection.via.index
|
||||||
|
if isinstance(cable.url, list):
|
||||||
|
wire_url = cable.url[wire_idx] if wire_idx < len(cable.url) else ''
|
||||||
|
wire_url = wire_url or ''
|
||||||
|
elif isinstance(cable.url, str):
|
||||||
|
wire_url = cable.url
|
||||||
|
dot.attr("edge", color=color, href=wire_url)
|
||||||
if not (l1, l2) == (None, None):
|
if not (l1, l2) == (None, None):
|
||||||
dot.edge(l1, l2)
|
dot.edge(l1, l2)
|
||||||
if not (r1, r2) == (None, None):
|
if not (r1, r2) == (None, None):
|
||||||
@ -365,11 +376,11 @@ class Harness:
|
|||||||
|
|
||||||
for color, we, ww in gv_edge_wire_inside(cable):
|
for color, we, ww in gv_edge_wire_inside(cable):
|
||||||
if not (we, ww) == (None, None):
|
if not (we, ww) == (None, None):
|
||||||
dot.edge(we, ww, color=color, straight="straight")
|
dot.edge(we, ww, color=color, straight="straight", href='')
|
||||||
for mate in self.mates:
|
for mate in self.mates:
|
||||||
color, dir, code_from, code_to = gv_edge_mate(mate)
|
color, dir, code_from, code_to = gv_edge_mate(mate)
|
||||||
|
|
||||||
dot.attr("edge", color=color, style="dashed", dir=dir)
|
dot.attr("edge", color=color, style="dashed", dir=dir, href='')
|
||||||
dot.edge(code_from, code_to)
|
dot.edge(code_from, code_to)
|
||||||
|
|
||||||
apply_dot_tweaks(dot, self.tweak)
|
apply_dot_tweaks(dot, self.tweak)
|
||||||
|
|||||||
@ -127,7 +127,10 @@ def generate_html_output(
|
|||||||
row_html = " <tr>\n"
|
row_html = " <tr>\n"
|
||||||
for i, item in enumerate(row):
|
for i, item in enumerate(row):
|
||||||
td_class = f"bom_col_{bom[0][i].lower()}"
|
td_class = f"bom_col_{bom[0][i].lower()}"
|
||||||
row_html = f'{row_html} <td class="{td_class}">{item if item is not None else ""}</td>\n'
|
cell_value = item if item is not None else ""
|
||||||
|
if bom[0][i] == "URL" and cell_value:
|
||||||
|
cell_value = f'<a href="{cell_value}">{cell_value}</a>'
|
||||||
|
row_html = f'{row_html} <td class="{td_class}">{cell_value}</td>\n'
|
||||||
row_html = f"{row_html} </tr>\n"
|
row_html = f"{row_html} </tr>\n"
|
||||||
bom_contents.append(row_html)
|
bom_contents.append(row_html)
|
||||||
|
|
||||||
|
|||||||
314
tests/test_url_href.py
Normal file
314
tests/test_url_href.py
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
# -*- 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
|
||||||
Loading…
x
Reference in New Issue
Block a user