# -*- 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