diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py index cf5c04f..f7d97e9 100644 --- a/src/wireviz/wv_bom.py +++ b/src/wireviz/wv_bom.py @@ -9,7 +9,7 @@ import tabulate as tabulate_module 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") @@ -98,7 +98,7 @@ def pn_info_string( def bom_list(bom): headers = ( "# 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.append(headers) @@ -125,6 +125,7 @@ def bom_list(bom): ) else: cells.extend([None, None, None, None, None]) + cells.append(hash.url) # cells.extend([f"{entry['category']} ({entry['category'].name})"]) # for debugging rows.append(cells) # remove empty columns diff --git a/src/wireviz/wv_dataclasses.py b/src/wireviz/wv_dataclasses.py index 49242cf..3890e2d 100644 --- a/src/wireviz/wv_dataclasses.py +++ b/src/wireviz/wv_dataclasses.py @@ -191,6 +191,8 @@ class Component: mpn: str = None supplier: str = None spn: str = None + # URL (makes nodes/edges clickable in SVG, appears in BOM) + url: Optional[Union[str, List[str]]] = None # BOM info qty: Optional[Union[None, int, float]] = None amount: Optional[NumberAndUnit] = None @@ -212,12 +214,15 @@ class Component: else: _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: _hash = BomHash( description=self.description, qty_unit=_amount.unit if _amount else None, amount=None, partnumbers=self.partnumbers, + url=_url, ) else: _hash = BomHash( @@ -225,6 +230,7 @@ class Component: qty_unit=None, amount=_amount, partnumbers=self.partnumbers, + url=_url, ) return _hash @@ -630,6 +636,7 @@ class WireClass: ignore_in_bom: Optional[bool] = False sum_amounts_in_bom: bool = True partnumbers: PartNumberInfo = None + url: Optional[str] = None @property def bom_hash(self) -> BomHash: @@ -639,6 +646,7 @@ class WireClass: qty_unit=self.length.unit if self.length else None, amount=None, partnumbers=self.partnumbers, + url=self.url, ) else: _hash = BomHash( @@ -646,6 +654,7 @@ class WireClass: qty_unit=None, amount=self.length, partnumbers=self.partnumbers, + url=self.url, ) return _hash @@ -786,6 +795,14 @@ class Cable(TopLevelGraphicalComponent): else: 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: super().__post_init__() @@ -851,6 +868,14 @@ class Cable(TopLevelGraphicalComponent): else: 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 wire_tuples = zip_longest( # TODO: self.wire_ids @@ -874,6 +899,7 @@ class Cable(TopLevelGraphicalComponent): sum_amounts_in_bom=self.sum_amounts_in_bom, ignore_in_bom=self.ignore_in_bom, partnumbers=self._get_wire_partnumber(wire_index), + url=self._get_wire_url(wire_index), ) if self.shield: diff --git a/src/wireviz/wv_harness.py b/src/wireviz/wv_harness.py index 6c51e89..d148fc1 100644 --- a/src/wireviz/wv_harness.py +++ b/src/wireviz/wv_harness.py @@ -309,17 +309,18 @@ class Harness: label=f"<\n{gv_html}\n>", shape="box", style="filled", + href=connector.url if isinstance(connector.url, str) else '', ) # generate edges for connector loops if len(connector.loops) > 0: - dot.attr("edge", color="#000000") + dot.attr("edge", color="#000000", href='') loops = gv_connector_loops(connector) for head, tail, color in loops: dot.edge(head, tail, color = color, label = " ", noLabel="noLabel") # generate edges for connector shorts if len(connector.shorts) > 0: - dot.attr("edge", color="#000000") + dot.attr("edge", color="#000000", href='') shorts = gv_connector_shorts(connector) for head, tail, color in shorts: dot.edge(head, tail, @@ -352,12 +353,22 @@ class Harness: label=f"<\n{gv_html}\n>", shape="box", style=style, + href=cable.url if isinstance(cable.url, str) else '', ) # generate wire edges between component nodes and cable nodes for connection in cable._connections: 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): dot.edge(l1, l2) if not (r1, r2) == (None, None): @@ -365,11 +376,11 @@ class Harness: for color, we, ww in gv_edge_wire_inside(cable): 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: 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) apply_dot_tweaks(dot, self.tweak) diff --git a/src/wireviz/wv_output.py b/src/wireviz/wv_output.py index 07c9b46..1db85d3 100644 --- a/src/wireviz/wv_output.py +++ b/src/wireviz/wv_output.py @@ -127,7 +127,10 @@ def generate_html_output( row_html = " \n" for i, item in enumerate(row): td_class = f"bom_col_{bom[0][i].lower()}" - row_html = f'{row_html} {item if item is not None else ""}\n' + cell_value = item if item is not None else "" + if bom[0][i] == "URL" and cell_value: + cell_value = f'{cell_value}' + row_html = f'{row_html} {cell_value}\n' row_html = f"{row_html} \n" bom_contents.append(row_html) diff --git a/tests/test_url_href.py b/tests/test_url_href.py new file mode 100644 index 0000000..dad2f8e --- /dev/null +++ b/tests/test_url_href.py @@ -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 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 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