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:
oliver 2026-08-07 00:51:08 +08:00
parent 5d705fa7bb
commit 1ef43fdcd4
30 changed files with 4389 additions and 371 deletions

View file

@ -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
View 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()

View 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()