mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Unify UME World as hex nav with conditional flat world map.
Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5d705fa7bb
commit
1ef43fdcd4
30 changed files with 4389 additions and 371 deletions
|
|
@ -480,7 +480,7 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
reg = next(c for c in tree.root.children if c.id == region.id)
|
||||
self.assertEqual(len(reg.views), 1)
|
||||
self.assertTrue(any(v.id == custom.id for v in reg.views))
|
||||
|
||||
out = svc.delete_folder(self.db, region.id)
|
||||
self.assertTrue(out.get("deleted"))
|
||||
|
|
@ -493,6 +493,53 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
0,
|
||||
)
|
||||
|
||||
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
|
||||
parent = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Parent-Canvas", kind="region")
|
||||
)
|
||||
child = svc.create_folder(
|
||||
self.db,
|
||||
TopologyFolderCreate(name="Child-Icon", kind="region", parent_id=parent.id),
|
||||
)
|
||||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
p = next(c for c in tree.root.children if c.id == parent.id)
|
||||
self.assertTrue(p.views)
|
||||
graph = svc.get_view_graph(self.db, p.views[0].id)
|
||||
regions = [n for n in graph.nodes if n.kind == "region"]
|
||||
self.assertEqual(len(regions), 1)
|
||||
self.assertEqual(regions[0].folder_id, child.id)
|
||||
self.assertEqual(regions[0].label, "Child-Icon")
|
||||
self.assertTrue(str(regions[0].fabric_node_id).startswith("region:"))
|
||||
|
||||
# Drag/save positions for synthetic region nodes.
|
||||
moved = svc.patch_view_positions(
|
||||
self.db,
|
||||
p.views[0].id,
|
||||
ViewPositionsPatch(
|
||||
positions=[
|
||||
ViewNodeIn(
|
||||
fabric_node_id=regions[0].fabric_node_id, x=321, y=210
|
||||
)
|
||||
],
|
||||
return_graph=True,
|
||||
),
|
||||
)
|
||||
if hasattr(moved, "nodes"):
|
||||
hit = next(n for n in moved.nodes if n.kind == "region")
|
||||
else:
|
||||
hit = next(
|
||||
n
|
||||
for n in svc.get_view_graph(self.db, p.views[0].id).nodes
|
||||
if n.kind == "region"
|
||||
)
|
||||
self.assertEqual(hit.x, 321)
|
||||
self.assertEqual(hit.y, 210)
|
||||
|
||||
svc.delete_folder(self.db, child.id)
|
||||
graph2 = svc.get_view_graph(self.db, p.views[0].id)
|
||||
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
|
||||
|
||||
def test_classify_role_region_and_slices(self) -> None:
|
||||
from netx_api import topology_classify as clf
|
||||
from netx_api.topology_schemas import (
|
||||
|
|
|
|||
369
tests/test_ume_hierarchy.py
Normal file
369
tests/test_ume_hierarchy.py
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
"""Hierarchical UME level canvas + flat packing tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import netx_api.models # noqa: F401
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import TopoFabricNode, TopoFolder, TopoView, UmeInventoryNE, UmeTopoLink, UmeTopoNode
|
||||
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
|
||||
from netx_api.ume_topology_flat_coords import recompute_flat_world_coords
|
||||
from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
|
||||
from netx_api.ume_topology_world_graph import get_level_view_graph, get_flat_view_graph
|
||||
|
||||
|
||||
class UmeHierarchyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
TestingSessionLocal = sessionmaker(
|
||||
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
self.db = TestingSessionLocal()
|
||||
self.now = datetime(2026, 8, 6, 12, 0, 0)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
|
||||
def _seed_tree(self):
|
||||
# MD
|
||||
# └─ root-sbn (x=10,y=20)
|
||||
# ├─ city-a (x=100,y=100) → ME-A1, ME-A2
|
||||
# └─ city-b (x=200,y=100) → ME-B1
|
||||
# └─ direct ME under MD (rare)
|
||||
for sid, label, parent, x, y in [
|
||||
("root-sbn", "Root SBN", "md-virtual", 10, 20),
|
||||
("city-a", "City A", "root-sbn", 100, 100),
|
||||
("city-b", "City B", "root-sbn", 200, 100),
|
||||
]:
|
||||
self.db.add(
|
||||
UmeTopoNode(
|
||||
node_id=sid,
|
||||
name=f"SBN={sid}",
|
||||
node_type="TOPO_NODE_SBN",
|
||||
user_label=label,
|
||||
parent_node=parent,
|
||||
x_pos=x,
|
||||
y_pos=y,
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
mes = [
|
||||
("me-a1", "A1", "city-a", 10, 10),
|
||||
("me-a2", "A2", "city-a", 50, 10),
|
||||
("me-b1", "B1", "city-b", 10, 10), # same local as A1 — would collide if global
|
||||
]
|
||||
for uid, label, parent, x, y in mes:
|
||||
self.db.add(
|
||||
UmeInventoryNE(
|
||||
ne_id=uid,
|
||||
ne_name=f"ME{{{uid}}}",
|
||||
user_label=label,
|
||||
host_name=label,
|
||||
ip_address=f"10.0.0.{label[-1]}",
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeTopoNode(
|
||||
node_id=uid,
|
||||
name=f"ME={uid}",
|
||||
node_type="TOPO_NODE_ME",
|
||||
user_label=label,
|
||||
parent_node=parent,
|
||||
x_pos=x,
|
||||
y_pos=y,
|
||||
ume_ne_id=uid,
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
# Cross-city link A1—B1
|
||||
self.db.add(
|
||||
UmeTopoLink(
|
||||
link_id="link-ab",
|
||||
name="TL{ab}",
|
||||
user_label="A1_xxvgei-1/1/0/1_B1_xxvgei-1/1/0/1",
|
||||
a_end_tp_ref="ME{me-a1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_1}",
|
||||
z_end_tp_ref="ME{me-b1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_1}",
|
||||
a_ume_ne_id="me-a1",
|
||||
z_ume_ne_id="me-b1",
|
||||
a_ifname="xxvgei-1/1/0/1",
|
||||
z_ifname="xxvgei-1/1/0/1",
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
# Intra-city A1—A2
|
||||
self.db.add(
|
||||
UmeTopoLink(
|
||||
link_id="link-aa",
|
||||
name="TL{aa}",
|
||||
user_label="A1_xxvgei-1/1/0/2_A2_xxvgei-1/1/0/2",
|
||||
a_end_tp_ref="ME{me-a1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_2}",
|
||||
z_end_tp_ref="ME{me-a2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_2}",
|
||||
a_ume_ne_id="me-a1",
|
||||
z_ume_ne_id="me-a2",
|
||||
a_ifname="xxvgei-1/1/0/2",
|
||||
z_ifname="xxvgei-1/1/0/2",
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def test_seed_views_and_level_graph(self):
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
stats = ensure_ume_world_and_sbn_folders(self.db)
|
||||
self.assertTrue(stats["world_view_id"])
|
||||
self.assertTrue(stats["world_flat_view_id"])
|
||||
self.assertGreaterEqual(stats["sbn_views"], 3)
|
||||
|
||||
world = get_world_view(self.db)
|
||||
self.assertIsNotNone(world)
|
||||
# World view hangs under World drill folder, Flat under UME World container.
|
||||
from netx_api.ume_topology_world import WORLD_DRILL_REF, WORLD_FOLDER_NAME
|
||||
|
||||
drill = (
|
||||
self.db.query(TopoFolder)
|
||||
.filter(TopoFolder.external_ref == WORLD_DRILL_REF)
|
||||
.one()
|
||||
)
|
||||
container = (
|
||||
self.db.query(TopoFolder)
|
||||
.filter(TopoFolder.name == WORLD_FOLDER_NAME)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(drill.parent_id, container.id)
|
||||
self.assertEqual(world.folder_id, drill.id)
|
||||
root_sbn = (
|
||||
self.db.query(TopoFolder)
|
||||
.filter(TopoFolder.external_ref == "root-sbn")
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(root_sbn.parent_id, drill.id)
|
||||
|
||||
g = get_level_view_graph(self.db, world)
|
||||
kinds = {n.kind for n in g.nodes}
|
||||
self.assertIn("region", kinds)
|
||||
# Root level shows root-sbn as region, not city MEs
|
||||
region_ids = {n.ume_ne_id for n in g.nodes if n.kind == "region"}
|
||||
self.assertIn("root-sbn", region_ids)
|
||||
self.assertNotIn("city-a", region_ids)
|
||||
|
||||
# Open root-sbn level: cities as regions + logical link between them
|
||||
root_view = (
|
||||
self.db.query(TopoView)
|
||||
.filter(TopoView.filter["sbn_id"].as_string() == "root-sbn")
|
||||
.first()
|
||||
)
|
||||
# SQLite JSON path may differ — find by filter scan
|
||||
if root_view is None:
|
||||
for v in self.db.query(TopoView).all():
|
||||
if str((v.filter or {}).get("sbn_id") or "") == "root-sbn":
|
||||
root_view = v
|
||||
break
|
||||
self.assertIsNotNone(root_view)
|
||||
g2 = get_level_view_graph(self.db, root_view)
|
||||
regions = [n for n in g2.nodes if n.kind == "region"]
|
||||
self.assertEqual({n.ume_ne_id for n in regions}, {"city-a", "city-b"})
|
||||
logical = [e for e in g2.edges if e.layer == "logical"]
|
||||
self.assertGreaterEqual(len(logical), 1)
|
||||
|
||||
# Leaf city-a: only MEs + physical edge
|
||||
leaf = None
|
||||
for v in self.db.query(TopoView).all():
|
||||
if str((v.filter or {}).get("sbn_id") or "") == "city-a":
|
||||
leaf = v
|
||||
break
|
||||
self.assertIsNotNone(leaf)
|
||||
g3 = get_level_view_graph(self.db, leaf)
|
||||
self.assertTrue(all(n.kind == "ne" for n in g3.nodes))
|
||||
self.assertEqual(len(g3.nodes), 2)
|
||||
self.assertTrue(any(e.layer == "physical" for e in g3.edges))
|
||||
|
||||
def test_region_drag_position_persists_on_ume_level(self):
|
||||
"""Drag/save writes TopoViewNode; reload must not snap back to UME x_pos."""
|
||||
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
|
||||
from netx_api.topology_views_graph import get_view_graph, patch_view_positions
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
world = get_world_view(self.db)
|
||||
self.assertIsNotNone(world)
|
||||
g0 = get_level_view_graph(self.db, world)
|
||||
region = next(n for n in g0.nodes if n.kind == "region" and n.ume_ne_id == "root-sbn")
|
||||
self.assertEqual(region.x, 10.0)
|
||||
self.assertEqual(region.y, 20.0)
|
||||
|
||||
saved = patch_view_positions(
|
||||
self.db,
|
||||
world.id,
|
||||
ViewPositionsPatch(
|
||||
positions=[ViewNodeIn(fabric_node_id=region.fabric_node_id, x=777.0, y=888.0)],
|
||||
return_graph=True,
|
||||
),
|
||||
)
|
||||
# Save return_graph must stay on the UME canvas (not empty membership graph).
|
||||
self.assertTrue(any(n.kind == "region" for n in saved.nodes))
|
||||
moved = next(n for n in saved.nodes if n.fabric_node_id == region.fabric_node_id)
|
||||
self.assertEqual(moved.x, 777.0)
|
||||
self.assertEqual(moved.y, 888.0)
|
||||
|
||||
g1 = get_level_view_graph(self.db, world)
|
||||
again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id)
|
||||
self.assertEqual(again.x, 777.0)
|
||||
self.assertEqual(again.y, 888.0)
|
||||
|
||||
via_get = get_view_graph(self.db, world.id)
|
||||
via = next(n for n in via_get.nodes if n.fabric_node_id == region.fabric_node_id)
|
||||
self.assertEqual(via.x, 777.0)
|
||||
self.assertEqual(via.y, 888.0)
|
||||
|
||||
def test_world_map_2a_and_tree_visibility(self):
|
||||
from netx_api.topology_views_tree import get_topology_tree
|
||||
from netx_api.ume_topology_world import (
|
||||
WORLD_FLAT_VIEW_NAME,
|
||||
get_world_container_folder,
|
||||
get_world_flat_view,
|
||||
world_map_should_exist,
|
||||
)
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
stats = ensure_ume_world_and_sbn_folders(self.db)
|
||||
self.assertTrue(world_map_should_exist(self.db))
|
||||
self.assertTrue(stats.get("world_map_visible"))
|
||||
flat = get_world_flat_view(self.db)
|
||||
self.assertIsNotNone(flat)
|
||||
self.assertFalse(bool((flat.filter or {}).get("suppressed")))
|
||||
|
||||
tree = get_topology_tree(self.db)
|
||||
container = get_world_container_folder(self.db)
|
||||
self.assertIsNotNone(container)
|
||||
# Find container in tree
|
||||
def find(folder, fid):
|
||||
if folder.id == fid:
|
||||
return folder
|
||||
for c in folder.children or []:
|
||||
hit = find(c, fid)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
node = find(tree.root, container.id)
|
||||
self.assertIsNotNone(node)
|
||||
view_names = {v.name for v in node.views}
|
||||
self.assertIn(WORLD_FLAT_VIEW_NAME, view_names)
|
||||
# Only one L2 region child (World drill)
|
||||
region_kids = [c for c in node.children if c.kind == "region"]
|
||||
drill = [c for c in region_kids if c.external_ref == "ume:world:drill" or c.name == "World"]
|
||||
self.assertEqual(len(drill), 1)
|
||||
|
||||
def test_world_map_forbids_add_nes(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from netx_api.topology_schemas import ViewNodesAdd
|
||||
from netx_api.topology_views_graph import add_nodes_to_view
|
||||
from netx_api.ume_topology_world import get_world_flat_view
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
flat = get_world_flat_view(self.db)
|
||||
self.assertIsNotNone(flat)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
add_nodes_to_view(
|
||||
self.db,
|
||||
flat.id,
|
||||
ViewNodesAdd(fabric_node_ids=["nope"], return_graph=False),
|
||||
)
|
||||
self.assertEqual(ctx.exception.detail, "world_map_no_direct_nes")
|
||||
|
||||
def test_flat_drag_override_survives_recompute(self):
|
||||
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
|
||||
from netx_api.topology_views_graph import patch_view_positions
|
||||
from netx_api.ume_topology_world import get_world_flat_view
|
||||
from netx_api.ume_topology_world_graph import get_flat_view_graph
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
flat = get_world_flat_view(self.db)
|
||||
self.assertIsNotNone(flat)
|
||||
g0 = get_flat_view_graph(self.db, flat)
|
||||
self.assertGreater(len(g0.nodes), 0)
|
||||
target = g0.nodes[0]
|
||||
patch_view_positions(
|
||||
self.db,
|
||||
flat.id,
|
||||
ViewPositionsPatch(
|
||||
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=1111.0, y=2222.0)],
|
||||
return_graph=False,
|
||||
),
|
||||
)
|
||||
recompute_flat_world_coords(self.db)
|
||||
g1 = get_flat_view_graph(self.db, flat)
|
||||
moved = next(n for n in g1.nodes if n.fabric_node_id == target.fabric_node_id)
|
||||
self.assertEqual(moved.x, 1111.0)
|
||||
self.assertEqual(moved.y, 2222.0)
|
||||
|
||||
def test_create_folder_under_ume_world_remounts_to_drill(self):
|
||||
from netx_api.topology_schemas import TopologyFolderCreate
|
||||
from netx_api.topology_views_tree import create_folder
|
||||
from netx_api.ume_topology_world import get_world_container_folder, get_world_drill_folder
|
||||
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
container = get_world_container_folder(self.db)
|
||||
drill = get_world_drill_folder(self.db)
|
||||
self.assertIsNotNone(container)
|
||||
self.assertIsNotNone(drill)
|
||||
out = create_folder(
|
||||
self.db,
|
||||
TopologyFolderCreate(name="Manual City", parent_id=container.id, kind="region"),
|
||||
)
|
||||
row = self.db.get(TopoFolder, out.id)
|
||||
self.assertEqual(row.parent_id, drill.id)
|
||||
|
||||
def test_flat_slots_do_not_overlap(self):
|
||||
self._seed_tree()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
ensure_ume_world_and_sbn_folders(self.db)
|
||||
recompute_flat_world_coords(self.db)
|
||||
by_sbn: dict[str, list[TopoFabricNode]] = {}
|
||||
for n in self.db.query(TopoFabricNode).all():
|
||||
sid = str((n.attrs or {}).get("ume_sbn_id") or "")
|
||||
by_sbn.setdefault(sid, []).append(n)
|
||||
a_nodes = by_sbn.get("city-a") or []
|
||||
b_nodes = by_sbn.get("city-b") or []
|
||||
self.assertEqual(len(a_nodes), 2)
|
||||
self.assertEqual(len(b_nodes), 1)
|
||||
# Bounding boxes of the two cities must not intersect
|
||||
def bbox(nodes):
|
||||
xs = [float(n.world_x) for n in nodes]
|
||||
ys = [float(n.world_y) for n in nodes]
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
ax0, ax1, ay0, ay1 = bbox(a_nodes)
|
||||
bx0, bx1, by0, by1 = bbox(b_nodes)
|
||||
overlap_x = not (ax1 < bx0 or bx1 < ax0)
|
||||
overlap_y = not (ay1 < by0 or by1 < ay0)
|
||||
self.assertFalse(overlap_x and overlap_y)
|
||||
|
||||
# Intra-city relative delta preserved (A2 - A1 local was 40 in x)
|
||||
a1 = next(n for n in a_nodes if n.ume_ne_id == "me-a1")
|
||||
a2 = next(n for n in a_nodes if n.ume_ne_id == "me-a2")
|
||||
self.assertAlmostEqual(float(a2.world_x) - float(a1.world_x), 40.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
235
tests/test_ume_topology_apply.py
Normal file
235
tests/test_ume_topology_apply.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"""Unit tests for UME port normalize + fabric apply provenance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import netx_api.models # noqa: F401
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import TopoFabricEdge, TopoFabricNode, UmeInventoryNE, UmeTopoLink, UmeTopoNode
|
||||
from netx_api.ume_port_normalize import (
|
||||
extract_ifnames_from_user_label,
|
||||
port_keys_compatible,
|
||||
port_suffix_from_tp_ref,
|
||||
resolve_link_ifnames,
|
||||
)
|
||||
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
|
||||
from netx_api.topology_fabric_links import upsert_fabric_edge
|
||||
|
||||
|
||||
class UmePortNormalizeTests(unittest.TestCase):
|
||||
def test_label_xxvgei_pair(self):
|
||||
label = (
|
||||
"MDN-UJSB-EN1-Z20HS-SMGD[1/1/0]_xxvgei-1/1/0/32(10G:to_MDN-UJSR-EN1-Z680H_"
|
||||
"xgei-1/2/0/1:via_OTB)_MDN-UJSR"
|
||||
)
|
||||
tokens = extract_ifnames_from_user_label(label)
|
||||
self.assertEqual(tokens[0], "xxvgei-1/1/0/32")
|
||||
self.assertEqual(tokens[1], "xgei-1/2/0/1")
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_32}",
|
||||
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=2},PTP={/p=1_1}",
|
||||
user_label=label,
|
||||
)
|
||||
self.assertEqual(a, "xxvgei-1/1/0/32")
|
||||
self.assertEqual(z, "xgei-1/2/0/1")
|
||||
|
||||
def test_eq_ptp_suffix(self):
|
||||
self.assertEqual(
|
||||
port_suffix_from_tp_ref("ME{x},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}"),
|
||||
"1/1/0/28",
|
||||
)
|
||||
self.assertEqual(
|
||||
port_suffix_from_tp_ref("ME{x},EQ={/r=0/sh=0/sl=0/ssl=1},PTP={/p=1_4}"),
|
||||
"0/0/1/4",
|
||||
)
|
||||
|
||||
def test_eth_colon_fallback(self):
|
||||
label = "JBI-SIMK-EN1-Z20HS-[1/1/0]_ETH:28_JBI-OTOB-EN1-Z20HS-[1/1/0]_ETH:28"
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
|
||||
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
|
||||
user_label=label,
|
||||
)
|
||||
self.assertEqual(a, "1/1/0/28")
|
||||
self.assertEqual(z, "1/1/0/28")
|
||||
|
||||
def test_port_compatible(self):
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "1/1/0/32"))
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/32"))
|
||||
self.assertFalse(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/28"))
|
||||
|
||||
|
||||
class UmeFabricApplyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
TestingSessionLocal = sessionmaker(
|
||||
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
self.db = TestingSessionLocal()
|
||||
self.now = datetime(2026, 8, 6, 12, 0, 0)
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
|
||||
def _seed_ume(self):
|
||||
self.db.add(
|
||||
UmeInventoryNE(
|
||||
ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
ne_name="ME{a}",
|
||||
user_label="NE-A",
|
||||
host_name="NE-A",
|
||||
ip_address="10.0.0.1",
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeInventoryNE(
|
||||
ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
ne_name="ME{b}",
|
||||
user_label="NE-B",
|
||||
host_name="NE-B",
|
||||
ip_address="10.0.0.2",
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeTopoNode(
|
||||
node_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
name="MD=ZTE;ME=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
node_type="TOPO_NODE_ME",
|
||||
user_label="NE-A",
|
||||
parent_node="sbn-1",
|
||||
x_pos=100,
|
||||
y_pos=200,
|
||||
ume_ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeTopoNode(
|
||||
node_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
name="MD=ZTE;ME=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
node_type="TOPO_NODE_ME",
|
||||
user_label="NE-B",
|
||||
parent_node="sbn-1",
|
||||
x_pos=300,
|
||||
y_pos=400,
|
||||
ume_ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeTopoLink(
|
||||
link_id="link-1",
|
||||
name="TL{…}",
|
||||
user_label="NE-A_xxvgei-1/1/0/32_NE-B_xxvgei-1/1/0/28",
|
||||
a_end_tp_ref="ME{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa},EQ={/r=0/sh=1/sl=1},PTP={/p=1_32}",
|
||||
z_end_tp_ref="ME{bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
|
||||
a_ume_ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
z_ume_ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
a_ptp="/p=1_32",
|
||||
z_ptp="/p=1_28",
|
||||
a_ifname="xxvgei-1/1/0/32",
|
||||
z_ifname="xxvgei-1/1/0/28",
|
||||
first_seen_at=self.now,
|
||||
last_seen_at=self.now,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def test_apply_sets_coords_and_ume_edge(self):
|
||||
self._seed_ume()
|
||||
stats = apply_ume_topology_to_fabric(self.db)
|
||||
self.assertEqual(stats["nodes_coords"], 2)
|
||||
self.assertGreaterEqual(stats["edges_upserted"] + stats["edges_merged"], 1)
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
self.assertEqual((a.attrs or {}).get("ume_local_x"), 100.0)
|
||||
self.assertEqual((a.attrs or {}).get("ume_local_y"), 200.0)
|
||||
# Flat packing: single SBN slot → local relative to cluster min.
|
||||
self.assertEqual(a.world_x, 0.0)
|
||||
self.assertEqual(a.world_y, 0.0)
|
||||
edges = self.db.query(TopoFabricEdge).all()
|
||||
self.assertEqual(len(edges), 1)
|
||||
self.assertIn("ume", (edges[0].attrs or {}).get("sources", []))
|
||||
|
||||
def test_lldp_edge_survives_ume_clear(self):
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
# Simulate LLDP also seeing it
|
||||
edge.source = "lldp"
|
||||
edge.attrs = {"sources": ["ume", "lldp"]}
|
||||
self.db.commit()
|
||||
|
||||
# Remove UME link → apply should keep edge (lldp remains)
|
||||
self.db.query(UmeTopoLink).delete()
|
||||
self.db.commit()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
edge2 = self.db.query(TopoFabricEdge).one()
|
||||
self.assertEqual(edge2.status, "active")
|
||||
self.assertIn("lldp", (edge2.attrs or {}).get("sources", []))
|
||||
self.assertNotIn("ume", (edge2.attrs or {}).get("sources", []))
|
||||
|
||||
def test_pure_ume_becomes_missing_when_gone(self):
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
self.db.query(UmeTopoLink).delete()
|
||||
self.db.commit()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
self.assertEqual(edge.status, "missing")
|
||||
|
||||
def test_manual_edge_never_cleared(self):
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=a.id,
|
||||
b_node_id=b.id,
|
||||
a_port="gi0/1",
|
||||
b_port="gi0/2",
|
||||
source="manual",
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.query(UmeTopoLink).delete()
|
||||
self.db.commit()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
manuals = (
|
||||
self.db.query(TopoFabricEdge).filter(TopoFabricEdge.source == "manual").all()
|
||||
)
|
||||
self.assertEqual(len(manuals), 1)
|
||||
self.assertEqual(manuals[0].status, "active")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue