mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
5.5 KiB
Python
146 lines
5.5 KiB
Python
"""Tests for suggestSinkHubs batch ranking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from netx_topology_mcp.layout_ops.suggest_sink_hubs import (
|
|
pick_batch,
|
|
suggest_sink_hub_batches,
|
|
)
|
|
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
|
|
|
|
|
def _star_graph():
|
|
# CN portals + TJP-like agg hub with 3 EN stubs + small GENA hub with 1 stub
|
|
nodes = [
|
|
{"fabric_node_id": "cn1", "name": "X-CN1-Y", "level": 1, "x": 0, "y": 0},
|
|
{"fabric_node_id": "cn2", "name": "X-CN2-Y", "level": 1, "x": 100, "y": 0},
|
|
{"fabric_node_id": "tjp", "name": "X-TJP-AN1-Y", "level": 2, "x": 50, "y": 100},
|
|
{"fabric_node_id": "e1", "name": "X-E1-EN1-Y", "level": 3, "x": 0, "y": 200},
|
|
{"fabric_node_id": "e2", "name": "X-E2-EN1-Y", "level": 3, "x": 50, "y": 200},
|
|
{"fabric_node_id": "e3", "name": "X-E3-EN1-Y", "level": 3, "x": 100, "y": 200},
|
|
{"fabric_node_id": "gena", "name": "X-GENA-AN1-Y", "level": 2, "x": 200, "y": 100},
|
|
{"fabric_node_id": "e4", "name": "X-E4-EN1-Y", "level": 3, "x": 200, "y": 200},
|
|
]
|
|
edges = [
|
|
{"a_node_id": "cn1", "b_node_id": "cn2"},
|
|
{"a_node_id": "cn1", "b_node_id": "tjp"},
|
|
{"a_node_id": "cn2", "b_node_id": "tjp"},
|
|
{"a_node_id": "tjp", "b_node_id": "e1"},
|
|
{"a_node_id": "tjp", "b_node_id": "e2"},
|
|
{"a_node_id": "tjp", "b_node_id": "e3"},
|
|
{"a_node_id": "cn2", "b_node_id": "gena"},
|
|
{"a_node_id": "gena", "b_node_id": "e4"},
|
|
]
|
|
return nodes, edges
|
|
|
|
|
|
def test_suggest_ranks_largest_territory_first() -> None:
|
|
nodes, edges = _star_graph()
|
|
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
|
|
src = {n["fabric_node_id"] for n in nodes}
|
|
report = suggest_sink_hub_batches(
|
|
hubs=struct["hubs"],
|
|
soft_blocks=struct["soft_blocks"],
|
|
source_ids=src,
|
|
sink_ids=set(),
|
|
dual_units=struct.get("dual_units"),
|
|
min_territory=1,
|
|
top_n=5,
|
|
)
|
|
assert report["ok"] is True
|
|
assert report["batch_count"] >= 1
|
|
top = report["batches"][0]
|
|
# TJP should beat GENA; CN portals must not lead
|
|
assert top["hub_id"] == "tjp"
|
|
assert "cn1" not in top["fabric_node_ids"]
|
|
assert "cn2" not in top["fabric_node_ids"]
|
|
assert set(top["fabric_node_ids"]) >= {"tjp", "e1", "e2", "e3"}
|
|
|
|
|
|
def test_suggest_drops_ids_already_on_sink() -> None:
|
|
nodes, edges = _star_graph()
|
|
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
|
|
src = {n["fabric_node_id"] for n in nodes}
|
|
report = suggest_sink_hub_batches(
|
|
hubs=struct["hubs"],
|
|
soft_blocks=struct["soft_blocks"],
|
|
source_ids=src,
|
|
sink_ids={"e1", "e2"},
|
|
exclude_ids={"cn1", "cn2"},
|
|
min_territory=1,
|
|
top_n=5,
|
|
)
|
|
top = pick_batch(report, 1)
|
|
assert top is not None
|
|
assert top["hub_id"] == "tjp"
|
|
assert "e1" not in top["fabric_node_ids"]
|
|
assert "e2" not in top["fabric_node_ids"]
|
|
assert "e3" in top["fabric_node_ids"]
|
|
|
|
|
|
def test_suggest_skips_portal_as_hub_leader() -> None:
|
|
nodes, edges = _star_graph()
|
|
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
|
|
src = {n["fabric_node_id"] for n in nodes}
|
|
report = suggest_sink_hub_batches(
|
|
hubs=struct["hubs"],
|
|
soft_blocks=struct["soft_blocks"],
|
|
source_ids=src,
|
|
exclude_ids={"tjp"}, # force skip TJP as leader id
|
|
dual_units={"units": [{"portal_a": "cn1", "portal_b": "cn2"}]},
|
|
min_territory=1,
|
|
top_n=5,
|
|
)
|
|
hubs = [b["hub_id"] for b in report["batches"]]
|
|
assert "cn1" not in hubs and "cn2" not in hubs
|
|
# Excluded hub may still emit stub-only batch (hub id not in move list)
|
|
tjp_batch = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
|
|
assert tjp_batch is not None
|
|
assert "tjp" not in tjp_batch["fabric_node_ids"]
|
|
assert set(tjp_batch["fabric_node_ids"]) >= {"e1", "e2", "e3"}
|
|
|
|
|
|
def test_suggest_moves_stubs_under_sunk_hub() -> None:
|
|
nodes, edges = _star_graph()
|
|
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
|
|
src = {n["fabric_node_id"] for n in nodes}
|
|
report = suggest_sink_hub_batches(
|
|
hubs=struct["hubs"],
|
|
soft_blocks=struct["soft_blocks"],
|
|
source_ids=src,
|
|
sink_ids={"tjp", "e1", "e2"}, # hub already on sink
|
|
exclude_ids={"cn1", "cn2"},
|
|
min_territory=1,
|
|
top_n=5,
|
|
)
|
|
tjp = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
|
|
assert tjp is not None
|
|
assert tjp["already_on_sink"] is True
|
|
assert tjp["fabric_node_ids"] == ["e3"]
|
|
|
|
|
|
def test_suggest_orphan_leftovers_batch() -> None:
|
|
nodes, edges = _star_graph()
|
|
# Add disconnected orphans on source
|
|
nodes = list(nodes) + [
|
|
{"fabric_node_id": "iso1", "name": "X-ISO1-EN1-Y", "level": 3, "x": 900, "y": 900},
|
|
{"fabric_node_id": "iso2", "name": "X-ISO2-EN1-Y", "level": 3, "x": 950, "y": 950},
|
|
]
|
|
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
|
|
src = {n["fabric_node_id"] for n in nodes}
|
|
# Everything except orphans already on sink
|
|
sink = src - {"iso1", "iso2"}
|
|
report = suggest_sink_hub_batches(
|
|
hubs=struct["hubs"],
|
|
soft_blocks=struct["soft_blocks"],
|
|
source_ids=src,
|
|
sink_ids=sink,
|
|
exclude_ids={"cn1", "cn2"},
|
|
min_territory=0,
|
|
top_n=8,
|
|
)
|
|
assert report["orphan_n"] == 2
|
|
orphan = next((b for b in report["batches"] if b.get("orphan")), None)
|
|
assert orphan is not None
|
|
assert set(orphan["fabric_node_ids"]) == {"iso1", "iso2"}
|
|
assert orphan["block_method"] == "orphan_leftovers"
|