mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 06:53:19 +08:00
Ship gated eye polish, fabric levels, and collection UI refresh.
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>
This commit is contained in:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue