mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Unify manual topology roots with UME World: auto root map, and fix empty UME canvas.
Top-level folders are nav-only with a unique system root map; UI drops custom/extra L2 creates. Batched dock-to-Fabric apply avoids PG lock OOM so World NEs can render, with subtree region counts restored. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
edfa3f8756
commit
48b10656d2
9 changed files with 867 additions and 366 deletions
|
|
@ -319,9 +319,17 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
self.assertEqual(action, "skipped_self_loop")
|
||||
|
||||
def _region(self, name: str = "Test-Region") -> str:
|
||||
return svc.create_folder(
|
||||
"""Return the unique L2「根图」canvas id under a new top-level「根」."""
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name=name, kind="region")
|
||||
).id
|
||||
)
|
||||
child = (
|
||||
self.db.query(TopoFolder)
|
||||
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
return child.id if child is not None else top.id
|
||||
|
||||
def test_view_crud_and_positions(self) -> None:
|
||||
suffix = uuid4().hex[:8]
|
||||
|
|
@ -394,93 +402,96 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
region = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="East", kind="region")
|
||||
)
|
||||
# Creating a region does not auto-create a map — user/MCP adds views.
|
||||
# Top-level「根」is nav-only; unique L2「根图」holds the canvas.
|
||||
tree1 = svc.get_topology_tree(self.db)
|
||||
assert tree1.root is not None
|
||||
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
||||
self.assertEqual(east0.views, [])
|
||||
self.assertEqual(len(east0.children), 1)
|
||||
root_map = east0.children[0]
|
||||
self.assertEqual(root_map.name, "根图")
|
||||
self.assertEqual(len(root_map.views), 1)
|
||||
self.assertEqual(root_map.views[0].name, "根图")
|
||||
|
||||
view = svc.create_view(
|
||||
self.db,
|
||||
TopologyViewCreate(
|
||||
name="East-Custom",
|
||||
folder_id=region.id,
|
||||
folder_id=root_map.id,
|
||||
kind="custom",
|
||||
),
|
||||
)
|
||||
self.assertEqual(view.folder_id, region.id)
|
||||
self.assertEqual(view.folder_id, root_map.id)
|
||||
self.assertEqual(view.kind, "custom")
|
||||
self.assertIn("membership", view.filter)
|
||||
|
||||
tree2 = svc.get_topology_tree(self.db)
|
||||
assert tree2.root is not None
|
||||
east = next(c for c in tree2.root.children if c.id == region.id)
|
||||
self.assertTrue(any(v.id == view.id for v in east.views))
|
||||
self.assertEqual(len(east.views), 1)
|
||||
self.assertTrue(all(not getattr(v, "children", None) for v in east.views))
|
||||
rm = east.children[0]
|
||||
self.assertTrue(any(v.id == view.id for v in rm.views))
|
||||
self.assertEqual(len(rm.views), 2)
|
||||
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views))
|
||||
|
||||
def test_site_physical_and_custom_flat(self) -> None:
|
||||
region = svc.create_folder(
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||||
)
|
||||
phys = svc.create_view(
|
||||
self.db,
|
||||
TopologyViewCreate(
|
||||
name="Physical",
|
||||
folder_id=region.id,
|
||||
kind="physical",
|
||||
),
|
||||
)
|
||||
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)
|
||||
physicals = [v for v in reg.views if v.kind == "physical"]
|
||||
self.assertEqual(len(physicals), 1)
|
||||
self.assertEqual(physicals[0].id, phys.id)
|
||||
tree0 = svc.get_topology_tree(self.db)
|
||||
assert tree0.root is not None
|
||||
nav = next(c for c in tree0.root.children if c.id == top.id)
|
||||
root_map = nav.children[0]
|
||||
self.assertEqual(root_map.name, "根图")
|
||||
auto_phys = root_map.views[0]
|
||||
self.assertEqual(auto_phys.kind, "physical")
|
||||
|
||||
custom = svc.create_view(
|
||||
self.db,
|
||||
TopologyViewCreate(
|
||||
name="Custom-A",
|
||||
folder_id=region.id,
|
||||
folder_id=root_map.id,
|
||||
kind="custom",
|
||||
),
|
||||
)
|
||||
tree2 = svc.get_topology_tree(self.db)
|
||||
assert tree2.root is not None
|
||||
reg2 = next(c for c in tree2.root.children if c.id == region.id)
|
||||
reg2 = next(c for c in tree2.root.children if c.id == top.id).children[0]
|
||||
ids = {v.id for v in reg2.views}
|
||||
self.assertIn(phys.id, ids)
|
||||
self.assertIn(auto_phys.id, ids)
|
||||
self.assertIn(custom.id, ids)
|
||||
# physical first
|
||||
self.assertEqual(reg2.views[0].kind, "physical")
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
svc.delete_view(self.db, phys.id)
|
||||
svc.delete_view(self.db, auto_phys.id)
|
||||
svc.delete_view(self.db, custom.id)
|
||||
# force-delete physical does not recreate another map
|
||||
svc.delete_view(self.db, phys.id, force=True)
|
||||
svc.delete_view(self.db, auto_phys.id, force=True)
|
||||
tree3 = svc.get_topology_tree(self.db)
|
||||
assert tree3.root is not None
|
||||
reg3 = next(c for c in tree3.root.children if c.id == region.id)
|
||||
reg3 = next(c for c in tree3.root.children if c.id == top.id).children[0]
|
||||
self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0)
|
||||
|
||||
def test_delete_region_cascades_views(self) -> None:
|
||||
region = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Del-Region", kind="region")
|
||||
)
|
||||
tree0 = svc.get_topology_tree(self.db)
|
||||
assert tree0.root is not None
|
||||
nav = next(c for c in tree0.root.children if c.id == region.id)
|
||||
root_map = nav.children[0]
|
||||
custom = svc.create_view(
|
||||
self.db,
|
||||
TopologyViewCreate(
|
||||
name="Custom-Del",
|
||||
folder_id=region.id,
|
||||
folder_id=root_map.id,
|
||||
kind="custom",
|
||||
),
|
||||
)
|
||||
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.assertTrue(any(v.id == custom.id for v in reg.views))
|
||||
self.assertTrue(any(v.id == custom.id for v in reg.children[0].views))
|
||||
|
||||
out = svc.delete_folder(self.db, region.id)
|
||||
self.assertTrue(out.get("deleted"))
|
||||
|
|
@ -489,13 +500,20 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
self.assertFalse(any(c.id == region.id for c in tree2.root.children))
|
||||
self.assertIsNone(self.db.get(TopoView, custom.id))
|
||||
self.assertEqual(
|
||||
self.db.query(TopoView).filter(TopoView.folder_id == region.id).count(),
|
||||
self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
|
||||
# Top-level「根」is nav-only; canvas work happens under unique「根图」.
|
||||
container = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Site-Group", kind="region")
|
||||
)
|
||||
parent = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Parent-Canvas", kind="region")
|
||||
self.db,
|
||||
TopologyFolderCreate(
|
||||
name="Parent-Canvas", kind="region", parent_id=container.id
|
||||
),
|
||||
)
|
||||
child = svc.create_folder(
|
||||
self.db,
|
||||
|
|
@ -503,7 +521,13 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
)
|
||||
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)
|
||||
group = next(c for c in tree.root.children if c.id == container.id)
|
||||
self.assertEqual(group.views, [])
|
||||
self.assertEqual(len(group.children), 1)
|
||||
root_map = group.children[0]
|
||||
self.assertEqual(root_map.name, "根图")
|
||||
# Creating under the nav「根」remounts onto「根图」.
|
||||
p = next(c for c in root_map.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"]
|
||||
|
|
@ -540,6 +564,50 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
graph2 = svc.get_view_graph(self.db, p.views[0].id)
|
||||
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
|
||||
|
||||
def test_top_level_stays_nav_when_adding_child(self) -> None:
|
||||
"""Creating under「根」remounts onto unique「根图」; top stays nav-only."""
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="East-Nav", kind="region")
|
||||
)
|
||||
child = svc.create_folder(
|
||||
self.db,
|
||||
TopologyFolderCreate(name="Site-A", kind="region", parent_id=top.id),
|
||||
)
|
||||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
east = next(c for c in tree.root.children if c.id == top.id)
|
||||
self.assertEqual(east.views, [])
|
||||
self.assertEqual(len(east.children), 1)
|
||||
root_map = east.children[0]
|
||||
self.assertEqual(root_map.name, "根图")
|
||||
self.assertEqual(len(root_map.views), 1)
|
||||
site = next(c for c in root_map.children if c.id == child.id)
|
||||
self.assertEqual(len(site.views), 1)
|
||||
self.assertEqual(site.views[0].name, "Site-A")
|
||||
self.assertNotEqual(site.views[0].name, "Physical topology")
|
||||
|
||||
def test_root_auto_spawns_unique_root_map(self) -> None:
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="West", kind="region")
|
||||
)
|
||||
# Second create under the same「根」must remount under existing「根图」.
|
||||
a = svc.create_folder(
|
||||
self.db,
|
||||
TopologyFolderCreate(name="A", kind="region", parent_id=top.id),
|
||||
)
|
||||
b = svc.create_folder(
|
||||
self.db,
|
||||
TopologyFolderCreate(name="B", kind="region", parent_id=top.id),
|
||||
)
|
||||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
west = next(c for c in tree.root.children if c.id == top.id)
|
||||
self.assertEqual(len(west.children), 1)
|
||||
root_map = west.children[0]
|
||||
self.assertEqual(root_map.name, "根图")
|
||||
child_ids = {c.id for c in root_map.children}
|
||||
self.assertEqual(child_ids, {a.id, b.id})
|
||||
|
||||
def test_classify_role_region_and_slices(self) -> None:
|
||||
from netx_api import topology_classify as clf
|
||||
from netx_api.topology_schemas import (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue