mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 23:13:26 +08:00
Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.
Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9bcb8ee0e3
commit
79f929de0c
86 changed files with 31578 additions and 240 deletions
137
packages/netx-topology-mcp/tests/test_channel_metro.py
Normal file
137
packages/netx-topology-mcp/tests/test_channel_metro.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Tests for channel_metro (channels + ring faces + recipe)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
|
||||
from netx_topology_mcp.layout_ops.channel_metro import build_channel_metro_skeleton
|
||||
from netx_topology_mcp.layout_ops.channels import extract_channels, place_channel_ray
|
||||
from netx_topology_mcp.layout_ops.ring_faces import place_ring_rectangle
|
||||
from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph
|
||||
|
||||
|
||||
def _core_bar_channels():
|
||||
"""Two cores, two ANs, each with a deg-2 corridor + one triangle ring."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c0", "b_node_id": "c1"},
|
||||
{"a_node_id": "c0", "b_node_id": "a0"},
|
||||
{"a_node_id": "c1", "b_node_id": "a1"},
|
||||
# corridor a0-e0-e1
|
||||
{"a_node_id": "a0", "b_node_id": "e0"},
|
||||
{"a_node_id": "e0", "b_node_id": "e1"},
|
||||
# corridor a1-e2-e3
|
||||
{"a_node_id": "a1", "b_node_id": "e2"},
|
||||
{"a_node_id": "e2", "b_node_id": "e3"},
|
||||
# triangle ring on a0 side
|
||||
{"a_node_id": "a0", "b_node_id": "e4"},
|
||||
{"a_node_id": "e4", "b_node_id": "e5"},
|
||||
{"a_node_id": "e5", "b_node_id": "a0"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_extract_channels() -> None:
|
||||
nodes, edges = _core_bar_channels()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
ch = extract_channels(st)
|
||||
assert len(ch) >= 1
|
||||
assert any(c.length >= 3 for c in ch)
|
||||
|
||||
|
||||
def test_place_channel_ray() -> None:
|
||||
placed = place_channel_ray(
|
||||
["h", "a", "b"],
|
||||
origin=(0.0, 0.0),
|
||||
ux=1.0,
|
||||
uy=0.0,
|
||||
step=100.0,
|
||||
pinned={"h"},
|
||||
)
|
||||
assert "h" not in placed
|
||||
assert placed["a"] == (100.0, 0.0)
|
||||
assert placed["b"] == (200.0, 0.0)
|
||||
|
||||
|
||||
def test_ring_rectangle() -> None:
|
||||
from netx_topology_mcp.layout_ops.ring_faces import RingFace
|
||||
|
||||
face = RingFace(("a", "b", "c", "d"))
|
||||
pos = place_ring_rectangle(face, center=(0, 0), width=200, height=100)
|
||||
assert set(pos) == {"a", "b", "c", "d"}
|
||||
|
||||
|
||||
def test_build_channel_metro_places_all() -> None:
|
||||
nodes, edges = _core_bar_channels()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = build_channel_metro_skeleton(st, LayoutParams())
|
||||
assert op.op == "build_channel_metro_skeleton"
|
||||
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
|
||||
assert op.state.meta.get("rings_mode") == "channel_metro"
|
||||
# Cores present; beam pin may or may not accept depending on crossings
|
||||
assert "c0" in op.state.positions and "c1" in op.state.positions
|
||||
|
||||
|
||||
def test_recipe_alias_and_run_unpublished() -> None:
|
||||
assert "channel_metro" not in RECIPE_ALIASES
|
||||
nodes, edges = _core_bar_channels()
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="layout", recipe="channel_metro")
|
||||
raise AssertionError("channel_metro should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_recipe" in str(e)
|
||||
|
||||
|
||||
def test_protect_rings_action_unpublished() -> None:
|
||||
nodes, edges = _core_bar_channels()
|
||||
for i, n in enumerate(nodes):
|
||||
n["x"] = float(i * 40)
|
||||
n["y"] = float((i % 3) * 30)
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="protect_rings")
|
||||
raise AssertionError("protect_rings should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_run_recipe_channel_metro_v1_unpublished() -> None:
|
||||
nodes, edges = _core_bar_channels()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
try:
|
||||
run_recipe(st, "channel_metro_v1", LayoutParams())
|
||||
raise AssertionError("channel_metro_v1 should be unpublished")
|
||||
except Exception as e:
|
||||
assert "channel" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
|
||||
|
||||
|
||||
def test_straighten_channels_action() -> None:
|
||||
nodes, edges = _core_bar_channels()
|
||||
# bent corridor a0-e0-e1
|
||||
coords = {
|
||||
"c0": (0, 0),
|
||||
"c1": (400, 0),
|
||||
"a0": (100, 200),
|
||||
"a1": (500, 200),
|
||||
"e0": (50, 400),
|
||||
"e1": (300, 450),
|
||||
"e2": (500, 400),
|
||||
"e3": (700, 420),
|
||||
"e4": (150, 300),
|
||||
"e5": (200, 250),
|
||||
}
|
||||
for n in nodes:
|
||||
x, y = coords[n["fabric_node_id"]]
|
||||
n["x"], n["y"] = x, y
|
||||
out = run_layout_on_graph(nodes, edges, action="straighten_channels")
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "straighten_channels"
|
||||
145
packages/netx-topology-mcp/tests/test_clear_edge_hits.py
Normal file
145
packages/netx-topology-mcp/tests/test_clear_edge_hits.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""edge_clearance metric + clear_edge_hits action."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
EDGE_CLEARANCE_THR,
|
||||
compute_edge_clearance,
|
||||
count_edge_crossings,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState
|
||||
from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components
|
||||
from netx_topology_mcp.layout_tool import run_layout_on_graph
|
||||
|
||||
|
||||
def _mksr_style() -> tuple[list[dict], list[dict]]:
|
||||
"""M1MJ—MEPL horizontal trunk with MKSR sitting on the segment; DALH stub."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
|
||||
{"fabric_node_id": "mksr", "name": "MKSR", "x": 200.0, "y": 6.0}, # on trunk
|
||||
{"fabric_node_id": "dalh", "name": "DALH", "x": 200.0, "y": 200.0},
|
||||
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "m1mj", "b_node_id": "mepl"},
|
||||
{"a_node_id": "mepl", "b_node_id": "bbsn"},
|
||||
{"a_node_id": "bbsn", "b_node_id": "mksr"},
|
||||
{"a_node_id": "mksr", "b_node_id": "dalh"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_collinear_midpoint_scores_edge_clearance_hit() -> None:
|
||||
nodes, edges = _mksr_style()
|
||||
m = analyze_layout_stats(nodes, edges)
|
||||
assert int(m["edge_clearance_hits"] or 0) >= 1
|
||||
assert float(m["edge_clearance_score"]) < 1.0
|
||||
assert "edge_clearance" in m["score"]["parts"]
|
||||
assert abs(m["score"]["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
|
||||
top = m.get("top_edge_hits") or []
|
||||
assert any(r.get("fabric_node_id") == "mksr" for r in top)
|
||||
|
||||
|
||||
def test_cleared_layout_has_zero_hits() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
|
||||
{"fabric_node_id": "mksr", "name": "MKSR", "x": 600.0, "y": 200.0},
|
||||
{"fabric_node_id": "dalh", "name": "DALH", "x": 600.0, "y": 400.0},
|
||||
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "m1mj", "b_node_id": "mepl"},
|
||||
{"a_node_id": "mepl", "b_node_id": "bbsn"},
|
||||
{"a_node_id": "bbsn", "b_node_id": "mksr"},
|
||||
{"a_node_id": "mksr", "b_node_id": "dalh"},
|
||||
]
|
||||
m = analyze_layout_stats(nodes, edges)
|
||||
assert int(m["edge_clearance_hits"] or 0) == 0
|
||||
assert float(m["edge_clearance_score"]) == 1.0
|
||||
assert m["report"]["edge_clearance"]["status"] == "ok"
|
||||
|
||||
|
||||
def test_clear_edge_hits_moves_mksr_off_trunk() -> None:
|
||||
nodes, edges = _mksr_style()
|
||||
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
names = {n["fabric_node_id"]: n["name"] for n in nodes}
|
||||
links = [("m1mj", "mepl"), ("mepl", "bbsn"), ("bbsn", "mksr"), ("mksr", "dalh")]
|
||||
st = LayoutState(positions=pos, names=names, links=links)
|
||||
x0 = count_edge_crossings(st.positions, st.links)
|
||||
clr0 = compute_edge_clearance(st.positions, st.links, thr=EDGE_CLEARANCE_THR)
|
||||
assert int(clr0["edge_clearance_hits"] or 0) >= 1
|
||||
|
||||
op = clear_edge_hits(st, LayoutParams(), thr=EDGE_CLEARANCE_THR, margin=20.0)
|
||||
assert "mksr" in op.moved or int(op.params.get("hits_after") or 0) < int(
|
||||
op.params.get("hits_before") or 0
|
||||
)
|
||||
x1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
assert x1 <= x0
|
||||
clr1 = compute_edge_clearance(op.state.positions, op.state.links, thr=EDGE_CLEARANCE_THR)
|
||||
assert int(clr1["edge_clearance_hits"] or 0) < int(clr0["edge_clearance_hits"] or 0)
|
||||
|
||||
|
||||
def test_run_layout_clear_edge_hits_apply_path() -> None:
|
||||
nodes, edges = _mksr_style()
|
||||
out = run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="clear_edge_hits",
|
||||
params={"thr": 40.0, "margin": 20.0, "top_n": 8},
|
||||
)
|
||||
assert out.get("ok") is not False
|
||||
assert out.get("action") == "clear_edge_hits"
|
||||
local = out.get("local") or {}
|
||||
note = str(local.get("note") or "")
|
||||
assert "clear_edge_hits" in note
|
||||
|
||||
|
||||
def test_score_weights_include_edge_clearance() -> None:
|
||||
s = score_layout_components(
|
||||
{
|
||||
"node_count": 40,
|
||||
"edge_crossings": 0,
|
||||
"crossings_per_link": 0.0,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.2,
|
||||
"hull_utilization": 0.25,
|
||||
"grid_occupancy": 0.4,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.2,
|
||||
"chain_score": 1.0,
|
||||
"rings_score": 1.0,
|
||||
"edge_clearance_score": 0.2,
|
||||
"edge_axis_score": 0.2,
|
||||
}
|
||||
)
|
||||
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
|
||||
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
|
||||
assert abs(s["weights"]["nn"] - 0.04) < 1e-9
|
||||
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
|
||||
good = score_layout_components(
|
||||
{
|
||||
"node_count": 40,
|
||||
"edge_crossings": 0,
|
||||
"crossings_per_link": 0.0,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.2,
|
||||
"hull_utilization": 0.25,
|
||||
"grid_occupancy": 0.4,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.2,
|
||||
"chain_score": 1.0,
|
||||
"rings_score": 1.0,
|
||||
"edge_clearance_score": 1.0,
|
||||
"edge_axis_score": 1.0,
|
||||
}
|
||||
)
|
||||
assert good["total"] > s["total"]
|
||||
129
packages/netx-topology-mcp/tests/test_compose_orbit.py
Normal file
129
packages/netx-topology-mcp/tests/test_compose_orbit.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Tests for incremental Prim+orbit compose_orbit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.compose_orbit import (
|
||||
compose_orbit_into_state,
|
||||
compose_orbit_params_from_overrides,
|
||||
crossings_touching,
|
||||
orbit_pack_blocks,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def test_actions_include_compose_orbit() -> None:
|
||||
assert "compose_orbit" not in ACTIONS
|
||||
|
||||
|
||||
def test_orbit_params_parse_knobs() -> None:
|
||||
knobs = compose_orbit_params_from_overrides(
|
||||
{
|
||||
"source_view_ids": ["a", "b"],
|
||||
"pad": 400,
|
||||
"angle_step": 45,
|
||||
"radii": [1.0, 1.5],
|
||||
"cand_cap": 40,
|
||||
}
|
||||
)
|
||||
assert knobs["pad"] == 400
|
||||
assert knobs["angle_step"] == 45
|
||||
assert knobs["radii"] == (1.0, 1.5)
|
||||
assert knobs["cand_cap"] == 40
|
||||
assert knobs["source_view_ids"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_orbit_pack_glues_shared_portal() -> None:
|
||||
unit_a = ComposeBlock(
|
||||
key="ua",
|
||||
positions={
|
||||
"p": (0.0, 0.0),
|
||||
"a1": (120.0, 0.0),
|
||||
"a2": (120.0, 80.0),
|
||||
},
|
||||
)
|
||||
unit_b = ComposeBlock(
|
||||
key="ub",
|
||||
positions={
|
||||
"p": (0.0, 0.0),
|
||||
"b1": (120.0, 0.0),
|
||||
"b2": (120.0, -80.0),
|
||||
},
|
||||
)
|
||||
links = [
|
||||
("p", "a1"),
|
||||
("a1", "a2"),
|
||||
("p", "b1"),
|
||||
("b1", "b2"),
|
||||
("a2", "b2"),
|
||||
]
|
||||
merged, meta = orbit_pack_blocks(
|
||||
[unit_a, unit_b],
|
||||
pad=200.0,
|
||||
links=links,
|
||||
fabric_bridges=True,
|
||||
angle_step=30,
|
||||
cand_cap=60,
|
||||
)
|
||||
assert meta["mode"] == "compose_orbit"
|
||||
assert meta["pack_mode"] == "orbit_attach"
|
||||
assert meta["slots"] == 2
|
||||
assert set(merged) == {"p", "a1", "a2", "b1", "b2"}
|
||||
# Shared portal is a single point (first-owner / align).
|
||||
assert "p" in merged
|
||||
via = meta.get("merged_via") or {}
|
||||
assert "orbit" in " ".join(str(v) for v in via.values()) or any(
|
||||
"shared" in str(v) or "orbit" in str(v) for v in via.values()
|
||||
)
|
||||
|
||||
|
||||
def test_crossings_touching_partial() -> None:
|
||||
# Two segments crossing; focus only on one endpoint set.
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (10.0, 10.0),
|
||||
"c": (0.0, 10.0),
|
||||
"d": (10.0, 0.0),
|
||||
}
|
||||
links = [("a", "b"), ("c", "d")]
|
||||
assert crossings_touching(pos, links, {"a", "b"}) == 1
|
||||
assert crossings_touching(pos, links, {"z"}) == 0
|
||||
|
||||
|
||||
def test_compose_orbit_into_state_and_run_layout() -> None:
|
||||
nodes = [
|
||||
{"id": "p", "x": 0, "y": 0},
|
||||
{"id": "a1", "x": 100, "y": 0},
|
||||
{"id": "b1", "x": 0, "y": 100},
|
||||
]
|
||||
edges = [{"source": "p", "target": "a1"}, {"source": "p", "target": "b1"}]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
blocks = [
|
||||
ComposeBlock(key="ua", positions={"p": (0.0, 0.0), "a1": (100.0, 0.0)}),
|
||||
ComposeBlock(key="ub", positions={"p": (0.0, 0.0), "b1": (100.0, 0.0)}),
|
||||
]
|
||||
op = compose_orbit_into_state(st, blocks, LayoutParams(), pad=150.0)
|
||||
assert op.op == "compose_orbit"
|
||||
assert "compose_views" in (op.state.meta or {})
|
||||
assert (op.state.meta.get("compose_views") or {}).get("mode") == "compose_orbit"
|
||||
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="compose_orbit",
|
||||
params={
|
||||
"_compose_blocks": [
|
||||
{"key": "ua", "positions": {"p": [0, 0], "a1": [100, 0]}},
|
||||
{"key": "ub", "positions": {"p": [0, 0], "b1": [100, 0]}},
|
||||
],
|
||||
"rigid_polish": False,
|
||||
"soft_polish": False,
|
||||
"pad": 150,
|
||||
"angle_step": 45,
|
||||
},
|
||||
)
|
||||
raise AssertionError("compose_orbit should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
325
packages/netx-topology-mcp/tests/test_compose_views.py
Normal file
325
packages/netx-topology-mcp/tests/test_compose_views.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"""Tests for world-map strip-pack compose_views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.compose_views import (
|
||||
ComposeBlock,
|
||||
compose_into_state,
|
||||
compose_params_from_overrides,
|
||||
strip_pack_blocks,
|
||||
)
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def test_actions_include_compose_views() -> None:
|
||||
assert "compose_views" not in ACTIONS
|
||||
|
||||
|
||||
def test_strip_pack_preserves_relative_geometry() -> None:
|
||||
a = ComposeBlock(
|
||||
key="va",
|
||||
positions={
|
||||
"a1": (10.0, 20.0),
|
||||
"a2": (110.0, 20.0),
|
||||
"a3": (10.0, 120.0),
|
||||
},
|
||||
)
|
||||
b = ComposeBlock(
|
||||
key="vb",
|
||||
positions={
|
||||
"b1": (0.0, 0.0),
|
||||
"b2": (50.0, 0.0),
|
||||
},
|
||||
)
|
||||
merged, meta = strip_pack_blocks([a, b], pad=100.0)
|
||||
assert meta["slots"] == 2
|
||||
assert set(merged) == {"a1", "a2", "a3", "b1", "b2"}
|
||||
# Relative offsets inside block A preserved.
|
||||
dx = merged["a2"][0] - merged["a1"][0]
|
||||
dy = merged["a2"][1] - merged["a1"][1]
|
||||
assert abs(dx - 100.0) < 1e-6
|
||||
assert abs(dy) < 1e-6
|
||||
# Blocks occupy distinct slots (centroids far apart vs pad).
|
||||
ax = sum(merged[n][0] for n in ("a1", "a2", "a3")) / 3
|
||||
bx = sum(merged[n][0] for n in ("b1", "b2")) / 2
|
||||
ay = sum(merged[n][1] for n in ("a1", "a2", "a3")) / 3
|
||||
by = sum(merged[n][1] for n in ("b1", "b2")) / 2
|
||||
assert abs(ax - bx) > 50 or abs(ay - by) > 50
|
||||
|
||||
|
||||
def test_strip_pack_first_owner_wins_duplicates() -> None:
|
||||
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
|
||||
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
|
||||
# Portal-grow still glues the later shared block rigidly.
|
||||
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
|
||||
assert "shared" in merged
|
||||
assert "a" in merged and "b" in merged
|
||||
via = meta.get("merged_via") or {}
|
||||
assert "rigid_shared" in via.values()
|
||||
assert meta.get("order_mode") == "portal_grow"
|
||||
|
||||
|
||||
def test_portal_grow_attaches_shared_before_orphan_misc() -> None:
|
||||
"""Connected dual units grow together; huge misc without shared packs last."""
|
||||
unit_ab = ComposeBlock(
|
||||
key="ab",
|
||||
positions={
|
||||
"p_a": (0.0, 0.0),
|
||||
"p_b": (200.0, 0.0),
|
||||
"mid": (100.0, 40.0),
|
||||
},
|
||||
)
|
||||
unit_bc = ComposeBlock(
|
||||
key="bc",
|
||||
positions={
|
||||
"p_b": (0.0, 0.0),
|
||||
"p_c": (200.0, 0.0),
|
||||
"x1": (50.0, 30.0),
|
||||
"x2": (100.0, 30.0),
|
||||
"x3": (150.0, 30.0),
|
||||
"x4": (80.0, -30.0),
|
||||
},
|
||||
)
|
||||
misc = ComposeBlock(
|
||||
key="misc",
|
||||
positions={f"m{i}": (float(i) * 40.0, float(i % 3) * 40.0) for i in range(24)},
|
||||
)
|
||||
merged, meta = strip_pack_blocks(
|
||||
[misc, unit_ab, unit_bc], pad=80.0, merge_shared=True
|
||||
)
|
||||
order = meta.get("order") or []
|
||||
assert meta.get("order_mode") == "portal_grow"
|
||||
# Misc is largest area but has no shared glue → last.
|
||||
assert order[-1] == "misc"
|
||||
# ab and bc share p_b → one of them merges rigidly onto the other.
|
||||
via = meta.get("merged_via") or {}
|
||||
assert via.get("ab") == "rigid_shared" or via.get("bc") == "rigid_shared"
|
||||
# Shared portal has a single world coord.
|
||||
assert "p_b" in merged
|
||||
assert abs(merged["p_b"][0] - merged["p_b"][0]) < 1e-9
|
||||
|
||||
|
||||
def test_portal_centroid_keeps_shared_units_near() -> None:
|
||||
"""Units that share portals get closer origins than unrelated orphans."""
|
||||
# Chain A—B—C via portals; orphan D has no shared nodes.
|
||||
unit_a = ComposeBlock(
|
||||
key="ua",
|
||||
positions={
|
||||
"p_ab": (0.0, 0.0),
|
||||
"a1": (100.0, 0.0),
|
||||
"a2": (100.0, 80.0),
|
||||
},
|
||||
)
|
||||
unit_b = ComposeBlock(
|
||||
key="ub",
|
||||
positions={
|
||||
"p_ab": (0.0, 0.0),
|
||||
"p_bc": (200.0, 0.0),
|
||||
"b1": (100.0, 40.0),
|
||||
},
|
||||
)
|
||||
unit_c = ComposeBlock(
|
||||
key="uc",
|
||||
positions={
|
||||
"p_bc": (0.0, 0.0),
|
||||
"c1": (100.0, 0.0),
|
||||
"c2": (100.0, 60.0),
|
||||
},
|
||||
)
|
||||
orphan = ComposeBlock(
|
||||
key="orphan",
|
||||
positions={f"o{i}": (float(i) * 30.0, 0.0) for i in range(8)},
|
||||
)
|
||||
merged, meta = strip_pack_blocks(
|
||||
[orphan, unit_c, unit_a, unit_b], pad=80.0, merge_shared=True
|
||||
)
|
||||
assert meta.get("pack_mode") == "portal_centroid"
|
||||
origins = meta["origins"]
|
||||
|
||||
def _slot_c(key: str) -> tuple[float, float]:
|
||||
sm = meta["slot_meta"][key]
|
||||
ox, oy = origins[key]
|
||||
return ox + 0.5 * sm["w"], oy + 0.5 * sm["h"]
|
||||
|
||||
ca, cb, cc, co = _slot_c("ua"), _slot_c("ub"), _slot_c("uc"), _slot_c("orphan")
|
||||
# Portal-graph neighbors closer than the A–C skip.
|
||||
d_ab = math.hypot(ca[0] - cb[0], ca[1] - cb[1])
|
||||
d_bc = math.hypot(cb[0] - cc[0], cb[1] - cc[1])
|
||||
d_ac = math.hypot(ca[0] - cc[0], ca[1] - cc[1])
|
||||
assert d_ab < d_ac
|
||||
assert d_bc < d_ac
|
||||
# Orphans sit below the glued cluster.
|
||||
glued_ymax = max(
|
||||
origins[k][1] + meta["slot_meta"][k]["h"] for k in ("ua", "ub", "uc")
|
||||
)
|
||||
assert origins["orphan"][1] >= glued_ymax - 1e-6
|
||||
# Rigid merge still glues the chain.
|
||||
via = meta.get("merged_via") or {}
|
||||
assert "rigid_shared" in via.values()
|
||||
assert "p_ab" in merged and "p_bc" in merged
|
||||
|
||||
|
||||
def test_fabric_bridges_pull_disjoint_units_closer() -> None:
|
||||
"""No shared portal: fabric edge + higher bridge_boost / lower ideal_scale."""
|
||||
unit_a = ComposeBlock(
|
||||
key="ua",
|
||||
positions={
|
||||
"a0": (0.0, 0.0),
|
||||
"a1": (120.0, 0.0),
|
||||
"a2": (60.0, 80.0),
|
||||
},
|
||||
)
|
||||
unit_b = ComposeBlock(
|
||||
key="ub",
|
||||
positions={
|
||||
"b0": (0.0, 0.0),
|
||||
"b1": (120.0, 0.0),
|
||||
"b2": (60.0, 80.0),
|
||||
},
|
||||
)
|
||||
unit_c = ComposeBlock(
|
||||
key="uc",
|
||||
positions={
|
||||
"c0": (0.0, 0.0),
|
||||
"c1": (120.0, 0.0),
|
||||
"c2": (60.0, 80.0),
|
||||
},
|
||||
)
|
||||
# Only A↔B has a fabric spoke; C is an unrelated peer (orphan strip below).
|
||||
links = [("a1", "b0")]
|
||||
blocks = [unit_a, unit_b, unit_c]
|
||||
|
||||
def _origin_gap(meta: dict, ka: str, kb: str) -> float:
|
||||
oa, ob = meta["origins"][ka], meta["origins"][kb]
|
||||
return math.hypot(oa[0] - ob[0], oa[1] - ob[1])
|
||||
|
||||
loose, meta_loose = strip_pack_blocks(
|
||||
blocks,
|
||||
pad=200.0,
|
||||
merge_shared=True,
|
||||
links=links,
|
||||
fabric_bridges=True,
|
||||
bridge_boost=4.0,
|
||||
ideal_scale=0.85,
|
||||
)
|
||||
tight, meta_tight = strip_pack_blocks(
|
||||
blocks,
|
||||
pad=200.0,
|
||||
merge_shared=True,
|
||||
links=links,
|
||||
fabric_bridges=True,
|
||||
bridge_boost=40.0,
|
||||
ideal_scale=0.3,
|
||||
)
|
||||
off, meta_off = strip_pack_blocks(
|
||||
blocks, pad=200.0, merge_shared=True, links=links, fabric_bridges=False
|
||||
)
|
||||
assert meta_off.get("fabric_bridges") is False
|
||||
assert meta_tight.get("fabric_bridges") is True
|
||||
assert meta_tight.get("bridge_boost") == 40.0
|
||||
assert meta_tight.get("ideal_scale") == 0.3
|
||||
# Tunable spring: denser knobs shrink A–B slot gap.
|
||||
assert _origin_gap(meta_tight, "ua", "ub") < _origin_gap(meta_loose, "ua", "ub") * 0.75
|
||||
# Fabric glue puts C below the A–B component (not interleaved in strip).
|
||||
glued_ymax = max(
|
||||
meta_tight["origins"][k][1] + meta_tight["slot_meta"][k]["h"]
|
||||
for k in ("ua", "ub")
|
||||
)
|
||||
assert meta_tight["origins"]["uc"][1] >= glued_ymax - 1e-6
|
||||
del loose, tight, off
|
||||
|
||||
|
||||
def test_compose_params_from_overrides_knobs() -> None:
|
||||
knobs = compose_params_from_overrides(
|
||||
{
|
||||
"pad": 400,
|
||||
"fabric_bridges": "on",
|
||||
"bridge_boost": 12,
|
||||
"ideal_scale": 0.4,
|
||||
"spring_iters": 100,
|
||||
"merge_shared": False,
|
||||
}
|
||||
)
|
||||
assert knobs["pad"] == 400.0
|
||||
assert knobs["fabric_bridges"] is True
|
||||
assert knobs["bridge_boost"] == 12.0
|
||||
assert knobs["ideal_scale"] == 0.4
|
||||
assert knobs["spring_iters"] == 100
|
||||
assert knobs["merge_shared"] is False
|
||||
|
||||
|
||||
def test_dual_portal_flip_shortens_external_bridge() -> None:
|
||||
"""When two portals fix the chord, pick the bank that shortens fabric spokes."""
|
||||
unit_a = ComposeBlock(
|
||||
key="ua",
|
||||
positions={
|
||||
"p0": (0.0, 0.0),
|
||||
"p1": (200.0, 0.0),
|
||||
"t": (100.0, -160.0),
|
||||
},
|
||||
)
|
||||
unit_b = ComposeBlock(
|
||||
key="ub",
|
||||
positions={
|
||||
"p0": (0.0, 0.0),
|
||||
"p1": (200.0, 0.0),
|
||||
"b": (100.0, 120.0),
|
||||
},
|
||||
)
|
||||
links = [("b", "t")]
|
||||
merged, _meta = strip_pack_blocks(
|
||||
[unit_a, unit_b], pad=40.0, merge_shared=True, links=links
|
||||
)
|
||||
db = math.hypot(merged["b"][0] - merged["t"][0], merged["b"][1] - merged["t"][1])
|
||||
# Unflipped bank would put b roughly opposite t (~280); flipped ~40.
|
||||
assert db < 120.0
|
||||
|
||||
|
||||
def test_strip_pack_merge_shared_false_keeps_first_only() -> None:
|
||||
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
|
||||
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
|
||||
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=False)
|
||||
assert meta.get("merge_shared") is False
|
||||
assert "shared" in merged and "b" in merged
|
||||
|
||||
|
||||
def test_compose_into_state_and_run_layout() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "A2", "x": 10, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "x": 10, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
blocks = [
|
||||
ComposeBlock("va", {"a1": (0.0, 0.0), "a2": (100.0, 0.0)}),
|
||||
ComposeBlock("vb", {"b1": (0.0, 0.0), "b2": (80.0, 0.0)}),
|
||||
]
|
||||
op = compose_into_state(st, blocks, LayoutParams(), pad=200.0)
|
||||
assert op.op == "compose_views"
|
||||
assert op.params.get("slots") == 2
|
||||
assert abs(op.state.positions["a2"][0] - op.state.positions["a1"][0] - 100.0) < 1e-6
|
||||
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="compose_views",
|
||||
params={
|
||||
"_compose_blocks": [
|
||||
{"key": "va", "positions": {"a1": [0, 0], "a2": [100, 0]}},
|
||||
{"key": "vb", "positions": {"b1": [0, 0], "b2": [80, 0]}},
|
||||
],
|
||||
"pad": 200,
|
||||
},
|
||||
)
|
||||
raise AssertionError("compose_views should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
76
packages/netx-topology-mcp/tests/test_core_beam.py
Normal file
76
packages/netx-topology-mcp/tests/test_core_beam.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""core_bar beam-first placement in sugiyama._place_component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.sugiyama import _place_component
|
||||
|
||||
|
||||
def _dual_core_multi_an():
|
||||
"""Two cores on a bar, two ANs each with a small access petal."""
|
||||
# c0 -- a0 -- e0
|
||||
# | |
|
||||
# c1 -- a1 -- e1
|
||||
nodes = [
|
||||
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c0", "b_node_id": "c1"},
|
||||
{"a_node_id": "c0", "b_node_id": "a0"},
|
||||
{"a_node_id": "c1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a0", "b_node_id": "e0"},
|
||||
{"a_node_id": "a0", "b_node_id": "e2"},
|
||||
{"a_node_id": "a1", "b_node_id": "e1"},
|
||||
{"a_node_id": "a1", "b_node_id": "e3"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_place_component_core_beam_collinear_cores() -> None:
|
||||
nodes, edges = _dual_core_multi_an()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
assert st.layers["c0"] == "core" and st.layers["c1"] == "core"
|
||||
assert st.layers["a0"] == "agg" and st.layers["a1"] == "agg"
|
||||
ids = [n["fabric_node_id"] for n in nodes]
|
||||
out = _place_component(ids, st, LayoutParams())
|
||||
assert set(out) == set(ids)
|
||||
# Cores approximately collinear on a horizontal beam
|
||||
y0, y1 = out["c0"][1], out["c1"][1]
|
||||
assert abs(y0 - y1) < 1.0
|
||||
assert abs(out["c0"][0] - out["c1"][0]) > 100.0
|
||||
# ANs hang off the beam (not packed into a core column): mean AN y away from beam
|
||||
beam_y = (y0 + y1) / 2
|
||||
an_y = (out["a0"][1] + out["a1"][1]) / 2
|
||||
assert abs(an_y - beam_y) > 80.0
|
||||
# Beam-first: both cores share one y; ANs are not co-located with cores as a column pack
|
||||
assert abs(out["a0"][1] - beam_y) > 40.0
|
||||
assert abs(out["a1"][1] - beam_y) > 40.0
|
||||
|
||||
|
||||
def test_place_component_single_core_still_packs() -> None:
|
||||
"""One core keeps the legacy AN-column pack path (no beam branch)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c0", "b_node_id": "a0"},
|
||||
{"a_node_id": "c0", "b_node_id": "a1"},
|
||||
{"a_node_id": "a0", "b_node_id": "e0"},
|
||||
{"a_node_id": "a1", "b_node_id": "e1"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
ids = [n["fabric_node_id"] for n in nodes]
|
||||
out = _place_component(ids, st, LayoutParams())
|
||||
assert set(out) == set(ids)
|
||||
assert "c0" in out
|
||||
219
packages/netx-topology-mcp/tests/test_densify_sweep.py
Normal file
219
packages/netx-topology-mcp/tests/test_densify_sweep.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""Tests for inward densify_sweep top-3 / round / corridor scan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.densify_sweep import (
|
||||
apply_densify_pick,
|
||||
densify_corridor_scan,
|
||||
densify_sweep_node,
|
||||
densify_sweep_round,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import _incident_stretch
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
|
||||
|
||||
|
||||
def _sparse_spoke():
|
||||
"""Hub + three near stubs + one far spoke (stretchy)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "HHHHHH-EN-H", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-A", "x": 180.0, "y": 0.0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-B", "x": 0.0, "y": 180.0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-C", "x": -180.0, "y": 0.0},
|
||||
{"fabric_node_id": "far", "name": "FFFFFF-EN-F", "x": 2400.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
{"a_node_id": "h", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "far"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _two_island_bridge():
|
||||
"""Two compact islands linked by a long bridge — corridor densify target."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "A1A1A1-EN-1", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a2", "name": "A2A2A2-EN-2", "x": 160.0, "y": 0.0},
|
||||
{"fabric_node_id": "a3", "name": "A3A3A3-EN-3", "x": 80.0, "y": 140.0},
|
||||
{"fabric_node_id": "b1", "name": "B1B1B1-EN-1", "x": 4000.0, "y": 0.0},
|
||||
{"fabric_node_id": "b2", "name": "B2B2B2-EN-2", "x": 4160.0, "y": 0.0},
|
||||
{"fabric_node_id": "b3", "name": "B3B3B3-EN-3", "x": 4080.0, "y": 140.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "a3"},
|
||||
{"a_node_id": "a3", "b_node_id": "a1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "b3"},
|
||||
{"a_node_id": "b3", "b_node_id": "b1"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_densify_sweep_node_pulls_inward() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
out = densify_sweep_node(
|
||||
st,
|
||||
"far",
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=40.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
cands = out["candidates"]
|
||||
assert 1 <= len(cands) <= 3
|
||||
best = cands[0]
|
||||
# Moved toward hub (x decreases).
|
||||
assert best["x"] < 2400.0 - 50.0
|
||||
assert best["crossings"]["global"] <= g0
|
||||
assert best["delta"]["global"] <= 0
|
||||
# Closer to hub than start.
|
||||
d0 = 2400.0
|
||||
d1 = abs(best["x"] - 0.0) # hub at 0
|
||||
assert d1 < d0
|
||||
|
||||
|
||||
def test_densify_nn_floor_rejects_crush() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
# Huge nn_floor should leave few/no candidates (can't get near hub).
|
||||
out = densify_sweep_node(
|
||||
st,
|
||||
"far",
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=500.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
for c in out["candidates"]:
|
||||
# Any accepted candidate must keep nn to all others ≥ floor.
|
||||
trial = dict(st.positions)
|
||||
trial["far"] = (c["x"], c["y"])
|
||||
for oid, (ox, oy) in trial.items():
|
||||
if oid == "far":
|
||||
continue
|
||||
d = ((c["x"] - ox) ** 2 + (c["y"] - oy) ** 2) ** 0.5
|
||||
assert d >= 500.0 - 1e-3
|
||||
|
||||
|
||||
def test_apply_densify_pick_moves() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
sweep = densify_sweep_node(st, "far", protect_rigid="off", max_pull=1800, nn_floor=40)
|
||||
assert sweep["ok"] and sweep["candidates"]
|
||||
op = apply_densify_pick(st, sweep, pick=1)
|
||||
assert "far" in op.moved
|
||||
chosen = sweep["candidates"][0]
|
||||
assert abs(op.state.positions["far"][0] - chosen["x"]) < 0.2
|
||||
|
||||
|
||||
def test_densify_round_lowers_stretch_not_x() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
params = LayoutParams(target_nn=155.0)
|
||||
stretch0 = _incident_stretch("far", st.positions, st.adj, 155.0)
|
||||
op = densify_sweep_round(
|
||||
st,
|
||||
params=params,
|
||||
top_n=4,
|
||||
max_degree=9,
|
||||
protect_rigid="off",
|
||||
max_pull=2000,
|
||||
nn_floor=40.0,
|
||||
focus_ids=["far"],
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
assert g1 <= g0
|
||||
stretch1 = _incident_stretch("far", op.state.positions, op.state.adj, 155.0)
|
||||
if op.moved:
|
||||
assert stretch1 < stretch0
|
||||
|
||||
|
||||
def test_corridor_scan_raises_util_or_reverts_clean() -> None:
|
||||
nodes, edges = _two_island_bridge()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "ua", "node_ids": ["a1", "a2", "a3"], "pivots": []},
|
||||
{"key": "ub", "node_ids": ["b1", "b2", "b3"], "pivots": []},
|
||||
]
|
||||
st.meta["compose_views"] = {"rigid_groups": groups}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
op = densify_corridor_scan(
|
||||
st,
|
||||
groups=groups,
|
||||
corridor_caps=[800.0, 1600.0, 3200.0],
|
||||
pulls=[0.5, 0.65],
|
||||
iters=4,
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
meta = op.params or {}
|
||||
assert g1 <= g0 + int(meta.get("x_slack") or 5)
|
||||
if not meta.get("reverted"):
|
||||
assert float(meta.get("end_util") or 0) >= float(meta.get("start_util") or 0)
|
||||
assert meta.get("chosen")
|
||||
|
||||
|
||||
def test_run_layout_densify_unpublished() -> None:
|
||||
nodes, edges = _sparse_spoke()
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="densify_sweep",
|
||||
params={"node_id": "far", "protect_rigid": "off", "max_pull": 1800, "nn_floor": 40},
|
||||
)
|
||||
raise AssertionError("densify_sweep should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_catalog_omits_densify_sweep() -> None:
|
||||
cat = list_layout_catalog()
|
||||
assert "densify_sweep" not in cat["actions"]
|
||||
|
||||
|
||||
def test_densify_default_protect_off_may_move_shared_portal() -> None:
|
||||
from netx_topology_mcp.layout_ops.densify_sweep import densify_params_from_overrides
|
||||
|
||||
knobs = densify_params_from_overrides({"node_id": "hub"})
|
||||
assert knobs["protect_rigid"] == "off"
|
||||
nodes = [
|
||||
{"fabric_node_id": "hub", "name": "HUBHUB-EN-1", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "a1", "name": "AAAAAA-EN-1", "x": 200.0, "y": 0.0},
|
||||
{"fabric_node_id": "b1", "name": "BBBBBB-EN-1", "x": 2000.0, "y": 0.0},
|
||||
{"fabric_node_id": "b2", "name": "BBBBBB-EN-2", "x": 2200.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "hub", "b_node_id": "a1"},
|
||||
{"a_node_id": "hub", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "ua", "node_ids": ["hub", "a1"], "pivots": ["hub"]},
|
||||
{"key": "ub", "node_ids": ["hub", "b1", "b2"], "pivots": ["hub"]},
|
||||
]
|
||||
st.meta["compose_views"] = {"rigid_groups": groups}
|
||||
# Default off: shared portal may densify (not shared_portal error).
|
||||
ok = densify_sweep_node(st, "hub", groups=groups, max_pull=1800, nn_floor=40)
|
||||
assert ok["ok"] is True
|
||||
# Opt-in freeze still works.
|
||||
blocked = densify_sweep_node(
|
||||
st, "hub", protect_rigid="portals", groups=groups, frozen_ids={"hub"}
|
||||
)
|
||||
assert blocked["ok"] is False
|
||||
assert blocked.get("error") in {"frozen", "shared_portal"}
|
||||
169
packages/netx-topology-mcp/tests/test_dual_mass.py
Normal file
169
packages/netx-topology-mcp/tests/test_dual_mass.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Same-canvas dual_mass: petal/straight beautify in place."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges, run_recipe
|
||||
from netx_topology_mcp.layout_ops.dual_mass import layout_dual_mass
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
DualUnit,
|
||||
beautify_dual_unit_positions,
|
||||
classify_dual_unit,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, RECIPE_ALIASES, run_layout_on_graph
|
||||
|
||||
|
||||
def _two_eye_graph():
|
||||
"""Two dual-portal petals sharing portal p2 + one leftover."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "p1", "name": "AN-P1", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "p2", "name": "AN-P2", "role": "an", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "p3", "name": "AN-P3", "role": "an", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "EN-A1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "EN-A2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "EN-B1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "EN-B2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "EN-C1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c2", "name": "EN-C2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "d1", "name": "EN-D1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "d2", "name": "EN-D2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "z1", "name": "EN-Z1", "role": "en", "x": 999, "y": 999},
|
||||
]
|
||||
edges = [
|
||||
# eye p1–p2
|
||||
{"a_node_id": "p1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "p2"},
|
||||
# eye p2–p3
|
||||
{"a_node_id": "p2", "b_node_id": "c1"},
|
||||
{"a_node_id": "c1", "b_node_id": "c2"},
|
||||
{"a_node_id": "c2", "b_node_id": "p3"},
|
||||
{"a_node_id": "p2", "b_node_id": "d1"},
|
||||
{"a_node_id": "d1", "b_node_id": "d2"},
|
||||
{"a_node_id": "d2", "b_node_id": "p3"},
|
||||
# leftover hanging off p3
|
||||
{"a_node_id": "p3", "b_node_id": "z1"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_recipe_alias_dual_mass() -> None:
|
||||
# dual_mass kept as module; unpublished from public recipe/action surface.
|
||||
assert "dual_mass" not in RECIPE_ALIASES
|
||||
assert "dual_mass" not in ACTIONS
|
||||
|
||||
|
||||
def test_classify_petal_and_straight() -> None:
|
||||
petal = DualUnit(
|
||||
portal_a="p1",
|
||||
portal_b="p2",
|
||||
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
|
||||
)
|
||||
assert classify_dual_unit(petal) == "petal"
|
||||
chain = DualUnit(
|
||||
portal_a="p1",
|
||||
portal_b="p2",
|
||||
paths=[["p1"] + [f"n{i}" for i in range(10)] + ["p2"]],
|
||||
)
|
||||
assert classify_dual_unit(chain) == "straight"
|
||||
short = DualUnit(
|
||||
portal_a="p1",
|
||||
portal_b="p2",
|
||||
paths=[["p1", "a1", "a2", "p2"]],
|
||||
)
|
||||
assert classify_dual_unit(short) == "straight"
|
||||
|
||||
|
||||
def test_beautify_parallel_lanes() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
unit = DualUnit(
|
||||
portal_a="p1",
|
||||
portal_b="p2",
|
||||
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
|
||||
)
|
||||
pos = beautify_dual_unit_positions(st, unit, LayoutParams())
|
||||
# Parallel H/V lanes: same y in a corridor; first/last share portal x.
|
||||
assert abs(pos["a1"][1] - pos["a2"][1]) < 1e-6
|
||||
assert abs(pos["b1"][1] - pos["b2"][1]) < 1e-6
|
||||
assert abs(pos["a1"][1]) > 1.0 and abs(pos["b1"][1]) > 1.0
|
||||
assert pos["a1"][1] * pos["b1"][1] < 0
|
||||
assert abs(pos["a1"][0] - pos["p1"][0]) < 1e-6
|
||||
assert abs(pos["a2"][0] - pos["p2"][0]) < 1e-6
|
||||
assert abs(pos["b1"][0] - pos["p1"][0]) < 1e-6
|
||||
assert abs(pos["b2"][0] - pos["p2"][0]) < 1e-6
|
||||
|
||||
|
||||
def test_dual_mass_refine_reports_drift() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
# Seed a spread layout first (optional repark).
|
||||
op1 = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
|
||||
op2 = layout_dual_mass(
|
||||
op1.state, LayoutParams(), mode="refine", rounds=2, stable_drift=1e9
|
||||
)
|
||||
assert op2.params.get("mode_first") == "refine"
|
||||
assert int(op2.params.get("rounds_ran") or 0) >= 1
|
||||
assert isinstance(op2.params.get("centroid_drift"), list)
|
||||
kinds = op2.params.get("kinds") or {}
|
||||
assert int(kinds.get("petal") or 0) >= 1
|
||||
|
||||
|
||||
def test_layout_dual_mass_beautify_and_meta() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_mass(st, LayoutParams(), mode="full")
|
||||
assert op.op == "layout_dual_mass"
|
||||
assert int(op.params.get("units_n") or 0) >= 1
|
||||
assert op.state.meta.get("dual_mass")
|
||||
cv = op.state.meta.get("compose_views") or {}
|
||||
assert cv.get("mass_groups")
|
||||
assert op.state.meta.get("mass_field")
|
||||
assert op.params.get("role") == "beautify"
|
||||
|
||||
p1 = op.state.positions["p1"]
|
||||
p2 = op.state.positions["p2"]
|
||||
dist = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
|
||||
assert dist >= 80.0
|
||||
assert int(op.params.get("unit_ok") or 0) >= 1
|
||||
|
||||
|
||||
def test_run_recipe_dual_mass_v1_unpublished() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
try:
|
||||
run_recipe(st, "dual_mass_v1", LayoutParams())
|
||||
raise AssertionError("dual_mass_v1 should be unpublished from RECIPES")
|
||||
except Exception as e:
|
||||
assert "dual_mass" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
|
||||
|
||||
|
||||
def test_run_layout_on_graph_dual_mass_unpublished() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="layout", recipe="dual_mass")
|
||||
raise AssertionError("dual_mass recipe should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_recipe" in str(e)
|
||||
|
||||
|
||||
def test_layout_dual_mass_module_still_works() -> None:
|
||||
nodes, edges = _two_eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
|
||||
assert op.op == "layout_dual_mass"
|
||||
pos = op.state.positions
|
||||
assert "p1" in pos and "p2" in pos
|
||||
members = {"p1", "p2", "a1", "a2", "b1", "b2"}
|
||||
links = [
|
||||
(e["a_node_id"], e["b_node_id"])
|
||||
for e in edges
|
||||
if e["a_node_id"] in members and e["b_node_id"] in members
|
||||
]
|
||||
assert count_edge_crossings({k: pos[k] for k in members}, links) == 0
|
||||
121
packages/netx-topology-mcp/tests/test_dual_units.py
Normal file
121
packages/netx-topology-mcp/tests/test_dual_units.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Dual-portal eye units: detect, zero-cross layout, shared-portal compose."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, strip_pack_blocks
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
find_dual_portal_units,
|
||||
layout_dual_unit,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def _eye_graph():
|
||||
"""Two AN portals + three interior-disjoint access corridors."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t1", "name": "BTM-EN-T1", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "c1"},
|
||||
{"a_node_id": "c1", "b_node_id": "c2"},
|
||||
{"a_node_id": "c2", "b_node_id": "p2"},
|
||||
{"a_node_id": "a1", "b_node_id": "t1"}, # tail off corridor
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_actions_include_layout_dual_unit() -> None:
|
||||
assert "layout_dual_unit" in ACTIONS
|
||||
|
||||
|
||||
def test_find_dual_portal_units_eye() -> None:
|
||||
nodes, edges = _eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
units = find_dual_portal_units(st)
|
||||
assert len(units) >= 1
|
||||
u = units[0]
|
||||
assert {u.portal_a, u.portal_b} == {"p1", "p2"}
|
||||
assert len(u.paths) >= 2
|
||||
interiors = set()
|
||||
for p in u.paths:
|
||||
interiors |= set(p[1:-1])
|
||||
assert "a1" in interiors or "b1" in interiors or "c1" in interiors
|
||||
|
||||
|
||||
def test_layout_dual_unit_zero_crossings() -> None:
|
||||
nodes, edges = _eye_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = layout_dual_unit(st, LayoutParams())
|
||||
assert op.params.get("accepted") is True
|
||||
members = set()
|
||||
unit = op.params.get("unit") or {}
|
||||
members.update(unit.get("node_ids") or [])
|
||||
unit_links = [e for e in op.state.links if e[0] in members and e[1] in members]
|
||||
x = count_edge_crossings(op.state.positions, unit_links)
|
||||
assert x == 0
|
||||
|
||||
out = run_layout_on_graph(nodes, edges, action="layout_dual_unit")
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "layout_dual_unit"
|
||||
loc = out.get("local") or {}
|
||||
assert loc.get("accepted") is True
|
||||
assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 0
|
||||
|
||||
|
||||
def test_structure_reports_dual_units() -> None:
|
||||
nodes, edges = _eye_graph()
|
||||
report = analyze_graph_structure(nodes, edges)
|
||||
du = report.get("dual_units") or {}
|
||||
assert int(du.get("unit_count") or 0) >= 1
|
||||
assert report.get("advice", {}).get("prefer_dual_units") is True or du.get(
|
||||
"unit_count", 0
|
||||
) >= 1
|
||||
|
||||
|
||||
def test_compose_merge_shared_portal_unique_coord() -> None:
|
||||
"""Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
|
||||
a = ComposeBlock(
|
||||
key="unit-p1-p2",
|
||||
positions={
|
||||
"p1": (0.0, 0.0),
|
||||
"p2": (200.0, 0.0),
|
||||
"a1": (100.0, 80.0),
|
||||
},
|
||||
)
|
||||
b = ComposeBlock(
|
||||
key="unit-p2-p3",
|
||||
positions={
|
||||
"p2": (0.0, 0.0),
|
||||
"p3": (200.0, 0.0),
|
||||
"b1": (100.0, -60.0),
|
||||
},
|
||||
)
|
||||
merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
|
||||
assert meta.get("merge_shared") is True
|
||||
assert "p2" in merged and "p1" in merged and "p3" in merged
|
||||
# Relative offset p2→p3 preserved after rigid glue (200 on x in local B).
|
||||
dx = merged["p3"][0] - merged["p2"][0]
|
||||
dy = merged["p3"][1] - merged["p2"][1]
|
||||
assert abs(dx - 200.0) < 1e-6
|
||||
assert abs(dy) < 1e-6
|
||||
# b1 relative to p2 preserved.
|
||||
assert abs(merged["b1"][0] - merged["p2"][0] - 100.0) < 1e-6
|
||||
assert abs(merged["b1"][1] - merged["p2"][1] + 60.0) < 1e-6
|
||||
52
packages/netx-topology-mcp/tests/test_edge_axis.py
Normal file
52
packages/netx-topology-mcp/tests/test_edge_axis.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""edge_axis: prefer H/V edges, horizontal over vertical."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import compute_edge_axis
|
||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
||||
|
||||
|
||||
def test_all_horizontal_scores_one() -> None:
|
||||
pos = {"a": (0.0, 0.0), "b": (200.0, 0.0), "c": (400.0, 2.0)} # 2px ≈ H
|
||||
links = [("a", "b"), ("b", "c")]
|
||||
m = compute_edge_axis(pos, links)
|
||||
assert m["horiz_n"] == 2
|
||||
assert m["diag_n"] == 0
|
||||
assert float(m["edge_axis_score"]) == 1.0
|
||||
|
||||
|
||||
def test_diagonal_scores_zero() -> None:
|
||||
pos = {"a": (0.0, 0.0), "b": (100.0, 100.0)}
|
||||
m = compute_edge_axis(pos, [("a", "b")])
|
||||
assert m["diag_n"] == 1
|
||||
assert float(m["edge_axis_score"]) == 0.0
|
||||
assert m["top_skew_edges"]
|
||||
|
||||
|
||||
def test_horizontal_beats_vertical() -> None:
|
||||
h = compute_edge_axis(
|
||||
{"a": (0.0, 0.0), "b": (200.0, 0.0)},
|
||||
[("a", "b")],
|
||||
)
|
||||
v = compute_edge_axis(
|
||||
{"a": (0.0, 0.0), "b": (0.0, 200.0)},
|
||||
[("a", "b")],
|
||||
)
|
||||
assert float(h["edge_axis_score"]) > float(v["edge_axis_score"])
|
||||
assert float(v["edge_axis_score"]) == 0.75
|
||||
|
||||
|
||||
def test_analyze_report_includes_edge_axis() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 0.0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 100.0, "y": 100.0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 200.0, "y": 0.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
m = analyze_layout_stats(nodes, edges)
|
||||
assert "edge_axis" in m["score"]["parts"]
|
||||
assert m["report"]["edge_axis"]["status"] in {"warn", "fail"}
|
||||
assert int(m["diag_n"] or 0) >= 1
|
||||
89
packages/netx-topology-mcp/tests/test_fold_chain.py
Normal file
89
packages/netx-topology-mcp/tests/test_fold_chain.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Tests for fold_chain sector sweep + tentacle fold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops.fold_chain import fold_chain_into_sector
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
|
||||
|
||||
|
||||
def _star_with_tentacle() -> tuple[list[dict], list[dict]]:
|
||||
"""Hub H with ring of leaves + long deg2 tentacle S0..S4."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "H", "name": "PLG-HUB-AN1-Z", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "A", "name": "PLG-AAA-EN1-Z", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "B", "name": "PLG-BBB-EN1-Z", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "C", "name": "PLG-CCC-EN1-Z", "x": -200, "y": 0},
|
||||
{"fabric_node_id": "D", "name": "PLG-DDD-EN1-Z", "x": 0, "y": -200},
|
||||
# tentacle stretched far east (pierces through ring)
|
||||
{"fabric_node_id": "S0", "name": "PLG-S0-EN1-Z", "x": 80, "y": 0},
|
||||
{"fabric_node_id": "S1", "name": "PLG-S1-EN1-Z", "x": 260, "y": 0},
|
||||
{"fabric_node_id": "S2", "name": "PLG-S2-EN1-Z", "x": 440, "y": 0},
|
||||
{"fabric_node_id": "S3", "name": "PLG-S3-EN1-Z", "x": 620, "y": 0},
|
||||
{"fabric_node_id": "S4", "name": "PLG-S4-EN1-Z", "x": 800, "y": 0},
|
||||
# filler leaf in south gap candidate
|
||||
{"fabric_node_id": "E", "name": "PLG-EEE-EN1-Z", "x": 140, "y": 140},
|
||||
{"fabric_node_id": "F", "name": "PLG-FFF-EN1-Z", "x": -140, "y": 140},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "H", "b_node_id": "A"},
|
||||
{"a_node_id": "H", "b_node_id": "B"},
|
||||
{"a_node_id": "H", "b_node_id": "C"},
|
||||
{"a_node_id": "H", "b_node_id": "D"},
|
||||
{"a_node_id": "H", "b_node_id": "E"},
|
||||
{"a_node_id": "H", "b_node_id": "F"},
|
||||
{"a_node_id": "H", "b_node_id": "S0"},
|
||||
{"a_node_id": "S0", "b_node_id": "S1"},
|
||||
{"a_node_id": "S1", "b_node_id": "S2"},
|
||||
{"a_node_id": "S2", "b_node_id": "S3"},
|
||||
{"a_node_id": "S3", "b_node_id": "S4"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_fold_chain_places_whole_chain_on_arc() -> None:
|
||||
nodes, edges = _star_with_tentacle()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = fold_chain_into_sector(
|
||||
st,
|
||||
LayoutParams(target_nn=155.0),
|
||||
hub_id="H",
|
||||
stub_id="S0",
|
||||
prefer_mid_deg=90.0,
|
||||
r_arc=300.0,
|
||||
chord=180.0,
|
||||
)
|
||||
meta = op.state.meta["fold_chain"]
|
||||
assert meta["hub_id"] == "H"
|
||||
assert meta["chain"] == ["S0", "S1", "S2", "S3", "S4"]
|
||||
assert len(meta["folded"]) == 5
|
||||
hx, hy = op.state.positions["H"]
|
||||
radii = [
|
||||
math.hypot(op.state.positions[n][0] - hx, op.state.positions[n][1] - hy)
|
||||
for n in meta["chain"]
|
||||
]
|
||||
assert all(abs(r - 300.0) < 1.0 for r in radii)
|
||||
# Chain should not remain as a straight east ray
|
||||
assert op.state.positions["S4"][0] < 700
|
||||
|
||||
|
||||
def test_fold_chain_action_unpublished() -> None:
|
||||
nodes, edges = _star_with_tentacle()
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="fold_chain",
|
||||
params={"hub_id": "H", "stub_id": "S0", "prefer_mid_deg": 120},
|
||||
)
|
||||
raise AssertionError("fold_chain should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_catalog_omits_fold_chain() -> None:
|
||||
cat = list_layout_catalog()
|
||||
assert "fold_chain" not in cat["actions"]
|
||||
141
packages/netx-topology-mcp/tests/test_force_densify.py
Normal file
141
packages/netx-topology-mcp/tests/test_force_densify.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Force densify: edge attract + local repulse + semi-rigid bodies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops.force_densify import force_densify_round
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_tool import ACTIONS
|
||||
|
||||
|
||||
def test_actions_include_force_densify() -> None:
|
||||
assert "force_densify" not in ACTIONS
|
||||
|
||||
|
||||
def _sparse_two_units():
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
|
||||
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "x": 4200, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "x": 4400, "y": 80},
|
||||
{"fabric_node_id": "b3", "name": "B3", "x": 4600, "y": -60},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b1", "b_node_id": "b3"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
groups = groups_from_membership(
|
||||
[
|
||||
("va", ["p", "a1", "a2"]),
|
||||
("vb", ["b1", "b2", "b3"]),
|
||||
]
|
||||
)
|
||||
st.meta = {"compose_views": {"rigid_groups": groups}}
|
||||
return st, groups
|
||||
|
||||
|
||||
def test_force_densify_shrinks_bridge() -> None:
|
||||
st, groups = _sparse_two_units()
|
||||
L0 = math.hypot(
|
||||
st.positions["b1"][0] - st.positions["a2"][0],
|
||||
st.positions["b1"][1] - st.positions["a2"][1],
|
||||
)
|
||||
before = score_state(st, fast=True)
|
||||
util0 = float((before.get("summary") or {}).get("util") or 0.0)
|
||||
|
||||
op = force_densify_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=16,
|
||||
step=0.5,
|
||||
max_step=280.0,
|
||||
ideal_len=280.0,
|
||||
nn_floor=90.0,
|
||||
attract_k=1.4,
|
||||
repulse_k=0.6,
|
||||
gravity_k=0.9,
|
||||
rigid_strength=0.95,
|
||||
deform=0.05,
|
||||
protect_rigid="off",
|
||||
x_slack=500,
|
||||
)
|
||||
assert op.op == "force_densify"
|
||||
assert not (op.params or {}).get("reverted"), op.note
|
||||
L1 = math.hypot(
|
||||
op.state.positions["b1"][0] - op.state.positions["a2"][0],
|
||||
op.state.positions["b1"][1] - op.state.positions["a2"][1],
|
||||
)
|
||||
assert L1 < L0 * 0.92, f"bridge not shortened: {L0:.0f}->{L1:.0f}"
|
||||
after = score_state(op.state, fast=True)
|
||||
util1 = float((after.get("summary") or {}).get("util") or 0.0)
|
||||
assert util1 >= util0 - 1e-6
|
||||
|
||||
|
||||
def test_edge_spring_pushes_crushed_edge() -> None:
|
||||
"""Too-short edge should lengthen (push), not only pull long ones."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 40, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 2000, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
L0 = math.hypot(
|
||||
st.positions["b"][0] - st.positions["a"][0],
|
||||
st.positions["b"][1] - st.positions["a"][1],
|
||||
)
|
||||
op = force_densify_round(
|
||||
st,
|
||||
groups=[],
|
||||
iters=12,
|
||||
step=0.45,
|
||||
max_step=80.0,
|
||||
ideal_len=160.0,
|
||||
nn_floor=80.0,
|
||||
attract_k=1.5,
|
||||
repulse_k=0.4,
|
||||
gravity_k=0.0,
|
||||
rigid_strength=0.0,
|
||||
deform=0.0,
|
||||
protect_rigid="off",
|
||||
x_slack=200,
|
||||
)
|
||||
L1 = math.hypot(
|
||||
op.state.positions["b"][0] - op.state.positions["a"][0],
|
||||
op.state.positions["b"][1] - op.state.positions["a"][1],
|
||||
)
|
||||
assert L1 > L0 + 15.0, f"crushed edge not pushed: {L0:.1f}->{L1:.1f}"
|
||||
|
||||
|
||||
def test_semi_rigid_preserves_internal_ratios() -> None:
|
||||
st, groups = _sparse_two_units()
|
||||
|
||||
def _pair(pos):
|
||||
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
|
||||
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
|
||||
return d12 / max(d13, 1e-6)
|
||||
|
||||
r0 = _pair(st.positions)
|
||||
op = force_densify_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=10,
|
||||
rigid_strength=1.0,
|
||||
deform=0.0,
|
||||
ideal_len=180.0,
|
||||
attract_k=1.2,
|
||||
x_slack=500,
|
||||
)
|
||||
r1 = _pair(op.state.positions)
|
||||
assert abs(r1 - r0) < 0.05, f"rigid shape drifted: {r0:.3f}->{r1:.3f}"
|
||||
123
packages/netx-topology-mcp/tests/test_hierarchy.py
Normal file
123
packages/netx-topology-mcp/tests/test_hierarchy.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for stage-2 hierarchy_sectors (contract → order → expand)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.hierarchy import (
|
||||
_candidate_orders,
|
||||
_contracted_crossings,
|
||||
_contracted_links,
|
||||
_stub_territories,
|
||||
hierarchy_sectors_greedy,
|
||||
)
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def _crossed_star():
|
||||
"""Hub with 4 stubs whose geo order crosses two inter-stub chords."""
|
||||
# Hub at origin; stubs placed so angular order A,C,B,D crosses A-B with C-D.
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "X-CN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "X-AN0-Y", "x": 200, "y": -40},
|
||||
{"fabric_node_id": "b", "name": "X-AN1-Y", "x": 40, "y": 200},
|
||||
{"fabric_node_id": "c", "name": "X-AN2-Y", "x": -200, "y": 40},
|
||||
{"fabric_node_id": "d", "name": "X-AN3-Y", "x": -40, "y": -200},
|
||||
{"fabric_node_id": "a1", "name": "X-EN0-Y", "x": 360, "y": -60},
|
||||
{"fabric_node_id": "b1", "name": "X-EN1-Y", "x": 60, "y": 360},
|
||||
{"fabric_node_id": "c1", "name": "X-EN2-Y", "x": -360, "y": 60},
|
||||
{"fabric_node_id": "d1", "name": "X-EN3-Y", "x": -60, "y": -360},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
{"a_node_id": "h", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "d"},
|
||||
{"a_node_id": "a", "b_node_id": "a1"},
|
||||
{"a_node_id": "b", "b_node_id": "b1"},
|
||||
{"a_node_id": "c", "b_node_id": "c1"},
|
||||
{"a_node_id": "d", "b_node_id": "d1"},
|
||||
# Cross chords between territories (contracted as a↔b and c↔d or similar)
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_actions_include_hierarchy_sectors() -> None:
|
||||
assert "hierarchy_sectors" not in ACTIONS
|
||||
|
||||
|
||||
def test_contracted_links_ignore_internal() -> None:
|
||||
nodes, edges = _crossed_star()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
members = set(st.positions)
|
||||
stubs, owner = _stub_territories("h", members, st.adj, pinned={"h"})
|
||||
assert set(stubs) == {"a", "b", "c", "d"}
|
||||
assert owner["a1"] == "a"
|
||||
links = _contracted_links("h", stubs, owner, st.adj, pinned={"h"})
|
||||
# Internal a—a1 must not appear; hub—stub and inter-stub do.
|
||||
assert ("a", "a1") not in links
|
||||
assert ("a", "h") in links
|
||||
assert ("a", "b") in links
|
||||
|
||||
|
||||
def test_candidate_orders_cover_rotations() -> None:
|
||||
stubs = ["a", "b", "c", "d"]
|
||||
pos = {
|
||||
"h": (0.0, 0.0),
|
||||
"a": (1.0, 0.0),
|
||||
"b": (0.0, 1.0),
|
||||
"c": (-1.0, 0.0),
|
||||
"d": (0.0, -1.0),
|
||||
}
|
||||
orders = _candidate_orders(stubs, pos, "h")
|
||||
assert len(orders) >= 4
|
||||
assert any(o[0] == "a" for o in orders)
|
||||
|
||||
|
||||
def test_contracted_crossings_sensitive_to_order() -> None:
|
||||
links = [("a", "h"), ("b", "h"), ("c", "h"), ("d", "h"), ("a", "b"), ("c", "d")]
|
||||
good = _contracted_crossings(
|
||||
"h",
|
||||
["a", "b", "c", "d"],
|
||||
links,
|
||||
radius=100.0,
|
||||
hub_xy=(0.0, 0.0),
|
||||
a0=-3.0,
|
||||
a1=3.0,
|
||||
)
|
||||
# Force a bad interleaved order on the same arc.
|
||||
bad = _contracted_crossings(
|
||||
"h",
|
||||
["a", "c", "b", "d"],
|
||||
links,
|
||||
radius=100.0,
|
||||
hub_xy=(0.0, 0.0),
|
||||
a0=-3.0,
|
||||
a1=3.0,
|
||||
)
|
||||
assert bad >= good
|
||||
|
||||
|
||||
def test_hierarchy_sectors_accepts_on_contracted_gain() -> None:
|
||||
nodes, edges = _crossed_star()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
op = hierarchy_sectors_greedy(st, LayoutParams())
|
||||
assert op.op == "hierarchy_sectors"
|
||||
assert "accepted_n" in op.params
|
||||
# Gate is contracted, not global: if a level was accepted, contracted fell.
|
||||
for lvl in op.params.get("levels") or []:
|
||||
assert lvl["contracted_after"] < lvl["contracted_before"]
|
||||
# Smoke: crossings countable after expand.
|
||||
assert count_edge_crossings(op.state.positions, op.state.links) >= 0
|
||||
|
||||
|
||||
def test_run_layout_on_graph_hierarchy_sectors_unpublished() -> None:
|
||||
nodes, edges = _crossed_star()
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="hierarchy_sectors")
|
||||
raise AssertionError("hierarchy_sectors should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
314
packages/netx-topology-mcp/tests/test_hotspots.py
Normal file
314
packages/netx-topology-mcp/tests/test_hotspots.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""Local hotspot / overlap fix tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops import (
|
||||
LayoutParams,
|
||||
build_state_from_nodes_edges,
|
||||
fix_overlaps_local,
|
||||
relax_hotspots,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_topology_mcp.layout_tool import run_layout_on_graph
|
||||
|
||||
|
||||
def test_fix_overlaps_local_pulls_apart() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 3, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 2000, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
assert len(overlapping_nodes(st)) >= 2
|
||||
far_before = st.positions["c"]
|
||||
out = fix_overlaps_local(st, LayoutParams(overlap_iters=200, overlap_step=5.0))
|
||||
assert len(overlapping_nodes(out.state)) == 0
|
||||
# distant node should barely move (outside 1-hop of a-b... c is 1-hop from b)
|
||||
# c may move if in expand; at least a/b separated
|
||||
assert abs(out.state.positions["a"][0] - out.state.positions["b"][0]) > 10
|
||||
|
||||
|
||||
def test_run_layout_action_pack_utilization_unpublished() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
|
||||
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
]
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="pack_utilization",
|
||||
preset="balanced",
|
||||
params={
|
||||
"target_util": 0.15,
|
||||
"pack_min_scale": 0.2,
|
||||
"pack_iters": 6,
|
||||
"shrink_corridors": True,
|
||||
"corridor_cap": 2000,
|
||||
"pull": 0.6,
|
||||
},
|
||||
)
|
||||
raise AssertionError("pack_utilization should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_pack_utilization_transform_still_works() -> None:
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_ops.transforms import pack_utilization
|
||||
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
|
||||
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = pack_utilization(
|
||||
st,
|
||||
LayoutParams(target_util=0.15, pack_min_scale=0.2, pack_iters=6),
|
||||
)
|
||||
assert op.op == "pack_utilization"
|
||||
xs = [p[0] for p in op.state.positions.values()]
|
||||
ys = [p[1] for p in op.state.positions.values()]
|
||||
assert max(xs) - min(xs) < 16000
|
||||
assert max(ys) - min(ys) < 8000
|
||||
local = {"mode": (op.params or {}).get("mode"), "op": op.params or {}}
|
||||
op = local.get("op") or {}
|
||||
# Corridor shrink reports bbox_area_ratio; uniform reports util_after.
|
||||
if op.get("bbox_area_ratio") is not None:
|
||||
assert float(op["bbox_area_ratio"]) >= 1.0
|
||||
else:
|
||||
assert float(op.get("util_after") or 0) > float(op.get("util_before") or 0)
|
||||
|
||||
|
||||
def test_shrink_long_corridors_pulls_islands() -> None:
|
||||
from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors
|
||||
|
||||
# Two dense islands connected by one long bridge.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 8000, "y": 8000},
|
||||
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 8200, "y": 8000},
|
||||
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 8000, "y": 8200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a1", "b_node_id": "a3"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b1", "b_node_id": "b3"},
|
||||
{"a_node_id": "a1", "b_node_id": "b1"}, # long corridor
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
d0 = math.hypot(
|
||||
st.positions["a1"][0] - st.positions["b1"][0],
|
||||
st.positions["a1"][1] - st.positions["b1"][1],
|
||||
)
|
||||
out = shrink_long_corridors(
|
||||
st,
|
||||
edge_len_cap=1200,
|
||||
pull=0.6,
|
||||
iters=3,
|
||||
min_island=3,
|
||||
max_bridges=4,
|
||||
accept_crossings=False,
|
||||
)
|
||||
d1 = math.hypot(
|
||||
out.state.positions["a1"][0] - out.state.positions["b1"][0],
|
||||
out.state.positions["a1"][1] - out.state.positions["b1"][1],
|
||||
)
|
||||
assert d1 < d0 * 0.55
|
||||
# Intra-island spacing preserved.
|
||||
assert abs(
|
||||
math.hypot(
|
||||
out.state.positions["a1"][0] - out.state.positions["a2"][0],
|
||||
out.state.positions["a1"][1] - out.state.positions["a2"][1],
|
||||
)
|
||||
- 200.0
|
||||
) < 1.0
|
||||
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.5
|
||||
|
||||
|
||||
def test_shrink_long_corridors_unit_exclusive() -> None:
|
||||
from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors
|
||||
|
||||
# Shared portal + two exclusive clusters far apart (metro dual-unit shape).
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
|
||||
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 10000, "y": 10000},
|
||||
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 10200, "y": 10000},
|
||||
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 10000, "y": 10200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a1", "b_node_id": "a3"},
|
||||
{"a_node_id": "p", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b1", "b_node_id": "b3"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "u0", "node_ids": ["p", "a1", "a2", "a3"], "pivots": ["p"]},
|
||||
{"key": "u1", "node_ids": ["p", "b1", "b2", "b3"], "pivots": ["p"]},
|
||||
]
|
||||
d0 = math.hypot(
|
||||
st.positions["a1"][0] - st.positions["b1"][0],
|
||||
st.positions["a1"][1] - st.positions["b1"][1],
|
||||
)
|
||||
out = shrink_long_corridors(
|
||||
st,
|
||||
edge_len_cap=1500,
|
||||
pull=0.55,
|
||||
iters=4,
|
||||
min_island=3,
|
||||
max_bridges=4,
|
||||
groups=groups,
|
||||
accept_crossings=True,
|
||||
)
|
||||
d1 = math.hypot(
|
||||
out.state.positions["a1"][0] - out.state.positions["b1"][0],
|
||||
out.state.positions["a1"][1] - out.state.positions["b1"][1],
|
||||
)
|
||||
assert (out.params or {}).get("island_mode") == "unit_exclusive"
|
||||
assert d1 < d0 * 0.75
|
||||
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.2
|
||||
# Portal may move less / stay; exclusives keep relative spacing.
|
||||
assert abs(
|
||||
math.hypot(
|
||||
out.state.positions["a1"][0] - out.state.positions["a2"][0],
|
||||
out.state.positions["a1"][1] - out.state.positions["a2"][1],
|
||||
)
|
||||
- 200.0
|
||||
) < 1.0
|
||||
|
||||
|
||||
def test_densify_rigid_groups_raises_util() -> None:
|
||||
from netx_topology_mcp.layout_ops.rigid_units import densify_rigid_groups
|
||||
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 10000, "y": 10000},
|
||||
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 10200, "y": 10000},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a"},
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "p", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
groups = [
|
||||
{"key": "u0", "node_ids": ["p", "a", "b"], "pivots": ["p"]},
|
||||
{"key": "u1", "node_ids": ["p", "c", "d"], "pivots": ["p"]},
|
||||
]
|
||||
xs0 = [st.positions[n][0] for n in st.positions]
|
||||
ys0 = [st.positions[n][1] for n in st.positions]
|
||||
area0 = (max(xs0) - min(xs0)) * (max(ys0) - min(ys0))
|
||||
out = densify_rigid_groups(st, groups, scale=0.5)
|
||||
xs1 = [out.state.positions[n][0] for n in out.state.positions]
|
||||
ys1 = [out.state.positions[n][1] for n in out.state.positions]
|
||||
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
|
||||
assert area1 < area0 * 0.85
|
||||
# Intra-unit gap preserved (a-b still ~200).
|
||||
assert abs(
|
||||
math.hypot(
|
||||
out.state.positions["a"][0] - out.state.positions["b"][0],
|
||||
out.state.positions["a"][1] - out.state.positions["b"][1],
|
||||
)
|
||||
- 200.0
|
||||
) < 1.0
|
||||
|
||||
|
||||
def test_run_layout_pack_compress_long_edges_unpublished() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 5000, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 0, "y": 5000},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a"},
|
||||
{"a_node_id": "p", "b_node_id": "b"},
|
||||
]
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="pack_utilization",
|
||||
preset="balanced",
|
||||
params={
|
||||
"portal_ids": ["p"],
|
||||
"shrink_corridors": True,
|
||||
"corridor_cap": 900,
|
||||
"pull": 0.6,
|
||||
"compress_iters": 4,
|
||||
},
|
||||
)
|
||||
raise AssertionError("pack_utilization should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
def test_run_layout_action_fix_overlaps() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 2, "y": 1},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-AN-1", "x": 400, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
out = run_layout_on_graph(nodes, edges, action="fix_overlaps", preset="balanced")
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "fix_overlaps"
|
||||
assert out["overlap"]["footprint_pairs"] == 0
|
||||
# Should not rebuild corridor skeleton to origin-scale extremes
|
||||
xs = [p["x"] for p in out["positions"]]
|
||||
assert max(xs) - min(xs) < 5000
|
||||
|
||||
|
||||
def test_relax_hotspots_moves_only_dense() -> None:
|
||||
# 6 coinciding + 1 disconnected far island
|
||||
nodes = [
|
||||
{"fabric_node_id": f"n{i}", "name": f"N{i}-EN-x", "x": float(i % 2), "y": 0.0}
|
||||
for i in range(6)
|
||||
]
|
||||
nodes.append({"fabric_node_id": "far", "name": "FAR-EN-z", "x": 8000.0, "y": 0.0})
|
||||
nodes.append({"fabric_node_id": "far2", "name": "FAR2-EN-z", "x": 8200.0, "y": 0.0})
|
||||
edges = [{"a_node_id": f"n{i}", "b_node_id": f"n{i+1}"} for i in range(5)]
|
||||
edges.append({"a_node_id": "far", "b_node_id": "far2"})
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
far0 = st.positions["far"]
|
||||
out = relax_hotspots(st, LayoutParams(cluster_gap=40.0, target_util=0.1))
|
||||
# disconnected far island should stay put (not in dense hotspot)
|
||||
assert abs(out.state.positions["far"][0] - far0[0]) < 1.0
|
||||
180
packages/netx-topology-mcp/tests/test_layout_jobs.py
Normal file
180
packages/netx-topology-mcp/tests/test_layout_jobs.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Background layout jobs + slim payload helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from netx_topology_mcp.http_tools import _layout_topology_view, _slim_layout_payload
|
||||
from netx_topology_mcp.layout_jobs import (
|
||||
cancel_job,
|
||||
get_job,
|
||||
is_cancelled,
|
||||
job_public,
|
||||
report_progress,
|
||||
start_job,
|
||||
)
|
||||
|
||||
|
||||
def test_start_job_and_poll() -> None:
|
||||
def runner() -> dict:
|
||||
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
|
||||
time.sleep(0.05)
|
||||
return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3}
|
||||
|
||||
jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1})
|
||||
assert jid
|
||||
saw_progress = False
|
||||
for _ in range(40):
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert "elapsed_ms" in pub
|
||||
assert "progress" in pub
|
||||
assert "heartbeat_age_ms" in pub
|
||||
if (pub.get("progress") or {}).get("phase") == "mid":
|
||||
saw_progress = True
|
||||
if pub["status"] != "running":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert saw_progress
|
||||
job = get_job(jid)
|
||||
assert job is not None
|
||||
assert job["status"] == "done"
|
||||
assert job["result"]["applied"] is True
|
||||
|
||||
st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
|
||||
assert st.get("ok") is True
|
||||
assert st.get("status") == "done"
|
||||
assert st.get("result", {}).get("applied") is True
|
||||
assert isinstance(st.get("progress"), dict)
|
||||
|
||||
|
||||
def test_cancel_job_cooperative() -> None:
|
||||
started = time.time()
|
||||
|
||||
def runner() -> dict:
|
||||
report_progress("work", pct=10.0, message="before sleep")
|
||||
for _ in range(40):
|
||||
if is_cancelled():
|
||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled
|
||||
|
||||
raise_if_cancelled()
|
||||
time.sleep(0.05)
|
||||
return {"ok": True, "applied": True}
|
||||
|
||||
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
|
||||
time.sleep(0.08)
|
||||
out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}})
|
||||
assert out.get("ok") is True
|
||||
assert out.get("cancel_requested") is True
|
||||
for _ in range(40):
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
if pub["status"] == "cancelled":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert pub["status"] == "cancelled"
|
||||
assert time.time() - started < 3.0
|
||||
|
||||
|
||||
def test_slim_layout_payload_drops_guide() -> None:
|
||||
fat = {
|
||||
"ok": True,
|
||||
"action": "compose_views",
|
||||
"node_count": 1200,
|
||||
"guide": {"how_to_read": "x" * 200},
|
||||
"tried": [1, 2, 3],
|
||||
"params_used": {"lane": 1},
|
||||
"crossing": {
|
||||
"status": "fail",
|
||||
"score": 0,
|
||||
"edge_crossings": 9,
|
||||
"crossings_per_link": 0.1,
|
||||
"top_nodes": [{"id": "a"}] * 20,
|
||||
"top_edges": [{"id": "e"}] * 20,
|
||||
"tip": "long",
|
||||
},
|
||||
"summary": {"total": 40},
|
||||
}
|
||||
slim = _slim_layout_payload(fat)
|
||||
assert slim.get("slim") is True
|
||||
assert "guide" not in slim
|
||||
assert "tried" not in slim
|
||||
assert len(slim["crossing"]["top_nodes"]) <= 5
|
||||
|
||||
|
||||
def test_compose_background_returns_job_id() -> None:
|
||||
src = [f"s{i:02d}" for i in range(12)]
|
||||
|
||||
def fake_start(**kwargs):
|
||||
assert kwargs["action"] == "compose_views"
|
||||
assert kwargs["view_id"] == "full"
|
||||
assert isinstance(kwargs.get("tool_args"), dict)
|
||||
assert kwargs["tool_args"].get("action") == "compose_views"
|
||||
return "abc123job"
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.start_job", side_effect=fake_start):
|
||||
out = _layout_topology_view(
|
||||
{
|
||||
"view_id": "full",
|
||||
"action": "compose_views",
|
||||
"mode": "apply",
|
||||
"params": {"source_view_ids": src},
|
||||
}
|
||||
)
|
||||
assert out.get("ok") is True
|
||||
assert out.get("status") == "running"
|
||||
assert out.get("job_id") == "abc123job"
|
||||
assert out.get("applied") is False
|
||||
|
||||
|
||||
def test_job_status_missing() -> None:
|
||||
out = _layout_topology_view({"action": "job_status", "params": {}})
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_id_required"
|
||||
|
||||
|
||||
def test_job_status_unknown() -> None:
|
||||
out = _layout_topology_view(
|
||||
{"action": "job_status", "params": {"job_id": "does-not-exist"}}
|
||||
)
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_not_found"
|
||||
|
||||
|
||||
def test_cancel_missing() -> None:
|
||||
out = cancel_job("missing-job")
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "job_not_found"
|
||||
|
||||
|
||||
def test_soft_stale_keeps_running_and_cancelable() -> None:
|
||||
"""stale is a poll warning only — must not freeze status or block cancel."""
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
def runner() -> dict:
|
||||
time.sleep(0.3)
|
||||
return {"ok": True, "applied": False}
|
||||
|
||||
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
|
||||
with lj._LOCK: # noqa: SLF001 — test injects silent heartbeat on disk
|
||||
job = lj._read_job_disk(jid) or lj._JOBS[jid]
|
||||
job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5)
|
||||
lj._JOBS[jid] = job
|
||||
lj._write_job_disk(job)
|
||||
pub = job_public(jid)
|
||||
assert pub is not None
|
||||
assert pub["stale"] is True
|
||||
assert pub["status"] == "running"
|
||||
cancelled = cancel_job(jid)
|
||||
assert cancelled.get("ok") is True
|
||||
assert cancelled.get("status") == "cancelling"
|
||||
for _ in range(40):
|
||||
pub2 = job_public(jid)
|
||||
assert pub2 is not None
|
||||
if pub2["status"] in {"cancelled", "done", "error"}:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert job_public(jid)["status"] == "cancelled"
|
||||
525
packages/netx-topology-mcp/tests/test_layout_ops.py
Normal file
525
packages/netx-topology-mcp/tests/test_layout_ops.py
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
"""Unit tests for composable layout_ops atoms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_metrics import grade_layout
|
||||
from netx_topology_mcp.layout_ops import (
|
||||
LayoutParams,
|
||||
build_state_from_nodes_edges,
|
||||
run_recipe,
|
||||
score_state,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.transforms import (
|
||||
resolve_overlaps,
|
||||
scale_region,
|
||||
select_pins,
|
||||
)
|
||||
|
||||
|
||||
def _line_graph(n: int = 6, gap: float = 40.0):
|
||||
nodes = []
|
||||
edges = []
|
||||
for i in range(n):
|
||||
nodes.append(
|
||||
{
|
||||
"fabric_node_id": f"n{i}",
|
||||
"name": f"X-EN{i}-Y",
|
||||
"x": i * gap,
|
||||
"y": 0.0,
|
||||
}
|
||||
)
|
||||
if i:
|
||||
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_scale_region_only_unpinned() -> None:
|
||||
nodes, edges = _line_graph(4, gap=100.0)
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
st.pinned = {"n0", "n1"}
|
||||
before = dict(st.positions)
|
||||
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=True)
|
||||
assert out.state.positions["n0"] == before["n0"]
|
||||
assert out.state.positions["n1"] == before["n1"]
|
||||
assert out.state.positions["n2"] != before["n2"]
|
||||
assert "n2" in out.moved
|
||||
|
||||
|
||||
def test_resolve_overlaps_clears_stack() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC", "x": 200, "y": 200},
|
||||
]
|
||||
edges = [{"a_node_id": "a", "b_node_id": "c"}]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
st.pinned = {"c"}
|
||||
m0 = score_state(st)
|
||||
assert m0["footprint_overlap_pairs"] >= 1
|
||||
out = resolve_overlaps(st, LayoutParams(overlap_iters=120, overlap_step=12.0))
|
||||
m1 = score_state(out.state)
|
||||
assert m1["footprint_overlap_pairs"] == 0
|
||||
assert out.state.positions["c"] == st.positions["c"]
|
||||
|
||||
|
||||
def test_select_pins_modes() -> None:
|
||||
nodes, edges = _line_graph(8)
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
st.layers = {n["fabric_node_id"]: "access" for n in nodes}
|
||||
st.layers["n0"] = "agg"
|
||||
st.spine = {"n1", "n2", "n3"}
|
||||
r = select_pins(st, mode="spine")
|
||||
assert "n0" in r.state.pinned
|
||||
assert "n1" in r.state.pinned
|
||||
|
||||
|
||||
def test_grade_hard_zero_overlap() -> None:
|
||||
m = {
|
||||
"node_count": 10,
|
||||
"link_count": 9,
|
||||
"edge_crossings": 0,
|
||||
"crossings_per_link": 0.0,
|
||||
"footprint_overlap_pairs": 2,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 160,
|
||||
"space_utilization": 0.2,
|
||||
}
|
||||
g = grade_layout(m)
|
||||
assert g["spacing_grade"] == "fail"
|
||||
g2 = grade_layout(m, ume_reference=True)
|
||||
assert g2["spacing_grade"] in {"ok", "warn", "fail"}
|
||||
|
||||
|
||||
def test_select_scope_limits_moves() -> None:
|
||||
from netx_topology_mcp.layout_ops.scope import select_scope
|
||||
from netx_topology_mcp.layout_ops.transforms import scale_region
|
||||
|
||||
nodes, edges = _line_graph(6, gap=100.0)
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
st = select_scope(st, mode="ids", node_ids={"n0", "n1", "n2"}).state
|
||||
before = dict(st.positions)
|
||||
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=False)
|
||||
assert out.state.positions["n5"] == before["n5"]
|
||||
assert out.state.positions["n0"] != before["n0"]
|
||||
|
||||
|
||||
def test_explode_clusters_breaks_stack() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "X-EN0-A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "X-EN1-B", "x": 1, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "X-EN2-C", "x": 400, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
from netx_topology_mcp.layout_ops.transforms import explode_clusters
|
||||
|
||||
out = explode_clusters(st, LayoutParams(), thr=5.0, gap=50.0)
|
||||
d = math.hypot(
|
||||
out.state.positions["a"][0] - out.state.positions["b"][0],
|
||||
out.state.positions["a"][1] - out.state.positions["b"][1],
|
||||
)
|
||||
assert d >= 40.0
|
||||
|
||||
|
||||
def test_place_on_rect_edge_axis_aligned() -> None:
|
||||
from netx_topology_mcp.layout_ops.sugiyama import _place_on_rect_edge
|
||||
|
||||
order = [f"e{i}" for i in range(8)]
|
||||
pos = _place_on_rect_edge(order, 0.0, 0.0, 400.0, 200.0)
|
||||
assert len(pos) == 8
|
||||
for x, y in pos.values():
|
||||
on_h = (abs(y - 0.0) < 1e-6 or abs(y - 200.0) < 1e-6) and -1e-6 <= x <= 400.0 + 1e-6
|
||||
on_v = (abs(x - 0.0) < 1e-6 or abs(x - 400.0) < 1e-6) and -1e-6 <= y <= 200.0 + 1e-6
|
||||
assert on_h or on_v
|
||||
|
||||
|
||||
def test_extract_dangling_feeder_leaf_to_attach() -> None:
|
||||
from netx_topology_mcp.layout_ops.sugiyama import _extract_dangling_feeders
|
||||
|
||||
# Leaf e4 → … → attach e0 (on ring/hub). Not a ring corridor.
|
||||
ens = [f"e{i}" for i in range(5)]
|
||||
adj = {
|
||||
"e0": {"e1", "r1", "r2"},
|
||||
"e1": {"e0", "e2"},
|
||||
"e2": {"e1", "e3"},
|
||||
"e3": {"e2", "e4"},
|
||||
"e4": {"e3"},
|
||||
"r1": {"e0", "r2"},
|
||||
"r2": {"e0", "r1"},
|
||||
}
|
||||
names = {n: n for n in adj}
|
||||
chains = _extract_dangling_feeders(
|
||||
ens + ["r1", "r2"], adj, names, attach_set={"e0", "r1", "r2"}, min_len=2
|
||||
)
|
||||
assert chains
|
||||
long = max(chains, key=len)
|
||||
assert long[0] == "e4"
|
||||
assert long[-1] == "e0"
|
||||
assert long == ["e4", "e3", "e2", "e1", "e0"]
|
||||
|
||||
|
||||
def test_eject_portal_neighbor_outside_ring() -> None:
|
||||
"""PNBR-like stub on a portal must leave the ring interior."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import _eject_intruders_from_rings
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
|
||||
# Trapezoid ring a—b with bottom corridor; stub s attached to a sits inside.
|
||||
groups = [
|
||||
{
|
||||
"portals": ("a", "b"),
|
||||
"paths": [["a", "b"], ["a", "p1", "p2", "b"]],
|
||||
}
|
||||
]
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (400.0, 0.0),
|
||||
"p1": (100.0, 150.0),
|
||||
"p2": (300.0, 150.0),
|
||||
"s": (120.0, 40.0), # inside polygon
|
||||
}
|
||||
adj = {"a": {"b", "p1", "s"}, "b": {"a", "p2"}, "p1": {"a", "p2"}, "p2": {"p1", "b"}, "s": {"a"}}
|
||||
edges = [("a", "b"), ("a", "p1"), ("p1", "p2"), ("p2", "b"), ("a", "s")]
|
||||
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
|
||||
# Outside: left of portal a (outer side away from b).
|
||||
assert out["s"][0] < out["a"][0]
|
||||
|
||||
|
||||
def test_triangle_ring_stub_must_not_pierce_chord() -> None:
|
||||
"""Minimal triangle ring: portal stub edge must not cross the third side."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import (
|
||||
_eject_intruders_from_rings,
|
||||
_stub_crosses_ring,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
|
||||
# Triangle a-v-b with direct a—b; stub s on b pierces a—v.
|
||||
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (300.0, 0.0),
|
||||
"v": (150.0, 120.0),
|
||||
"s": (50.0, 80.0), # s—b crosses a—v
|
||||
"out": (-200.0, 0.0),
|
||||
}
|
||||
adj = {
|
||||
"a": {"b", "v"},
|
||||
"b": {"a", "v", "s"},
|
||||
"v": {"a", "b"},
|
||||
"s": {"b", "out"},
|
||||
"out": {"s"},
|
||||
}
|
||||
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("b", "s"), ("s", "out")]
|
||||
assert _stub_crosses_ring("s", "b", pos, ["a", "b", "v"])
|
||||
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
|
||||
assert not _stub_crosses_ring("s", "b", out, ["a", "b", "v"])
|
||||
|
||||
|
||||
def test_triangle_apex_flips_off_foreign_chord() -> None:
|
||||
"""VOTI-like apex must sit on the side that foreign portal edges do not hit."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import _orient_ring_sides
|
||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
||||
|
||||
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
|
||||
# Foreign f—b runs under the chord; apex v below is pierced / crossed.
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (300.0, 0.0),
|
||||
"v": (150.0, 120.0),
|
||||
"f": (-100.0, 60.0),
|
||||
}
|
||||
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("f", "b")]
|
||||
assert segments_properly_intersect(pos["a"], pos["v"], pos["f"], pos["b"])
|
||||
out = _orient_ring_sides(
|
||||
pos, groups, edges, pinned={"a", "b", "v"}, max_interiors=3, push=40.0
|
||||
)
|
||||
assert out["v"][1] < 0.0 # flipped above the chord
|
||||
assert not segments_properly_intersect(out["a"], out["v"], out["f"], out["b"])
|
||||
|
||||
|
||||
def test_eject_one_ring_must_not_pierce_another() -> None:
|
||||
"""Ejecting from a large ring must not park the stub through a triangle."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import (
|
||||
_eject_intruders_from_rings,
|
||||
_stub_crosses_ring,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
|
||||
# Big ring a—b via p1-p2; triangle a-v-b; stub s inside big ring.
|
||||
groups = [
|
||||
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "p1", "p2", "b"]]},
|
||||
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]},
|
||||
]
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (400.0, 0.0),
|
||||
"p1": (80.0, 200.0),
|
||||
"p2": (320.0, 200.0),
|
||||
"v": (200.0, -120.0),
|
||||
"s": (120.0, 40.0), # inside big ring; naive eject can cross a—v
|
||||
}
|
||||
adj = {
|
||||
"a": {"b", "p1", "v", "s"},
|
||||
"b": {"a", "p2", "v"},
|
||||
"p1": {"a", "p2"},
|
||||
"p2": {"p1", "b"},
|
||||
"v": {"a", "b"},
|
||||
"s": {"a"},
|
||||
}
|
||||
edges = [
|
||||
("a", "b"),
|
||||
("a", "p1"),
|
||||
("p1", "p2"),
|
||||
("p2", "b"),
|
||||
("a", "v"),
|
||||
("v", "b"),
|
||||
("a", "s"),
|
||||
]
|
||||
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
|
||||
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "v"])
|
||||
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "p2", "p1"])
|
||||
|
||||
|
||||
def test_an_side_ring_not_mistaken_for_feeder() -> None:
|
||||
"""AN—EN ring leg must be a ring unit, not a dangling feeder (no X with chain)."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import (
|
||||
_extract_dangling_feeders,
|
||||
_find_two_portal_ring_groups,
|
||||
_ring_nodes_from_groups,
|
||||
)
|
||||
|
||||
# SPB(AN)-TNM direct + SPB-SRIN-ADAK-JROS-TNM; chain TNM-TNMS-SMDA
|
||||
nodes = {
|
||||
"spb": "X-AN-SPB",
|
||||
"tnm": "X-EN-TNM",
|
||||
"tnms": "X-EN-TNMS",
|
||||
"smda": "X-EN-SMDA",
|
||||
"jros": "X-EN-JROS",
|
||||
"adak": "X-EN-ADAK",
|
||||
"srin": "X-EN-SRIN",
|
||||
}
|
||||
adj = {
|
||||
"spb": {"tnm", "srin", "other"},
|
||||
"tnm": {"spb", "tnms", "jros"},
|
||||
"tnms": {"tnm", "smda"},
|
||||
"smda": {"tnms"},
|
||||
"jros": {"tnm", "adak"},
|
||||
"adak": {"jros", "srin"},
|
||||
"srin": {"adak", "spb"},
|
||||
"other": {"spb"},
|
||||
}
|
||||
ens = ["tnm", "tnms", "smda", "jros", "adak", "srin"]
|
||||
an_set = {"spb"}
|
||||
groups = _find_two_portal_ring_groups(ens, adj, nodes, an_set)
|
||||
assert groups
|
||||
ring = _ring_nodes_from_groups(groups)
|
||||
assert {"tnm", "jros", "adak", "srin", "spb"} <= ring
|
||||
attach = ring | an_set
|
||||
feeders = _extract_dangling_feeders(
|
||||
ens, adj, nodes, attach, min_len=2, an_set=an_set
|
||||
)
|
||||
bodies = {n for f in feeders for n in f[:-1]}
|
||||
# Ring leg must not be consumed as a feeder body.
|
||||
assert not ({"jros", "adak", "srin"} & bodies)
|
||||
# True dangling chain off TNM remains.
|
||||
assert any(f[0] == "smda" or "smda" in f for f in feeders)
|
||||
|
||||
|
||||
def test_two_portal_rings_nest_smallest_inner() -> None:
|
||||
"""Shared portals: shortest corridor innermost, longer outward."""
|
||||
from netx_topology_mcp.layout_ops.sugiyama import (
|
||||
_find_two_portal_ring_groups,
|
||||
_place_two_portal_ring_groups,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
|
||||
# Portals p1,p2 with three disjoint corridors (sizes 1 / 2 / 3 mids).
|
||||
ens = ["p1", "p2", "a1", "b1", "b2", "c1", "c2", "c3"]
|
||||
adj = {
|
||||
"p1": {"a1", "b1", "c1", "an"},
|
||||
"p2": {"a1", "b2", "c3", "x"},
|
||||
"a1": {"p1", "p2"},
|
||||
"b1": {"p1", "b2"},
|
||||
"b2": {"b1", "p2"},
|
||||
"c1": {"p1", "c2"},
|
||||
"c2": {"c1", "c3"},
|
||||
"c3": {"c2", "p2"},
|
||||
"an": {"p1"},
|
||||
"x": {"p2"},
|
||||
}
|
||||
names = {n: n for n in list(adj)}
|
||||
groups = _find_two_portal_ring_groups(ens, adj, names, {"an"})
|
||||
assert groups
|
||||
g0 = next(g for g in groups if set(g["portals"]) == {"p1", "p2"})
|
||||
paths = g0["paths"]
|
||||
assert len(paths) >= 2
|
||||
# Shortest path first.
|
||||
assert all(len(paths[i]) <= len(paths[i + 1]) for i in range(len(paths) - 1))
|
||||
|
||||
pos = {
|
||||
"p1": (0.0, 0.0),
|
||||
"p2": (600.0, 0.0),
|
||||
"a1": (300.0, 10.0),
|
||||
"b1": (200.0, 20.0),
|
||||
"b2": (400.0, 20.0),
|
||||
"c1": (150.0, 30.0),
|
||||
"c2": (300.0, 30.0),
|
||||
"c3": (450.0, 30.0),
|
||||
}
|
||||
edges = [("p1", "a1"), ("a1", "p2"), ("p1", "b1"), ("b1", "b2"), ("b2", "p2"),
|
||||
("p1", "c1"), ("c1", "c2"), ("c2", "c3"), ("c3", "p2")]
|
||||
out, pinned = _place_two_portal_ring_groups(
|
||||
pos, ens, adj, names, {"an"}, LayoutParams(), edges
|
||||
)
|
||||
assert pinned
|
||||
# Trapezoid nest: shortest path closer to midline; outer band wider.
|
||||
my = (out["p1"][1] + out["p2"][1]) / 2
|
||||
assert abs(out["a1"][1] - my) <= abs(out["c2"][1] - my) + 1e-6
|
||||
outer_span = abs(out["c3"][0] - out["c1"][0])
|
||||
mid_span = abs(out["b2"][0] - out["b1"][0])
|
||||
assert outer_span + 1e-6 >= mid_span
|
||||
|
||||
|
||||
def test_chain_first_keeps_feeder_sequential() -> None:
|
||||
"""Pure feeder chain stays a coherent polyline after rings recipe."""
|
||||
nodes = [{"fabric_node_id": "an", "name": "X-AN1-Y"}]
|
||||
edges = []
|
||||
for i in range(5):
|
||||
nodes.append({"fabric_node_id": f"e{i}", "name": f"X-EN{i}-Y"})
|
||||
edges.append({"a_node_id": "an", "b_node_id": "e0"})
|
||||
for i in range(4):
|
||||
edges.append({"a_node_id": f"e{i}", "b_node_id": f"e{i+1}"})
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
|
||||
assert len(st2.positions) == 6
|
||||
# Consecutive chain edges should stay roughly axis-aligned (not hop-scrambled).
|
||||
axis = 0
|
||||
for i in range(4):
|
||||
a, b = st2.positions[f"e{i}"], st2.positions[f"e{i+1}"]
|
||||
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
|
||||
if dx < 1e-6 or dy < 1e-6:
|
||||
axis += 1
|
||||
assert axis >= 3
|
||||
assert int(final.get("edge_crossings") or 0) == 0
|
||||
|
||||
|
||||
def test_agg_rings_recipe_places_dual_agg_ring() -> None:
|
||||
# Two ANs + 4-EN ring + one chain stub → dual-hub min-rings
|
||||
nodes = [
|
||||
{"fabric_node_id": "an1", "name": "X-AN1-Y"},
|
||||
{"fabric_node_id": "an2", "name": "X-AN2-Y"},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
|
||||
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
|
||||
{"fabric_node_id": "e3", "name": "X-EN3-Y"},
|
||||
{"fabric_node_id": "e4", "name": "X-EN4-Y"},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "an1", "b_node_id": "an2"},
|
||||
{"a_node_id": "an1", "b_node_id": "e0"},
|
||||
{"a_node_id": "an2", "b_node_id": "e2"},
|
||||
{"a_node_id": "e0", "b_node_id": "e1"},
|
||||
{"a_node_id": "e1", "b_node_id": "e2"},
|
||||
{"a_node_id": "e2", "b_node_id": "e3"},
|
||||
{"a_node_id": "e3", "b_node_id": "e0"},
|
||||
{"a_node_id": "e1", "b_node_id": "e4"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
params = LayoutParams()
|
||||
st2, trace, final = run_recipe(st, "agg_rings_v1", params)
|
||||
assert any(t.get("op") == "build_ring_skeleton" for t in trace)
|
||||
assert st2.meta.get("rings_mode") == "min_rings"
|
||||
assert len(st2.positions) == 7
|
||||
# Dual hubs on a horizontal bar; corridors nest above/below
|
||||
assert abs(st2.positions["an1"][1] - st2.positions["an2"][1]) < 1e-6
|
||||
assert st2.positions["an1"][0] < st2.positions["an2"][0]
|
||||
assert "edge_crossings" in final
|
||||
assert int(final.get("edge_crossings") or 0) == 0
|
||||
assert int(final.get("footprint_overlap_pairs") or 0) == 0
|
||||
|
||||
|
||||
def test_min_rings_dual_hub_parallel_paths_zero_cross() -> None:
|
||||
"""Classic dual-AN parallel corridors + side stubs → 0 crossings via rings."""
|
||||
# anL / anR with 3 disjoint corridors + left stub chain off anL
|
||||
nodes = [
|
||||
{"fabric_node_id": "anL", "name": "Z-PLAU-AN1-X"},
|
||||
{"fabric_node_id": "anR", "name": "Z-ATP-AN1-X"},
|
||||
{"fabric_node_id": "orphan", "name": "Z-BSR-AN1-X"},
|
||||
]
|
||||
edges = []
|
||||
# short upper: anL-a1-a2-anR
|
||||
for i, nid in enumerate(["a1", "a2"]):
|
||||
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
|
||||
edges += [
|
||||
{"a_node_id": "anL", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "anR"},
|
||||
]
|
||||
# mid lower: anL-b1-b2-b3-anR
|
||||
for nid in ["b1", "b2", "b3"]:
|
||||
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
|
||||
edges += [
|
||||
{"a_node_id": "anL", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "b3"},
|
||||
{"a_node_id": "b3", "b_node_id": "anR"},
|
||||
]
|
||||
# long upper-outer: anL-c1..c4-anR
|
||||
for nid in ["c1", "c2", "c3", "c4"]:
|
||||
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
|
||||
edges += [
|
||||
{"a_node_id": "anL", "b_node_id": "c1"},
|
||||
{"a_node_id": "c1", "b_node_id": "c2"},
|
||||
{"a_node_id": "c2", "b_node_id": "c3"},
|
||||
{"a_node_id": "c3", "b_node_id": "c4"},
|
||||
{"a_node_id": "c4", "b_node_id": "anR"},
|
||||
]
|
||||
# side stub off anL
|
||||
for nid in ["s1", "s2"]:
|
||||
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
|
||||
edges += [
|
||||
{"a_node_id": "anL", "b_node_id": "s1"},
|
||||
{"a_node_id": "s1", "b_node_id": "s2"},
|
||||
]
|
||||
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
|
||||
assert st2.meta.get("rings_mode") == "min_rings"
|
||||
assert int(final.get("edge_crossings") or 0) == 0
|
||||
assert int(final.get("footprint_overlap_pairs") or 0) == 0
|
||||
# orphan AN parked, not on the bar mid
|
||||
assert "orphan" in st2.positions
|
||||
assert abs(st2.positions["anL"][1] - st2.positions["anR"][1]) < 1e-6
|
||||
|
||||
|
||||
def test_tiny_recipe_runs() -> None:
|
||||
# Mini AN+EN star for skeleton path
|
||||
nodes = [
|
||||
{"fabric_node_id": "an", "name": "X-AN1-Y"},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
|
||||
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "an", "b_node_id": "e0"},
|
||||
{"a_node_id": "e0", "b_node_id": "e1"},
|
||||
{"a_node_id": "e1", "b_node_id": "e2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
params = LayoutParams(width_mul=2.0, height_mul=1.5, lane=120.0, target_util=0.05)
|
||||
st2, trace, final = run_recipe(st, "smd_corridor_v1", params)
|
||||
assert len(trace) >= 5
|
||||
assert len(st2.positions) == 4
|
||||
assert "edge_crossings" in final
|
||||
assert "space_utilization" in final
|
||||
# Zero-overlap is a tuning target of resolve_overlaps; recipe must complete.
|
||||
assert all("op" in t for t in trace)
|
||||
34
packages/netx-topology-mcp/tests/test_layout_sight.py
Normal file
34
packages/netx-topology-mcp/tests/test_layout_sight.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Tests for agent layout sight (crossing hotspots / blocks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_sight import build_sight, list_crossings
|
||||
|
||||
|
||||
def test_list_crossings_and_drag_candidates() -> None:
|
||||
# Two segments that properly cross: a—b horizontal, c—d vertical.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "X-A-Y", "x": 0.0, "y": 50.0},
|
||||
{"fabric_node_id": "b", "name": "X-B-Y", "x": 100.0, "y": 50.0},
|
||||
{"fabric_node_id": "c", "name": "X-C-Y", "x": 50.0, "y": 0.0},
|
||||
{"fabric_node_id": "d", "name": "X-D-Y", "x": 50.0, "y": 100.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
]
|
||||
out = list_crossings(nodes, edges, limit=10)
|
||||
assert out["crossings_listed"] == 1
|
||||
assert out["crossings"][0]["e1"]["a_name"] == "A"
|
||||
assert len(out.get("top_nodes") or []) == 4
|
||||
assert out["top_nodes"][0]["crossing_hits"] >= 1
|
||||
assert len(out.get("top_edges") or []) == 2
|
||||
assert out["top_edges"][0]["crossing_hits"] >= 1
|
||||
assert out["drag_candidates"]
|
||||
cand = out["drag_candidates"][0]
|
||||
assert "suggest_xy" in cand and len(cand["suggest_xy"]) >= 1
|
||||
assert "delta_crossings_est" in cand
|
||||
assert "x" in cand["suggest_xy"][0] and "delta_crossings_est" in cand["suggest_xy"][0]
|
||||
sight = build_sight(nodes, edges, mode="both", limit=10, cell=200.0)
|
||||
assert "hotspots" in sight and "blocks" in sight
|
||||
assert sight["blocks"]["blocks"]
|
||||
132
packages/netx-topology-mcp/tests/test_layout_stats.py
Normal file
132
packages/netx-topology-mcp/tests/test_layout_stats.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Tests for topology layout statistics / composite score."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_stats import (
|
||||
analyze_layout_stats,
|
||||
build_layout_report,
|
||||
score_layout_components,
|
||||
)
|
||||
|
||||
|
||||
def test_dense_vs_sparse_score() -> None:
|
||||
# Compact 2x2 grid at recommended pitch
|
||||
dense_nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
{"a_node_id": "d", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "a"},
|
||||
]
|
||||
dense = analyze_layout_stats(dense_nodes, edges)
|
||||
|
||||
sparse_nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 20000, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 17000},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 20000, "y": 17000},
|
||||
]
|
||||
sparse = analyze_layout_stats(sparse_nodes, edges)
|
||||
|
||||
assert dense["space_utilization"] > sparse["space_utilization"]
|
||||
assert dense["whitespace_index"] < sparse["whitespace_index"]
|
||||
assert dense["score"]["total"] > sparse["score"]["total"]
|
||||
assert dense["grid_occupancy"] > sparse["grid_occupancy"]
|
||||
|
||||
|
||||
def test_overlap_hard_gates_score() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
|
||||
]
|
||||
edges = [{"a_node_id": "a", "b_node_id": "b"}]
|
||||
m = analyze_layout_stats(nodes, edges)
|
||||
assert m["footprint_overlap_pairs"] >= 1
|
||||
assert m["score"]["parts"]["overlap"] == 0.0
|
||||
assert m["score"]["total"] < 20.0
|
||||
|
||||
|
||||
def test_score_components_rank_key() -> None:
|
||||
good = {
|
||||
"node_count": 100,
|
||||
"edge_crossings": 10,
|
||||
"crossings_per_link": 0.05,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.2,
|
||||
"hull_utilization": 0.25,
|
||||
"grid_occupancy": 0.4,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.2,
|
||||
}
|
||||
s = score_layout_components(good)
|
||||
assert s["total"] > 50
|
||||
assert s["rank_key"][0] == 0
|
||||
|
||||
|
||||
def test_top_crossing_nodes_in_summary() -> None:
|
||||
# Classic X: AC and BD cross at center → all four endpoints participate.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
]
|
||||
m = analyze_layout_stats(nodes, edges)
|
||||
assert m["edge_crossings"] == 1
|
||||
top = m.get("top_crossing_nodes") or []
|
||||
assert len(top) == 4
|
||||
assert all(int(r["crossing_hits"]) >= 1 for r in top)
|
||||
assert (m.get("summary") or {}).get("top_crossing")
|
||||
assert (m.get("report") or {}).get("crossing", {}).get("top_nodes")
|
||||
top_e = m.get("top_crossing_edges") or []
|
||||
assert len(top_e) == 2
|
||||
assert all(int(r["crossing_hits"]) == 1 for r in top_e)
|
||||
assert (m.get("summary") or {}).get("top_crossing_edges")
|
||||
assert (m.get("report") or {}).get("crossing", {}).get("top_edges")
|
||||
|
||||
|
||||
def test_unified_report_facets() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
{"a_node_id": "d", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "a"},
|
||||
]
|
||||
full = analyze_layout_stats(nodes, edges)
|
||||
r = full["report"]
|
||||
assert set(r) >= {
|
||||
"verdict",
|
||||
"size",
|
||||
"overlap",
|
||||
"crossing",
|
||||
"spacing",
|
||||
"sparsity",
|
||||
"edges",
|
||||
"chains",
|
||||
"rings",
|
||||
"score",
|
||||
"guide",
|
||||
}
|
||||
assert r["overlap"]["status"] == "ok"
|
||||
assert r["sparsity"]["status"] in {"ok", "warn", "fail"}
|
||||
assert "chain" in r["score"]["parts"]
|
||||
assert "rings" in r["score"]["parts"]
|
||||
built = build_layout_report(full)
|
||||
assert built["verdict"]["total"] == r["verdict"]["total"]
|
||||
123
packages/netx-topology-mcp/tests/test_layout_structure.py
Normal file
123
packages/netx-topology-mcp/tests/test_layout_structure.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Structure / gravity detection for layout planning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
||||
|
||||
|
||||
def _nodes_edges_core_bar():
|
||||
"""Two cores own access rings; ANs are low-degree decorative."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-a", "role": "core", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c2", "name": "X-CN2-a", "role": "core", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-a", "role": "aggregation", "x": 100, "y": 50},
|
||||
{"fabric_node_id": "a2", "name": "X-AN2-a", "role": "aggregation", "x": 300, "y": 50},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c1", "b_node_id": "c2"},
|
||||
{"a_node_id": "c1", "b_node_id": "a1"},
|
||||
{"a_node_id": "c2", "b_node_id": "a2"},
|
||||
]
|
||||
# Access petals hanging on cores
|
||||
for i in range(12):
|
||||
eid = f"e{i}"
|
||||
nodes.append(
|
||||
{
|
||||
"fabric_node_id": eid,
|
||||
"name": f"X-EN{i}-a",
|
||||
"role": "access",
|
||||
"x": i * 40,
|
||||
"y": 200,
|
||||
}
|
||||
)
|
||||
hub = "c1" if i < 6 else "c2"
|
||||
edges.append({"a_node_id": hub, "b_node_id": eid})
|
||||
if i % 2 == 1:
|
||||
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _nodes_edges_agg_bar():
|
||||
"""No cores; ANs own access rings."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "Y-AN1-a", "role": "aggregation", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "Y-AN2-a", "role": "aggregation", "x": 400, "y": 0},
|
||||
]
|
||||
edges = [{"a_node_id": "a1", "b_node_id": "a2"}]
|
||||
for i in range(16):
|
||||
eid = f"e{i}"
|
||||
nodes.append(
|
||||
{
|
||||
"fabric_node_id": eid,
|
||||
"name": f"Y-EN{i}-a",
|
||||
"role": "access",
|
||||
"x": i * 30,
|
||||
"y": 200,
|
||||
}
|
||||
)
|
||||
hub = "a1" if i < 8 else "a2"
|
||||
edges.append({"a_node_id": hub, "b_node_id": eid})
|
||||
if i % 2 == 1:
|
||||
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_core_bar_gravity() -> None:
|
||||
nodes, edges = _nodes_edges_core_bar()
|
||||
s = analyze_graph_structure(nodes, edges)
|
||||
assert s["gravity"]["type"] == "core_bar"
|
||||
assert s["gravity"]["anchor_layer"] == "core"
|
||||
assert "agg" in s["gravity"]["decorative_layers"] or s["layers"]["agg"]["territory_frac"] < 0.2
|
||||
assert s["gravity"]["recipe_preference"][0] in {"compact", "corridor"}
|
||||
assert s["advice"]["skip_rings_first"] is True
|
||||
assert s["gravity"]["geometry_hint"] in {"core_center", "core_top"}
|
||||
|
||||
|
||||
def test_agg_bar_gravity() -> None:
|
||||
nodes, edges = _nodes_edges_agg_bar()
|
||||
s = analyze_graph_structure(nodes, edges)
|
||||
assert s["gravity"]["type"] == "agg_bar"
|
||||
assert s["gravity"]["anchor_layer"] == "agg"
|
||||
assert s["gravity"]["recipe_preference"][0] == "rings"
|
||||
assert s["advice"]["skip_rings_first"] is False
|
||||
assert len(s["hubs"]) >= 2
|
||||
|
||||
|
||||
def test_hubs_include_stubs() -> None:
|
||||
nodes, edges = _nodes_edges_agg_bar()
|
||||
s = analyze_graph_structure(nodes, edges, hub_top_k=4, stub_top_k=10)
|
||||
assert s["hubs"][0]["access_neighbors"] >= 1
|
||||
assert s["stubs"]
|
||||
assert "headline" in s
|
||||
|
||||
|
||||
def _nodes_edges_chains():
|
||||
"""Several path components (SID-TOB-like): mostly deg≤2, not hub-spoke."""
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
# three chains of lengths 12 / 8 / 5; one has a CN/AN on the path (still chain)
|
||||
chains = [
|
||||
[("c0", "Z-CN1-a", "core")]
|
||||
+ [(f"a{i}", f"Z-EN{i}-a", "access") for i in range(1, 12)],
|
||||
[(f"b{i}", f"Y-EN{i}-a", "access") for i in range(8)],
|
||||
[(f"d{i}", f"X-EN{i}-a", "access") for i in range(5)],
|
||||
]
|
||||
for ch in chains:
|
||||
for fid, name, role in ch:
|
||||
nodes.append({"fabric_node_id": fid, "name": name, "role": role, "x": 0, "y": 0})
|
||||
for i in range(len(ch) - 1):
|
||||
edges.append({"a_node_id": ch[i][0], "b_node_id": ch[i + 1][0]})
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_chains_gravity_before_hub_fallback() -> None:
|
||||
nodes, edges = _nodes_edges_chains()
|
||||
s = analyze_graph_structure(nodes, edges)
|
||||
assert s["shape"]["primary"] == "chains"
|
||||
assert s["gravity"]["type"] == "chains"
|
||||
assert s["gravity"]["geometry_hint"] == "chain_rows"
|
||||
assert s["gravity"]["recipe_preference"][0] == "corridor"
|
||||
assert s["advice"]["skip_rings_first"] is True
|
||||
assert s["advice"]["decompose_by_component"] is True
|
||||
assert s["shape"]["component_count"] == 3
|
||||
assert all(b["shape"] in {"chain", "tiny"} for b in s["shape"]["blocks"])
|
||||
185
packages/netx-topology-mcp/tests/test_layout_tool.py
Normal file
185
packages/netx-topology-mcp/tests/test_layout_tool.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Tests for layoutTopologyView MCP wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from netx_topology_mcp.http_tools import call_http_tool
|
||||
from netx_topology_mcp.layout_tool import build_params, list_layout_catalog, resolve_recipe, run_layout_on_graph
|
||||
|
||||
|
||||
def test_catalog_and_aliases() -> None:
|
||||
cat = list_layout_catalog()
|
||||
assert "rings" in cat["recipes"]
|
||||
assert "compact" in cat["recipes"]
|
||||
assert "corridor" in cat["recipes"]
|
||||
assert "unstick" in cat["recipes"]
|
||||
assert "compact_soft" not in cat["recipes"]
|
||||
assert "ortho_metro" not in cat["recipes"]
|
||||
assert "dual_mass" not in cat["recipes"]
|
||||
assert "balanced" in cat["presets"]
|
||||
assert "fix_overlaps" in cat["actions"]
|
||||
assert "orbit_sweep" in cat["actions"]
|
||||
assert "polish_crossings" in cat["actions"]
|
||||
assert "clear_edge_hits" in cat["actions"]
|
||||
assert "mass_merge" not in cat["actions"]
|
||||
assert "compose_orbit" not in cat["actions"]
|
||||
assert resolve_recipe("rings") == "agg_rings_v1"
|
||||
assert resolve_recipe("compact") == "smd_corridor_compact_v1"
|
||||
assert resolve_recipe("corridor") == "smd_corridor_v1"
|
||||
try:
|
||||
resolve_recipe("compact_soft")
|
||||
raise AssertionError("compact_soft should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_recipe" in str(e)
|
||||
p = build_params(preset="dense")
|
||||
assert p.target_util >= 0.12
|
||||
|
||||
|
||||
def test_run_layout_on_small_graph() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B-AN-1", "x": 10, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C-EN-2", "x": 20, "y": 0},
|
||||
{"fabric_node_id": "d", "name": "D-EN-3", "x": 30, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
]
|
||||
out = run_layout_on_graph(nodes, edges, recipe="corridor", preset="balanced")
|
||||
assert out["ok"] is True
|
||||
assert len(out["positions"]) == 4
|
||||
assert "verdict" in out
|
||||
assert out["overlap"]["status"] in {"ok", "warn", "fail"}
|
||||
|
||||
|
||||
def test_layout_topology_view_catalog_tool() -> None:
|
||||
out = call_http_tool("layoutTopologyView", {"catalog": True})
|
||||
assert out.get("isError") is not True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert "recipes" in payload
|
||||
assert "rings" in payload["recipes"]
|
||||
assert "compact" in payload["recipes"]
|
||||
assert "actions" in payload
|
||||
assert "fix_overlaps" in payload["actions"]
|
||||
|
||||
|
||||
def test_layout_topology_view_preview() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
|
||||
"nodes": [
|
||||
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
|
||||
],
|
||||
"edges": [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
],
|
||||
}
|
||||
with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}):
|
||||
out = call_http_tool(
|
||||
"layoutTopologyView",
|
||||
{"view_id": "v1", "recipe": "corridor", "preset": "loose", "mode": "preview"},
|
||||
)
|
||||
assert out.get("isError") is not True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["applied"] is False
|
||||
assert payload["mode"] == "preview"
|
||||
assert payload["verdict"]["total"] is not None
|
||||
|
||||
|
||||
def test_local_polish_refuses_crossing_regression() -> None:
|
||||
"""untangle apply must not silently worsen crossings."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H-AN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A-EN-1", "x": -200, "y": -40},
|
||||
{"fabric_node_id": "b", "name": "B-EN-2", "x": 200, "y": -40},
|
||||
{"fabric_node_id": "c", "name": "C-EN-3", "x": -200, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D-EN-4", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "e", "name": "E-EN-5", "x": 0, "y": 100},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
{"a_node_id": "h", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "d"},
|
||||
{"a_node_id": "a", "b_node_id": "d"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "e", "b_node_id": "a"},
|
||||
{"a_node_id": "e", "b_node_id": "b"},
|
||||
]
|
||||
graph = {"view": {"id": "v1", "name": "demo", "folder_id": "f1"}, "nodes": nodes, "edges": edges}
|
||||
bad = run_layout_on_graph(nodes, edges, action="untangle")
|
||||
worse = dict(bad)
|
||||
worse["crossing"] = {**(bad.get("crossing") or {}), "edge_crossings": 9999}
|
||||
worse["positions"] = bad["positions"]
|
||||
|
||||
def fake_http(method, path, body=None, **_kw):
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": graph}
|
||||
raise AssertionError("PATCH must not run on crossing regression")
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http):
|
||||
with patch(
|
||||
"netx_topology_mcp.http_tools.run_layout_on_graph",
|
||||
return_value=worse,
|
||||
):
|
||||
out = call_http_tool(
|
||||
"layoutTopologyView",
|
||||
{"view_id": "v1", "action": "untangle", "mode": "apply"},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("error") == "crossing_regression"
|
||||
assert payload.get("applied") is False
|
||||
|
||||
|
||||
def test_unpublished_action_rejected() -> None:
|
||||
out = call_http_tool(
|
||||
"layoutTopologyView",
|
||||
{"view_id": "v1", "action": "mass_merge", "mode": "preview"},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("ok") is False
|
||||
assert "unknown_action" in str(payload.get("error") or "")
|
||||
assert "mass_merge" not in (payload.get("actions") or {})
|
||||
|
||||
|
||||
def test_layout_topology_view_apply_patches() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
|
||||
"nodes": [
|
||||
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
|
||||
],
|
||||
"edges": [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
],
|
||||
}
|
||||
calls: list[tuple] = []
|
||||
|
||||
def fake_http(method, path, body=None, **_kw):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": graph}
|
||||
return {"ok": True, "data": {"updated": len((body or {}).get("positions") or [])}}
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http):
|
||||
out = call_http_tool(
|
||||
"layoutTopologyView",
|
||||
{"view_id": "v1", "recipe": "compact", "preset": "balanced", "mode": "apply"},
|
||||
)
|
||||
assert out.get("isError") is not True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["applied"] is True
|
||||
assert payload["updated"] >= 3
|
||||
assert any(m == "PATCH" for m, _p, _b in calls)
|
||||
331
packages/netx-topology-mcp/tests/test_mass_merge.py
Normal file
331
packages/netx-topology-mcp/tests/test_mass_merge.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"""Mass field roles + soft merge (attract/repulse instead of rigid islands)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops.dual_units import DualUnit
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.mass_field import (
|
||||
annotate_dual_unit,
|
||||
build_mass_field,
|
||||
capture_pass,
|
||||
evolve_chains_to_rings,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round
|
||||
from netx_topology_mcp.layout_tool import ACTIONS
|
||||
|
||||
|
||||
def test_actions_include_mass_merge() -> None:
|
||||
assert "mass_merge" not in ACTIONS
|
||||
|
||||
|
||||
def test_annotate_dual_unit_roles() -> None:
|
||||
unit = DualUnit(
|
||||
portal_a="pa",
|
||||
portal_b="pb",
|
||||
paths=[
|
||||
["pa", "r1", "r2", "pb"],
|
||||
["pa", "r3", "r4", "pb"],
|
||||
["pa", "r5", "r6", "r7", "pb"],
|
||||
],
|
||||
tails=[["t1", "t2"]],
|
||||
unit_id=0,
|
||||
)
|
||||
ann = annotate_dual_unit(unit)
|
||||
assert ann["nest_depth"] == 3
|
||||
assert ann["nodes"]["pa"]["role"] == "core"
|
||||
assert ann["nodes"]["r1"]["role"] == "ring"
|
||||
assert ann["nodes"]["t1"]["role"] == "chain"
|
||||
assert ann["nodes"]["r1"]["attract"] > ann["nodes"]["t1"]["attract"]
|
||||
assert ann["nodes"]["pa"]["attract"] > ann["nodes"]["r1"]["attract"]
|
||||
|
||||
|
||||
def _two_block_state():
|
||||
# Strong left dual eye + weak right chain attached by bridge.
|
||||
nodes = [
|
||||
{"fabric_node_id": "pa", "name": "PA", "x": 0, "y": 0, "layer": "agg"},
|
||||
{"fabric_node_id": "pb", "name": "PB", "x": 400, "y": 0, "layer": "agg"},
|
||||
{"fabric_node_id": "r1", "name": "R1", "x": 120, "y": 80, "layer": "access"},
|
||||
{"fabric_node_id": "r2", "name": "R2", "x": 280, "y": 80, "layer": "access"},
|
||||
{"fabric_node_id": "r3", "name": "R3", "x": 120, "y": -80, "layer": "access"},
|
||||
{"fabric_node_id": "r4", "name": "R4", "x": 280, "y": -80, "layer": "access"},
|
||||
{"fabric_node_id": "t1", "name": "T1", "x": 500, "y": 0, "layer": "access"},
|
||||
{"fabric_node_id": "t2", "name": "T2", "x": 700, "y": 0, "layer": "access"},
|
||||
{"fabric_node_id": "w1", "name": "W1", "x": 2200, "y": 40, "layer": "access"},
|
||||
{"fabric_node_id": "w2", "name": "W2", "x": 2400, "y": -40, "layer": "access"},
|
||||
{"fabric_node_id": "wc", "name": "WC", "x": 2000, "y": 0, "layer": "agg"},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "pa", "b_node_id": "r1"},
|
||||
{"a_node_id": "r1", "b_node_id": "r2"},
|
||||
{"a_node_id": "r2", "b_node_id": "pb"},
|
||||
{"a_node_id": "pa", "b_node_id": "r3"},
|
||||
{"a_node_id": "r3", "b_node_id": "r4"},
|
||||
{"a_node_id": "r4", "b_node_id": "pb"},
|
||||
{"a_node_id": "pb", "b_node_id": "t1"},
|
||||
{"a_node_id": "t1", "b_node_id": "t2"},
|
||||
{"a_node_id": "t2", "b_node_id": "wc"},
|
||||
{"a_node_id": "wc", "b_node_id": "w1"},
|
||||
{"a_node_id": "wc", "b_node_id": "w2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
groups = [
|
||||
{
|
||||
"key": "strong",
|
||||
"node_ids": ["pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"],
|
||||
"pivots": ["pa", "pb"],
|
||||
"cores": ["pa", "pb"],
|
||||
"soft": True,
|
||||
},
|
||||
{
|
||||
"key": "weak",
|
||||
"node_ids": ["wc", "w1", "w2"],
|
||||
"pivots": ["wc"],
|
||||
"cores": ["wc"],
|
||||
"soft": True,
|
||||
},
|
||||
]
|
||||
unit = DualUnit(
|
||||
portal_a="pa",
|
||||
portal_b="pb",
|
||||
paths=[
|
||||
["pa", "r1", "r2", "pb"],
|
||||
["pa", "r3", "r4", "pb"],
|
||||
],
|
||||
tails=[["t1", "t2"]],
|
||||
unit_id=0,
|
||||
)
|
||||
mass = build_mass_field(st, units=[unit], groups=groups)
|
||||
st.meta = {
|
||||
"compose_views": {
|
||||
"rigid_groups": groups,
|
||||
"mass_groups": groups,
|
||||
"soft": True,
|
||||
},
|
||||
"mass_field": mass,
|
||||
}
|
||||
return st, groups, mass
|
||||
|
||||
|
||||
def test_build_mass_field_tags_chain_and_bridge() -> None:
|
||||
st, groups, mass = _two_block_state()
|
||||
assert mass["nodes"]["t1"]["role"] == "chain"
|
||||
assert mass["nodes"]["r1"]["role"] == "ring"
|
||||
bridge = mass["edges"].get("t2|wc") or mass["edges"].get("wc|t2")
|
||||
assert bridge is not None
|
||||
assert bridge["role"] == "bridge"
|
||||
|
||||
|
||||
def test_capture_can_steal_chain_toward_strong_core() -> None:
|
||||
st, groups, mass = _two_block_state()
|
||||
# Pull weak leaves next to strong core so capture fires.
|
||||
st.positions["w1"] = (450.0, 20.0)
|
||||
st.positions["w2"] = (460.0, -20.0)
|
||||
st.positions["wc"] = (480.0, 0.0)
|
||||
mass2, report = capture_pass(
|
||||
st,
|
||||
mass,
|
||||
kappa_node=1.0,
|
||||
kappa_block=1.5,
|
||||
rho_ideal=8.0,
|
||||
ideal_len=200.0,
|
||||
)
|
||||
home = {}
|
||||
for g in mass2["groups"]:
|
||||
for n in g["node_ids"]:
|
||||
if n not in ("pa", "pb", "wc"):
|
||||
home.setdefault(n, g["key"])
|
||||
# At least some movement of membership or block absorb.
|
||||
assert report["stolen_nodes"] + report["stolen_blocks"] >= 1 or any(
|
||||
home.get(n) == "strong" for n in ("w1", "w2")
|
||||
)
|
||||
|
||||
|
||||
def test_evolve_chain_to_ring_when_second_corridor() -> None:
|
||||
# Single corridor + dangling path that completes a second corridor.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0, "layer": "agg"},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 300, "y": 0, "layer": "agg"},
|
||||
{"fabric_node_id": "u1", "name": "U1", "x": 100, "y": 60, "layer": "access"},
|
||||
{"fabric_node_id": "u2", "name": "U2", "x": 200, "y": 60, "layer": "access"},
|
||||
{"fabric_node_id": "d1", "name": "D1", "x": 100, "y": -60, "layer": "access"},
|
||||
{"fabric_node_id": "d2", "name": "D2", "x": 200, "y": -60, "layer": "access"},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "u1"},
|
||||
{"a_node_id": "u1", "b_node_id": "u2"},
|
||||
{"a_node_id": "u2", "b_node_id": "b"},
|
||||
{"a_node_id": "a", "b_node_id": "d1"},
|
||||
{"a_node_id": "d1", "b_node_id": "d2"},
|
||||
{"a_node_id": "d2", "b_node_id": "b"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
# Pretend lower path was tagged chain initially.
|
||||
mass = {
|
||||
"nodes": {
|
||||
"a": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
|
||||
"b": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
|
||||
"u1": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
|
||||
"u2": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
|
||||
"d1": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
|
||||
"d2": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
|
||||
},
|
||||
"edges": {},
|
||||
"groups": [],
|
||||
}
|
||||
out = evolve_chains_to_rings(st, mass)
|
||||
# If dual-unit finder sees both corridors, d1/d2 should promote.
|
||||
if (out.get("evolve") or {}).get("promoted_nodes", 0) > 0:
|
||||
assert out["nodes"]["d1"]["role"] == "ring"
|
||||
assert out["nodes"]["d1"]["attract"] > 0.7
|
||||
|
||||
|
||||
def test_mass_merge_shortens_bridge() -> None:
|
||||
st, groups, _mass = _two_block_state()
|
||||
L0 = math.hypot(
|
||||
st.positions["wc"][0] - st.positions["t2"][0],
|
||||
st.positions["wc"][1] - st.positions["t2"][1],
|
||||
)
|
||||
op = mass_merge_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=14,
|
||||
step=0.45,
|
||||
max_step=220.0,
|
||||
ideal_len=280.0,
|
||||
nn_floor=90.0,
|
||||
attract_k=1.2,
|
||||
repulse_k=0.7,
|
||||
gravity_k=0.6,
|
||||
group_sep_k=0.4,
|
||||
global_gravity_k=0.0,
|
||||
core_pull_k=0.8,
|
||||
protect_rigid="off",
|
||||
evolve_every=4,
|
||||
kappa_node=1.1,
|
||||
kappa_block=1.8,
|
||||
x_slack=800,
|
||||
capture=False,
|
||||
cluster_seed=False,
|
||||
use_dual_units=True,
|
||||
)
|
||||
assert op.op == "mass_merge"
|
||||
assert not (op.params or {}).get("reverted"), op.note
|
||||
L1 = math.hypot(
|
||||
op.state.positions["wc"][0] - op.state.positions["t2"][0],
|
||||
op.state.positions["wc"][1] - op.state.positions["t2"][1],
|
||||
)
|
||||
assert L1 < L0 * 0.95 or (op.params or {}).get("end_geo", 0) >= (
|
||||
op.params or {}
|
||||
).get("start_geo", 0)
|
||||
|
||||
|
||||
def test_mass_merge_cluster_centroids_round_pack() -> None:
|
||||
"""Dual-unit clusters reseated on a round pack (not a strip lattice)."""
|
||||
st, groups, _mass = _two_block_state()
|
||||
# Force a strip of group centroids.
|
||||
for n in ("pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"):
|
||||
st.positions[n] = (st.positions[n][0], 0.0)
|
||||
for n in ("wc", "w1", "w2"):
|
||||
st.positions[n] = (st.positions[n][0] + 5000.0, 5.0)
|
||||
op = mass_merge_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=8,
|
||||
fa2=True,
|
||||
use_dual_units=True,
|
||||
cluster_seed=True,
|
||||
sep_ideal=800.0,
|
||||
group_sep_k=1.2,
|
||||
global_gravity_k=0.4,
|
||||
capture=False,
|
||||
x_slack=2000,
|
||||
)
|
||||
assert (op.params or {}).get("use_dual_units") is True
|
||||
assert not (op.params or {}).get("reverted"), op.note
|
||||
# Two cluster centroids should not sit on a long horizontal line only.
|
||||
c0 = (
|
||||
0.5 * (op.state.positions["pa"][0] + op.state.positions["pb"][0]),
|
||||
0.5 * (op.state.positions["pa"][1] + op.state.positions["pb"][1]),
|
||||
)
|
||||
c1 = (
|
||||
(op.state.positions["wc"][0] + op.state.positions["w1"][0] + op.state.positions["w2"][0])
|
||||
/ 3.0,
|
||||
(op.state.positions["wc"][1] + op.state.positions["w1"][1] + op.state.positions["w2"][1])
|
||||
/ 3.0,
|
||||
)
|
||||
assert math.hypot(c0[0] - c1[0], c0[1] - c1[1]) > 200.0
|
||||
|
||||
|
||||
def test_mass_merge_fa2_spreads_stacks() -> None:
|
||||
"""FA2 mode: coincident nodes get unpacked; lin-log + long-range repulse run."""
|
||||
st, groups, _mass = _two_block_state()
|
||||
for n in st.positions:
|
||||
st.positions[n] = (10.0, 10.0)
|
||||
op = mass_merge_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=10,
|
||||
fa2=True,
|
||||
scaling=10.0,
|
||||
linlog=True,
|
||||
use_dual_units=True,
|
||||
gravity_k=0.6,
|
||||
group_sep_k=1.2,
|
||||
group_pack_k=1.3,
|
||||
sep_ideal=800.0,
|
||||
global_gravity_k=0.0,
|
||||
core_pull_k=0.1,
|
||||
cluster_seed=True,
|
||||
capture=False,
|
||||
x_slack=2000,
|
||||
)
|
||||
assert op.op == "mass_merge"
|
||||
assert not (op.params or {}).get("reverted"), op.note
|
||||
assert (op.params or {}).get("fa2") is True
|
||||
# Not a single hairball.
|
||||
xs = [op.state.positions[n][0] for n in op.state.positions]
|
||||
ys = [op.state.positions[n][1] for n in op.state.positions]
|
||||
assert max(xs) - min(xs) > 200.0 or max(ys) - min(ys) > 200.0
|
||||
assert float(op.params.get("end_sep_nn") or 0) > 80.0
|
||||
|
||||
|
||||
def test_mass_merge_separates_collapsed_groups() -> None:
|
||||
"""Two dual-unit groups stacked on top of each other → seed+sep spreads them."""
|
||||
st, groups, _mass = _two_block_state()
|
||||
# Collapse weak block onto strong block (hairball seed).
|
||||
for n in ("wc", "w1", "w2"):
|
||||
st.positions[n] = (
|
||||
st.positions["pa"][0] + 40.0,
|
||||
st.positions["pa"][1] + 20.0,
|
||||
)
|
||||
c0 = (
|
||||
0.5 * (st.positions["pa"][0] + st.positions["pb"][0]),
|
||||
0.5 * (st.positions["pa"][1] + st.positions["pb"][1]),
|
||||
)
|
||||
c1 = (
|
||||
(st.positions["wc"][0] + st.positions["w1"][0] + st.positions["w2"][0]) / 3.0,
|
||||
(st.positions["wc"][1] + st.positions["w1"][1] + st.positions["w2"][1]) / 3.0,
|
||||
)
|
||||
d0 = math.hypot(c0[0] - c1[0], c0[1] - c1[1])
|
||||
op = mass_merge_round(
|
||||
st,
|
||||
groups=groups,
|
||||
iters=12,
|
||||
use_dual_units=True,
|
||||
gravity_k=0.9,
|
||||
group_sep_k=1.2,
|
||||
group_pack_k=0.8,
|
||||
sep_ideal=900.0,
|
||||
global_gravity_k=0.0,
|
||||
core_pull_k=0.05,
|
||||
protect_rigid="off",
|
||||
capture=False,
|
||||
cluster_seed=True,
|
||||
x_slack=2000,
|
||||
)
|
||||
assert not (op.params or {}).get("reverted"), op.note
|
||||
assert float(op.params.get("end_sep_nn") or 0) > d0 + 80.0
|
||||
# Not blown to absurd spacing.
|
||||
assert float(op.params.get("end_sep_nn") or 0) < 4000.0
|
||||
|
|
@ -7,21 +7,38 @@ import subprocess
|
|||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
|
||||
from netx_topology_mcp.http_tools import (
|
||||
HTTP_MCP_TOOLS,
|
||||
call_http_tool,
|
||||
resolve_draw_target,
|
||||
tools_for_scopes,
|
||||
)
|
||||
from netx_topology_mcp.server import _fetch_scopes
|
||||
|
||||
|
||||
def test_tool_list_has_draw_and_query_tools() -> None:
|
||||
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
|
||||
assert len(names) == 14
|
||||
assert "sinkTopologyDualUnits" in names
|
||||
assert "copyTopologyViewNodes" in names
|
||||
assert "createTopologyFolder" in names
|
||||
assert "createTopologyView" in names
|
||||
assert "createTopologyView" not in names
|
||||
assert "listTopologyViews" not in names
|
||||
assert "queryTopologyFabricNodes" in names
|
||||
assert "listTopologyFabricNodes" not in names
|
||||
assert "searchTopologyFabricNodes" not in names
|
||||
assert "getTopologyFabricSummary" not in names
|
||||
assert "addTopologyViewNodes" in names
|
||||
assert "updateTopologyViewPositions" in names
|
||||
assert "queryTopologyEdges" in names
|
||||
assert "analyzeTopologyViewLayout" in names
|
||||
assert "layoutTopologyView" in names
|
||||
assert "getTopologyTree" in names
|
||||
assert "createTopologyManualEdge" not in names
|
||||
assert "populateTopologyView" not in names
|
||||
folder_tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "createTopologyFolder")
|
||||
assert "Does not create a canvas" not in str(folder_tool.get("description") or "")
|
||||
assert "view_id" in str(folder_tool.get("description") or "")
|
||||
|
||||
|
||||
def test_create_folder_requires_name() -> None:
|
||||
|
|
@ -31,20 +48,95 @@ def test_create_folder_requires_name() -> None:
|
|||
assert payload["error"] == "name_required"
|
||||
|
||||
|
||||
def test_create_folder_posts_region() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}}
|
||||
out = call_http_tool("createTopologyFolder", {"name": "华北", "sort_order": 1})
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][0] == "POST"
|
||||
assert mock_http.call_args[0][1] == "/v1/topology/folders"
|
||||
body = mock_http.call_args[1]["body"]
|
||||
def test_create_folder_posts_region_and_resolves_view() -> None:
|
||||
tree = {
|
||||
"root": {
|
||||
"id": "sys-root",
|
||||
"kind": "root",
|
||||
"children": [
|
||||
{
|
||||
"id": "f1",
|
||||
"name": "华北",
|
||||
"kind": "region",
|
||||
"views": [],
|
||||
"ne_count": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": "rm1",
|
||||
"name": "根图",
|
||||
"kind": "region",
|
||||
"is_system": True,
|
||||
"ne_count": 0,
|
||||
"views": [{"id": "v-root-map", "name": "根图", "kind": "physical"}],
|
||||
"children": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def fake_http(method: str, path: str, body: dict | None = None, **_kwargs):
|
||||
if method == "POST" and path == "/v1/topology/folders":
|
||||
return {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}}
|
||||
if method == "GET" and path == "/v1/topology/tree":
|
||||
return {"ok": True, "data": tree}
|
||||
return {"ok": False, "error": f"unexpected {method} {path}"}
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http) as mock_http:
|
||||
out = call_http_tool(
|
||||
"createTopologyFolder",
|
||||
{"name": "华北", "sort_order": 1, "locale": "zh"},
|
||||
)
|
||||
assert mock_http.call_count == 2
|
||||
post = mock_http.call_args_list[0]
|
||||
assert post[0][0] == "POST"
|
||||
assert post[0][1] == "/v1/topology/folders"
|
||||
body = post[1]["body"]
|
||||
assert body["name"] == "华北"
|
||||
assert body["kind"] == "region"
|
||||
assert body["sort_order"] == 1
|
||||
assert body["locale"] == "zh"
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["id"] == "f1"
|
||||
assert payload["view_id"] == "v-root-map"
|
||||
assert payload["canvas_folder_id"] == "rm1"
|
||||
assert "view_id" in str(payload.get("hint") or "")
|
||||
|
||||
|
||||
def test_resolve_draw_target_nested_region() -> None:
|
||||
tree = {
|
||||
"root": {
|
||||
"id": "sys",
|
||||
"children": [
|
||||
{
|
||||
"id": "nav",
|
||||
"views": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "rm",
|
||||
"name": "Root map",
|
||||
"is_system": True,
|
||||
"views": [{"id": "v0", "kind": "physical"}],
|
||||
"children": [
|
||||
{
|
||||
"id": "zone",
|
||||
"ne_count": 3,
|
||||
"views": [{"id": "vz", "kind": "physical"}],
|
||||
"children": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
tip = resolve_draw_target(tree, "zone")
|
||||
assert tip["view_id"] == "vz"
|
||||
assert tip["canvas_folder_id"] == "zone"
|
||||
assert tip["ne_count"] == 3
|
||||
|
||||
|
||||
def test_add_nodes_rejects_managed_ume_ids() -> None:
|
||||
|
|
@ -107,36 +199,22 @@ def test_update_positions_layout_filter() -> None:
|
|||
assert payload["updated"] == 5
|
||||
|
||||
|
||||
def test_create_view_requires_folder() -> None:
|
||||
out = call_http_tool("createTopologyView", {"name": "map1"})
|
||||
assert out.get("isError") is True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["error"] == "folder_id_required"
|
||||
def test_create_view_tool_removed() -> None:
|
||||
try:
|
||||
call_http_tool("createTopologyView", {"name": "map1", "folder_id": "f1"})
|
||||
raise AssertionError("createTopologyView should be unregistered")
|
||||
except ValueError as e:
|
||||
assert "unknown tool" in str(e)
|
||||
|
||||
|
||||
def test_create_view_posts_body() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"id": "v1", "name": "map1"}}
|
||||
out = call_http_tool(
|
||||
"createTopologyView",
|
||||
{"name": "map1", "folder_id": "f1", "kind": "custom"},
|
||||
)
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][0] == "POST"
|
||||
assert mock_http.call_args[0][1] == "/v1/topology/views"
|
||||
body = mock_http.call_args[1]["body"]
|
||||
assert body["name"] == "map1"
|
||||
assert body["folder_id"] == "f1"
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_query_edges_enriches_peers() -> None:
|
||||
def test_query_edges_defaults_to_adjacency() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"total": 1,
|
||||
"total": 2,
|
||||
"page": 1,
|
||||
"page_size": 100,
|
||||
"items": [
|
||||
{
|
||||
"a_node_id": "A",
|
||||
|
|
@ -145,28 +223,253 @@ def test_query_edges_enriches_peers() -> None:
|
|||
"b_name": "ne-b",
|
||||
"a_ip": "1.1.1.1",
|
||||
"b_ip": "2.2.2.2",
|
||||
}
|
||||
"a_port": "p1",
|
||||
"b_port": "p2",
|
||||
},
|
||||
{
|
||||
"a_node_id": "B",
|
||||
"b_node_id": "A",
|
||||
"a_name": "ne-b",
|
||||
"b_name": "ne-a",
|
||||
"a_port": "p3",
|
||||
"b_port": "p4",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["detail"] == "adjacency"
|
||||
assert payload["link_count"] == 1
|
||||
assert payload["links"][0]["link_count"] == 2
|
||||
assert "items" not in payload
|
||||
assert payload["peer_count"] == 1
|
||||
assert payload["peers"][0]["node_id"] == "B"
|
||||
assert payload["peers"][0]["link_count"] == 2
|
||||
assert payload["peers_complete"] is True
|
||||
|
||||
out_ports = call_http_tool(
|
||||
"queryTopologyEdges", {"node_id": "A", "detail": "ports", "page_size": 100}
|
||||
)
|
||||
ports = json.loads(out_ports["content"][0]["text"])
|
||||
assert ports["detail"] == "ports"
|
||||
assert len(ports["items"]) == 2
|
||||
|
||||
|
||||
def test_tools_for_scopes_filters_write() -> None:
|
||||
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
|
||||
assert "queryTopologyEdges" in read_only
|
||||
assert "createTopologyView" not in read_only
|
||||
assert "queryTopologyFabricNodes" in read_only
|
||||
assert "createTopologyFolder" not in read_only
|
||||
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
|
||||
assert "createTopologyView" in write
|
||||
assert "createTopologyView" not in write
|
||||
assert "createTopologyFolder" in write
|
||||
|
||||
|
||||
def test_query_fabric_nodes_modes() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"node_count": 3, "edge_count": 2}}
|
||||
out = call_http_tool("queryTopologyFabricNodes", {"mode": "summary"})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("mode") == "summary"
|
||||
assert mock_http.call_args[0][1] == "/v1/topology/fabric/summary"
|
||||
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {"items": [{"id": "n1", "name": "A"}], "total": 1, "page": 1, "page_size": 50},
|
||||
}
|
||||
out = call_http_tool("queryTopologyFabricNodes", {"q": "A"})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("mode") == "search"
|
||||
assert "/search" in mock_http.call_args[0][1]
|
||||
|
||||
out = call_http_tool(
|
||||
"queryTopologyFabricNodes",
|
||||
{"mode": "list", "keyword": "core", "role": "core"},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload.get("mode") == "list"
|
||||
assert mock_http.call_args[0][1] == "/v1/topology/fabric/nodes"
|
||||
assert mock_http.call_args[1]["params"]["keyword"] == "core"
|
||||
|
||||
|
||||
def test_get_topology_tree_compacts_by_default() -> None:
|
||||
tree = {
|
||||
"root": {
|
||||
"id": "r",
|
||||
"name": "Network",
|
||||
"kind": "root",
|
||||
"ne_count": 2,
|
||||
"external_ref": "drop-me",
|
||||
"views": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "a",
|
||||
"name": "RootA",
|
||||
"kind": "region",
|
||||
"ne_count": 2,
|
||||
"views": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "rm",
|
||||
"name": "根图",
|
||||
"kind": "region",
|
||||
"is_system": True,
|
||||
"ne_count": 2,
|
||||
"views": [{"id": "v1", "name": "根图", "kind": "physical", "node_count": 2}],
|
||||
"children": [
|
||||
{
|
||||
"id": "deep",
|
||||
"name": "Deep",
|
||||
"kind": "region",
|
||||
"ne_count": 0,
|
||||
"views": [],
|
||||
"children": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": tree}
|
||||
out = call_http_tool("getTopologyTree", {"max_depth": 1})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["compact"] is True
|
||||
assert "external_ref" not in payload["root"]
|
||||
child = payload["root"]["children"][0]
|
||||
assert child["name"] == "RootA"
|
||||
# depth 1: grandchildren collapsed
|
||||
assert child["children_truncated"] == 1
|
||||
assert child["children"] == []
|
||||
|
||||
|
||||
def test_get_topology_view_defaults_to_summary() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "根图", "folder_id": "f1", "kind": "physical", "node_count": 2},
|
||||
"nodes": [
|
||||
{"fabric_node_id": "n1", "name": "A", "ip": "1.1.1.1", "x": 1, "y": 2},
|
||||
{"fabric_node_id": "n2", "name": "B", "ip": "2.2.2.2", "x": 3, "y": 4},
|
||||
],
|
||||
"edges": [{"id": "e1", "a_node_id": "n1", "b_node_id": "n2", "display_label": "huge" * 50}],
|
||||
"outside_peers": [{"fabric_node_id": "n3"}],
|
||||
"truncated": False,
|
||||
}
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": graph}
|
||||
out = call_http_tool("getTopologyView", {"view_id": "v1"})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["detail"] == "summary"
|
||||
assert payload["node_count"] == 2
|
||||
assert payload["edge_count"] == 1
|
||||
assert payload["link_count"] == 1
|
||||
assert payload["links"][0]["a_node_id"] == "n1"
|
||||
assert payload["links"][0]["b_node_id"] == "n2"
|
||||
assert "edges" not in payload
|
||||
assert len(payload["sample_nodes"]) == 2
|
||||
|
||||
out_full = call_http_tool("getTopologyView", {"view_id": "v1", "detail": "full"})
|
||||
full = json.loads(out_full["content"][0]["text"])
|
||||
assert full["detail"] == "full"
|
||||
assert len(full["edges"]) == 1
|
||||
|
||||
|
||||
def test_project_neighbors_defaults_to_summary() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "根图", "node_count": 3},
|
||||
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
|
||||
"edges": [],
|
||||
"outside_peers": [],
|
||||
"truncated": True,
|
||||
"truncate_reason": "membership_cap",
|
||||
}
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": graph}
|
||||
out = call_http_tool("projectTopologyNeighbors", {"view_id": "v1"})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["detail"] == "summary"
|
||||
assert payload["projected"] is True
|
||||
assert payload["truncated"] is True
|
||||
assert "edges" not in payload
|
||||
|
||||
|
||||
def test_project_neighbors_passes_region_folder_and_reports_skips() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "根图", "node_count": 2},
|
||||
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
|
||||
"edges": [],
|
||||
"outside_peers": [],
|
||||
"out_of_region_skipped": 3,
|
||||
"out_of_region_sample": [
|
||||
{"fabric_node_id": "x1", "name": "X", "region_folder_id": "other"}
|
||||
],
|
||||
}
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": graph}
|
||||
out = call_http_tool(
|
||||
"projectTopologyNeighbors",
|
||||
{"view_id": "v1", "region_folder_id": "reg-A"},
|
||||
)
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["projected"] is True
|
||||
assert payload["region_folder_id"] == "reg-A"
|
||||
assert payload["out_of_region_skipped"] == 3
|
||||
assert payload["out_of_region_sample"][0]["fabric_node_id"] == "x1"
|
||||
assert mock_http.call_args.kwargs["body"]["region_folder_id"] == "reg-A"
|
||||
|
||||
|
||||
def test_neighborhood_returns_links_not_ports() -> None:
|
||||
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"center_node_id": "A",
|
||||
"depth": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "A",
|
||||
"name": "ne-a",
|
||||
"ip": "1.1.1.1",
|
||||
"attrs": {"sources": ["ume"], "huge": "x" * 200},
|
||||
"world_x": 1,
|
||||
"world_y": 2,
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "e1",
|
||||
"a_node_id": "A",
|
||||
"b_node_id": "B",
|
||||
"source": "ume",
|
||||
"status": "active",
|
||||
"a_port": "p1",
|
||||
"attrs": {"display_label": "LONG" * 40},
|
||||
},
|
||||
{
|
||||
"id": "e2",
|
||||
"a_node_id": "A",
|
||||
"b_node_id": "B",
|
||||
"source": "ume",
|
||||
"status": "active",
|
||||
"a_port": "p2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
out = call_http_tool("queryTopologyNeighborhood", {"node_id": "A"})
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["node_count"] == 1
|
||||
assert payload["edge_count"] == 2
|
||||
assert payload["link_count"] == 1
|
||||
assert payload["links"][0]["link_count"] == 2
|
||||
assert "edges" not in payload
|
||||
assert "attrs" not in payload["nodes"][0]
|
||||
assert "world_x" not in payload["nodes"][0]
|
||||
|
||||
|
||||
def test_fetch_scopes_unwraps_envelope() -> None:
|
||||
with patch("netx_topology_mcp.server.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}}
|
||||
|
|
@ -202,6 +505,69 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
list_resp = json.loads(proc.stdout.readline())
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 14
|
||||
names = {t["name"] for t in tools}
|
||||
assert "createTopologyView" not in names
|
||||
assert "listTopologyViews" not in names
|
||||
assert "queryTopologyFabricNodes" in names
|
||||
assert "createTopologyFolder" in names
|
||||
assert "analyzeTopologyViewLayout" in names
|
||||
assert "layoutTopologyView" in names
|
||||
assert "sinkTopologyDualUnits" in names
|
||||
assert "copyTopologyViewNodes" in names
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def test_layout_metrics_crossing_and_spacing() -> None:
|
||||
from netx_topology_mcp.layout_metrics import analyze_positions, grade_layout, segments_properly_intersect
|
||||
|
||||
assert segments_properly_intersect((0, 0), (2, 2), (0, 2), (2, 0))
|
||||
assert not segments_properly_intersect((0, 0), (1, 0), (1, 0), (2, 0)) # shared endpoint
|
||||
|
||||
# X crossing
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
]
|
||||
m = analyze_positions(nodes, edges)
|
||||
assert m["edge_crossings"] == 1
|
||||
assert m["link_count"] == 2
|
||||
g = grade_layout(m)
|
||||
assert g["overall"] in {"ok", "warn", "fail"}
|
||||
|
||||
|
||||
def test_analyze_topology_view_layout_single() -> None:
|
||||
graph = {
|
||||
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
|
||||
"nodes": [
|
||||
{"fabric_node_id": "a", "name": "N-A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "N-B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "N-C", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "N-D", "x": 200, "y": 200},
|
||||
],
|
||||
"edges": [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
{"a_node_id": "d", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "a"},
|
||||
],
|
||||
}
|
||||
|
||||
with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}):
|
||||
out = call_http_tool("analyzeTopologyViewLayout", {"view_id": "v1"})
|
||||
assert out.get("isError") is not True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
assert payload["size"]["links"] == 4
|
||||
assert payload["crossing"]["edge_crossings"] == 0
|
||||
assert payload["overlap"]["status"] == "ok"
|
||||
assert "sparsity" in payload
|
||||
assert "verdict" in payload
|
||||
assert "total" in payload["verdict"]
|
||||
|
|
|
|||
36
packages/netx-topology-mcp/tests/test_move_nodes.py
Normal file
36
packages/netx-topology-mcp/tests/test_move_nodes.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""layoutTopologyView action=move_nodes registration + validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, _move_topology_view_nodes
|
||||
from netx_topology_mcp.layout_tool import list_layout_catalog
|
||||
|
||||
|
||||
def test_move_nodes_in_layout_schema_and_catalog() -> None:
|
||||
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
|
||||
enum = tool["inputSchema"]["properties"]["action"]["enum"]
|
||||
assert "move_nodes" in enum
|
||||
assert "sink_nodes" in enum
|
||||
cat = list_layout_catalog()
|
||||
assert "move_nodes" in cat["actions"]
|
||||
assert "sink_nodes" in cat["actions"]
|
||||
|
||||
|
||||
def test_move_nodes_requires_ids_and_views() -> None:
|
||||
assert _move_topology_view_nodes({}).get("error") == "view_id_and_source_view_id_required"
|
||||
assert (
|
||||
_move_topology_view_nodes(
|
||||
{
|
||||
"view_id": "a",
|
||||
"source_view_id": "a",
|
||||
"params": {"fabric_node_ids": ["x"]},
|
||||
}
|
||||
).get("error")
|
||||
== "source_and_dest_must_differ"
|
||||
)
|
||||
assert (
|
||||
_move_topology_view_nodes(
|
||||
{"view_id": "a", "source_view_id": "b", "params": {}}
|
||||
).get("error")
|
||||
== "fabric_node_ids_required"
|
||||
)
|
||||
176
packages/netx-topology-mcp/tests/test_orbit_sweep.py
Normal file
176
packages/netx-topology-mcp/tests/test_orbit_sweep.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Tests for polar orbit_sweep top-3 suggest / round apply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
||||
apply_orbit_pick,
|
||||
orbit_sweep_node,
|
||||
orbit_sweep_round,
|
||||
)
|
||||
from netx_topology_mcp.layout_tool import run_layout_on_graph
|
||||
|
||||
|
||||
def _crossed_pair():
|
||||
# a—b horizontal crosses c—d vertical at center; free node e tethered to c.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0},
|
||||
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0},
|
||||
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 200.0, "y": 0.0},
|
||||
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0},
|
||||
{"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
{"a_node_id": "c", "b_node_id": "e"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_orbit_sweep_node_returns_top3_with_gain() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
assert g0 >= 1
|
||||
# Move endpoint d — polar sweep should find a non-crossing slot.
|
||||
out = orbit_sweep_node(
|
||||
st,
|
||||
"d",
|
||||
protect_rigid="off",
|
||||
max_jump=500,
|
||||
angle_step=15,
|
||||
nn_floor=20.0,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
cands = out["candidates"]
|
||||
assert 1 <= len(cands) <= 3
|
||||
assert cands[0]["rank"] == 1
|
||||
assert "x" in cands[0] and "y" in cands[0]
|
||||
assert cands[0]["crossings"]["global"] <= g0
|
||||
# Prefer at least one improving candidate on this toy cross.
|
||||
assert out["improving_n"] >= 1 or cands[0]["delta"]["global"] <= 0
|
||||
|
||||
|
||||
def test_orbit_top3_angular_diversity() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
out = orbit_sweep_node(
|
||||
st, "d", protect_rigid="off", max_jump=600, min_angle_sep=35.0
|
||||
)
|
||||
cands = out["candidates"]
|
||||
if len(cands) >= 2:
|
||||
for i in range(len(cands)):
|
||||
for j in range(i + 1, len(cands)):
|
||||
a = float(cands[i]["angle_deg"])
|
||||
b = float(cands[j]["angle_deg"])
|
||||
d = abs(a - b) % 360.0
|
||||
d = d if d <= 180 else 360 - d
|
||||
r0 = max(float(cands[i]["r"]), 1.0)
|
||||
r1 = max(float(cands[j]["r"]), 1.0)
|
||||
ratio = max(r0, r1) / min(r0, r1)
|
||||
# Either angularly separated or clearly different radius.
|
||||
assert d >= 34.0 or ratio >= 1.29
|
||||
|
||||
|
||||
def test_apply_orbit_pick_moves_to_rank2() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
sweep = orbit_sweep_node(st, "d", protect_rigid="off", max_jump=600)
|
||||
assert sweep["ok"]
|
||||
cands = sweep["candidates"]
|
||||
assert len(cands) >= 1
|
||||
pick = 2 if len(cands) >= 2 else 1
|
||||
op = apply_orbit_pick(st, sweep, pick=pick)
|
||||
assert "d" in op.moved
|
||||
chosen = cands[pick - 1]
|
||||
assert abs(op.state.positions["d"][0] - chosen["x"]) < 0.2
|
||||
assert abs(op.state.positions["d"][1] - chosen["y"]) < 0.2
|
||||
|
||||
|
||||
def test_orbit_sweep_round_does_not_raise_crossings() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
g0 = count_edge_crossings(st.positions, st.links)
|
||||
op = orbit_sweep_round(
|
||||
st,
|
||||
top_n=4,
|
||||
max_degree=9,
|
||||
protect_rigid="off",
|
||||
max_jump=600,
|
||||
)
|
||||
g1 = count_edge_crossings(op.state.positions, op.state.links)
|
||||
assert g1 <= g0
|
||||
assert (op.params or {}).get("end_crossings") == g1
|
||||
|
||||
|
||||
def test_run_layout_orbit_sweep_preview_keeps_coords() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
out = run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="orbit_sweep",
|
||||
params={"node_id": "d", "protect_rigid": "off", "max_jump": 600},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "orbit_sweep"
|
||||
sweep = (out.get("local") or {}).get("sweep") or {}
|
||||
assert sweep.get("candidates")
|
||||
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
|
||||
assert abs(by_id["d"]["x"] - 200.0) < 0.2
|
||||
assert abs(by_id["d"]["y"] - 400.0) < 0.2
|
||||
|
||||
|
||||
def test_run_layout_orbit_sweep_pick_applies() -> None:
|
||||
nodes, edges = _crossed_pair()
|
||||
out = run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="orbit_sweep",
|
||||
params={
|
||||
"node_id": "d",
|
||||
"pick": 1,
|
||||
"protect_rigid": "off",
|
||||
"max_jump": 600,
|
||||
},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
pick = (out.get("local") or {}).get("pick")
|
||||
assert pick == 1
|
||||
sweep = (out.get("local") or {}).get("sweep") or {}
|
||||
chosen = (sweep.get("candidates") or [{}])[0]
|
||||
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
|
||||
# normalize_origin shifts all coords; check relative to fixed peer a.
|
||||
assert abs((by_id["d"]["x"] - by_id["a"]["x"]) - (chosen["x"] - 0.0)) < 0.2
|
||||
assert abs((by_id["d"]["y"] - by_id["a"]["y"]) - (chosen["y"] - 200.0)) < 0.2
|
||||
|
||||
|
||||
def test_catalog_lists_orbit_sweep() -> None:
|
||||
from netx_topology_mcp.layout_tool import list_layout_catalog
|
||||
|
||||
cat = list_layout_catalog()
|
||||
assert "orbit_sweep" in cat["actions"]
|
||||
|
||||
|
||||
def test_orbit_default_protect_off_ignores_portal_freeze() -> None:
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides
|
||||
|
||||
knobs = orbit_params_from_overrides({"node_id": "d", "portal_ids": ["d"]})
|
||||
assert knobs["protect_rigid"] == "off"
|
||||
nodes, edges = _crossed_pair()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
# frozen_ids present but protect default off → still sweeps.
|
||||
out = orbit_sweep_node(st, "d", frozen_ids={"d"}, max_jump=500, nn_floor=20.0)
|
||||
assert out["ok"] is True
|
||||
assert out.get("candidates")
|
||||
frozen = orbit_sweep_node(
|
||||
st, "d", protect_rigid="portals", frozen_ids={"d"}, max_jump=500
|
||||
)
|
||||
assert frozen["ok"] is False
|
||||
assert frozen.get("error") == "frozen"
|
||||
113
packages/netx-topology-mcp/tests/test_ortho_metro.py
Normal file
113
packages/netx-topology-mcp/tests/test_ortho_metro.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Tests for ortho_metro (multi-layer H/V metro recipe)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
|
||||
from netx_topology_mcp.layout_ops.ortho_metro import (
|
||||
_axis_ok,
|
||||
build_ortho_metro_skeleton,
|
||||
)
|
||||
from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph
|
||||
|
||||
|
||||
def _core_bar_graph():
|
||||
"""Two CN cores + AN stubs + one deg-2 corridor + one triangle."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t0", "name": "X-AN2-Y", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "t1", "name": "X-AN3-Y", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c0", "b_node_id": "c1"},
|
||||
{"a_node_id": "c0", "b_node_id": "a0"},
|
||||
{"a_node_id": "c1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a0", "b_node_id": "e0"},
|
||||
{"a_node_id": "e0", "b_node_id": "e1"},
|
||||
# triangle on a0
|
||||
{"a_node_id": "a0", "b_node_id": "t0"},
|
||||
{"a_node_id": "t0", "b_node_id": "t1"},
|
||||
{"a_node_id": "t1", "b_node_id": "a0"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_recipe_alias() -> None:
|
||||
# ortho_metro module kept; unpublished from public recipe aliases.
|
||||
assert "ortho_metro" not in RECIPE_ALIASES
|
||||
|
||||
|
||||
def test_build_ortho_metro_places_all() -> None:
|
||||
nodes, edges = _core_bar_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = build_ortho_metro_skeleton(st, LayoutParams())
|
||||
assert op.op == "build_ortho_metro_skeleton"
|
||||
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
|
||||
assert op.state.meta.get("rings_mode") == "ortho_metro"
|
||||
meta = op.state.meta.get("ortho_metro") or {}
|
||||
assert int(meta.get("axis_edges") or 0) + int(meta.get("diag_edges") or 0) == len(
|
||||
op.state.links
|
||||
)
|
||||
|
||||
|
||||
def test_ortho_metro_mostly_axis_aligned() -> None:
|
||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
||||
|
||||
nodes, edges = _core_bar_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=True)
|
||||
pos = op.state.positions
|
||||
axis = 0
|
||||
diag = 0
|
||||
for a, b in op.state.links:
|
||||
if _axis_ok(pos[a], pos[b]):
|
||||
axis += 1
|
||||
else:
|
||||
diag += 1
|
||||
# Ortho first; clearance may break one chord to kill edge-through-node.
|
||||
assert axis >= len(op.state.links) - 2
|
||||
assert diag <= 2
|
||||
clr = compute_edge_clearance(pos, op.state.links, names=op.state.names, thr=40.0)
|
||||
assert int(clr.get("edge_clearance_hits") or 0) == 0
|
||||
|
||||
|
||||
def test_ortho_metro_no_footprint_stack() -> None:
|
||||
nodes, edges = _core_bar_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = build_ortho_metro_skeleton(st, LayoutParams(pitch=200, side=170))
|
||||
ids = list(op.state.positions)
|
||||
for i, a in enumerate(ids):
|
||||
for b in ids[i + 1 :]:
|
||||
x1, y1 = op.state.positions[a]
|
||||
x2, y2 = op.state.positions[b]
|
||||
assert math.hypot(x2 - x1, y2 - y1) >= 140.0
|
||||
|
||||
|
||||
def test_run_recipe_and_layout_tool_unpublished() -> None:
|
||||
nodes, edges = _core_bar_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
try:
|
||||
run_recipe(st, "ortho_metro_v1", LayoutParams())
|
||||
raise AssertionError("ortho_metro_v1 should be unpublished")
|
||||
except Exception as e:
|
||||
assert "ortho" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
|
||||
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="layout", recipe="ortho_metro")
|
||||
raise AssertionError("ortho_metro recipe should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_recipe" in str(e)
|
||||
|
||||
|
||||
def test_skip_triangles_false_tries_all() -> None:
|
||||
nodes, edges = _core_bar_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=False)
|
||||
# Still places everyone; may leave 0 diags if coplanar H on triangle.
|
||||
assert len(op.state.positions) == len(nodes)
|
||||
166
packages/netx-topology-mcp/tests/test_partition.py
Normal file
166
packages/netx-topology-mcp/tests/test_partition.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Soft partition: hub territory (always) + optional igraph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops import (
|
||||
build_state_from_nodes_edges,
|
||||
igraph_available,
|
||||
list_blocks,
|
||||
pack_block_centers,
|
||||
partition_soft_blocks,
|
||||
)
|
||||
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
||||
|
||||
|
||||
def _star_graph():
|
||||
# Two hubs + private stubs + shared leftover chain
|
||||
nodes = [
|
||||
{"fabric_node_id": "h1", "name": "H1-AN-1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "h2", "name": "H2-AN-1", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 20},
|
||||
{"fabric_node_id": "a2", "name": "A2-EN-1", "x": 0, "y": 40},
|
||||
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 100, "y": 20},
|
||||
{"fabric_node_id": "b2", "name": "B2-EN-1", "x": 100, "y": 40},
|
||||
{"fabric_node_id": "z1", "name": "Z1-EN-1", "x": 50, "y": 80},
|
||||
{"fabric_node_id": "z2", "name": "Z2-EN-1", "x": 50, "y": 100},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "h2", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "h1", "b_node_id": "h2"},
|
||||
{"a_node_id": "a2", "b_node_id": "z1"},
|
||||
{"a_node_id": "z1", "b_node_id": "z2"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_hub_territory_splits_by_hub() -> None:
|
||||
nodes, edges = _star_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
blocks = partition_soft_blocks(st, mode="hub_territory")
|
||||
methods = {b.method for b in blocks}
|
||||
assert "hub_territory" in methods
|
||||
by_hub = {b.hub_id: set(b.node_ids) for b in blocks if b.hub_id}
|
||||
assert "h1" in by_hub and "h2" in by_hub
|
||||
assert "a1" in by_hub["h1"] and "a2" in by_hub["h1"]
|
||||
assert "b1" in by_hub["h2"] and "b2" in by_hub["h2"]
|
||||
# z* claimed via a2 → h1 territory (first BFS from stubs)
|
||||
assert "z1" in by_hub["h1"] or any(
|
||||
"z1" in b.node_ids for b in blocks if b.method == "leftover"
|
||||
)
|
||||
|
||||
|
||||
def test_list_blocks_hub_territory_mode() -> None:
|
||||
nodes, edges = _star_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
sets = list_blocks(st, mode="hub_territory")
|
||||
assert len(sets) >= 2
|
||||
assert all(isinstance(s, set) and s for s in sets)
|
||||
|
||||
|
||||
def test_pack_block_centers_grid_fallback() -> None:
|
||||
nodes, edges = _star_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
blocks = partition_soft_blocks(st, mode="hub_territory")
|
||||
centers = pack_block_centers(st, blocks)
|
||||
assert len(centers) == len(blocks)
|
||||
assert all(isinstance(xy, tuple) and len(xy) == 2 for xy in centers.values())
|
||||
|
||||
|
||||
def test_structure_includes_soft_blocks() -> None:
|
||||
nodes, edges = _star_graph()
|
||||
struct = analyze_graph_structure(nodes, edges)
|
||||
sb = struct.get("soft_blocks") or {}
|
||||
assert sb.get("block_count", 0) >= 2
|
||||
assert "blocks" in sb
|
||||
assert "igraph" in sb
|
||||
assert struct["advice"].get("decompose_soft_blocks") in {True, False}
|
||||
|
||||
|
||||
def test_leiden_mode_falls_back_without_crash() -> None:
|
||||
nodes, edges = _star_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
blocks = partition_soft_blocks(st, mode="leiden")
|
||||
assert blocks
|
||||
# With or without igraph, we still get a partition.
|
||||
assert sum(len(b.node_ids) for b in blocks) >= len(st.positions)
|
||||
_ = igraph_available() # smoke
|
||||
|
||||
|
||||
def test_resolve_auto_small_graph_stays_component() -> None:
|
||||
from netx_topology_mcp.layout_ops import resolve_block_mode
|
||||
|
||||
nodes, edges = _star_graph()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
# 8 nodes < 40 → auto keeps component
|
||||
assert resolve_block_mode(st, "auto") == "component"
|
||||
|
||||
|
||||
def test_pack_soft_blocks_moves_territories_apart() -> None:
|
||||
from netx_topology_mcp.layout_ops import pack_soft_blocks
|
||||
|
||||
nodes, edges = _star_graph()
|
||||
# Stack both hubs on top of each other so pack must separate
|
||||
for n in nodes:
|
||||
if n["fabric_node_id"] in {"h1", "a1", "a2"}:
|
||||
n["x"], n["y"] = 0.0, 0.0
|
||||
else:
|
||||
n["x"], n["y"] = 5.0, 5.0
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
before = dict(st.positions)
|
||||
out = pack_soft_blocks(st, mode="hub_territory")
|
||||
assert out.op == "pack_soft_blocks"
|
||||
assert len(out.moved) >= 2
|
||||
# centroids of the two hub blocks should diverge
|
||||
blocks = partition_soft_blocks(out.state, mode="hub_territory")
|
||||
hubs = [b for b in blocks if b.hub_id]
|
||||
assert len(hubs) >= 2
|
||||
|
||||
def centroid(ids):
|
||||
pts = [out.state.positions[i] for i in ids if i in out.state.positions]
|
||||
return (
|
||||
sum(p[0] for p in pts) / len(pts),
|
||||
sum(p[1] for p in pts) / len(pts),
|
||||
)
|
||||
|
||||
c0, c1 = centroid(hubs[0].node_ids), centroid(hubs[1].node_ids)
|
||||
dist = ((c0[0] - c1[0]) ** 2 + (c0[1] - c1[1]) ** 2) ** 0.5
|
||||
assert dist > 50.0
|
||||
# something moved vs stacked start
|
||||
assert any(out.state.positions[n] != before[n] for n in out.moved)
|
||||
|
||||
|
||||
def test_pack_soft_blocks_preserves_spread_constellation() -> None:
|
||||
from netx_topology_mcp.layout_ops import pack_soft_blocks
|
||||
|
||||
nodes, edges = _star_graph()
|
||||
# Already-spread hubs: preserve strategy densifies but keeps left/right order
|
||||
for n in nodes:
|
||||
fid = n["fabric_node_id"]
|
||||
if fid in {"h1", "a1", "a2"}:
|
||||
n["x"], n["y"] = 0.0, 0.0
|
||||
elif fid in {"h2", "b1", "b2"}:
|
||||
n["x"], n["y"] = 2000.0, 0.0
|
||||
else:
|
||||
n["x"], n["y"] = 1000.0, 400.0
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
out = pack_soft_blocks(st, mode="hub_territory", gap=420.0, strategy="auto")
|
||||
assert out.params.get("strategy") == "preserve"
|
||||
h1 = out.state.positions["h1"]
|
||||
h2 = out.state.positions["h2"]
|
||||
assert h1[0] < h2[0]
|
||||
# densified toward gap, not remapped onto a fresh grid origin
|
||||
assert abs(h2[0] - h1[0]) < 2000.0
|
||||
assert abs(h2[0] - h1[0]) > 200.0
|
||||
|
||||
|
||||
def test_compact_soft_recipe_unpublished() -> None:
|
||||
from netx_topology_mcp.layout_tool import resolve_recipe
|
||||
|
||||
try:
|
||||
resolve_recipe("compact_soft")
|
||||
raise AssertionError("compact_soft should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_recipe" in str(e)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
"""Tests for stage-2 pin_beam + soft_petals actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
|
||||
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def _starish():
|
||||
"""Two cores + two AN hubs each with a few stubs (positions intentionally messy)."""
|
||||
nodes = [
|
||||
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 100, "y": 500},
|
||||
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 900, "y": 100},
|
||||
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 200, "y": 800},
|
||||
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 800, "y": 50},
|
||||
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 50, "y": 900},
|
||||
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 350, "y": 950},
|
||||
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 750, "y": 20},
|
||||
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 950, "y": 40},
|
||||
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 250, "y": 700},
|
||||
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 850, "y": 200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "c0", "b_node_id": "c1"},
|
||||
{"a_node_id": "c0", "b_node_id": "a0"},
|
||||
{"a_node_id": "c1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a0", "b_node_id": "e0"},
|
||||
{"a_node_id": "a0", "b_node_id": "e1"},
|
||||
{"a_node_id": "a0", "b_node_id": "e4"},
|
||||
{"a_node_id": "a1", "b_node_id": "e2"},
|
||||
{"a_node_id": "a1", "b_node_id": "e3"},
|
||||
{"a_node_id": "a1", "b_node_id": "e5"},
|
||||
{"a_node_id": "e0", "b_node_id": "e4"},
|
||||
{"a_node_id": "e2", "b_node_id": "e5"},
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def test_actions_include_pin_and_petals() -> None:
|
||||
assert "pin_beam" not in ACTIONS
|
||||
assert "soft_petals" not in ACTIONS
|
||||
|
||||
|
||||
def test_pin_beam_aligns_cores() -> None:
|
||||
nodes, edges = _starish()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
op = pin_beam_rigid(st, LayoutParams())
|
||||
assert op.op == "pin_beam"
|
||||
ys = [op.state.positions["c0"][1], op.state.positions["c1"][1]]
|
||||
# Either accepted with near-collinear cores, or refused (no_improvement) keeping input
|
||||
if op.params.get("accepted"):
|
||||
assert abs(ys[0] - ys[1]) < 5.0
|
||||
else:
|
||||
assert op.note in {"no_improvement", "need_ge_2_cores"}
|
||||
|
||||
|
||||
def test_soft_petals_runs() -> None:
|
||||
nodes, edges = _starish()
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
op = soft_petals_greedy(st, LayoutParams())
|
||||
assert op.op == "soft_petals"
|
||||
assert "accepted_n" in op.params
|
||||
|
||||
|
||||
def test_run_layout_on_graph_pin_beam_unpublished() -> None:
|
||||
nodes, edges = _starish()
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="pin_beam")
|
||||
raise AssertionError("pin_beam should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
|
||||
|
||||
def test_run_layout_on_graph_soft_petals_unpublished() -> None:
|
||||
nodes, edges = _starish()
|
||||
try:
|
||||
run_layout_on_graph(nodes, edges, action="soft_petals")
|
||||
raise AssertionError("soft_petals should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
162
packages/netx-topology-mcp/tests/test_press_crossings.py
Normal file
162
packages/netx-topology-mcp/tests/test_press_crossings.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""press_hot_edges / press_crossers / polish_crossings MCP actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossings_after_node_move,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.press_crossings import (
|
||||
_large_graph_budget,
|
||||
park_phantom_nodes,
|
||||
press_hot_edges,
|
||||
)
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def test_actions_include_press_polish() -> None:
|
||||
assert "press_hot_edges" not in ACTIONS
|
||||
assert "press_crossers" not in ACTIONS
|
||||
assert "polish_crossings" in ACTIONS
|
||||
|
||||
|
||||
def test_park_phantom_region_nodes() -> None:
|
||||
st = build_state_from_nodes_edges(
|
||||
[
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{
|
||||
"fabric_node_id": "region:x",
|
||||
"name": "Units",
|
||||
"x": 1e12,
|
||||
"y": 100,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
moved = park_phantom_nodes(st)
|
||||
assert "region:x" in moved
|
||||
assert abs(st.positions["region:x"][0]) < 1000
|
||||
|
||||
|
||||
def test_crossings_after_node_move_matches_full_recount() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 400, "y": 100},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
{"a_node_id": "a", "b_node_id": "d"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
cur = count_edge_crossings(st.positions, st.links)
|
||||
for xy in ((200.0, -40.0), (50.0, 50.0), (300.0, 300.0)):
|
||||
inc = crossings_after_node_move(
|
||||
st.positions, st.links, st.adj, "a", xy, current_total=cur
|
||||
)
|
||||
trial = dict(st.positions)
|
||||
trial["a"] = xy
|
||||
assert inc == count_edge_crossings(trial, st.links)
|
||||
|
||||
|
||||
def test_large_graph_budget_scales_down() -> None:
|
||||
small = _large_graph_budget(50)
|
||||
huge = _large_graph_budget(500)
|
||||
assert huge["hot_max_sweeps"] < small["hot_max_sweeps"]
|
||||
assert huge["untangle_rounds"] < small["untangle_rounds"]
|
||||
assert huge["untangle_rank_cap"] < small["untangle_rank_cap"]
|
||||
assert huge["cross_cand_cap"] < small["cross_cand_cap"]
|
||||
|
||||
|
||||
def test_press_hot_edges_reduces_x_crossing() -> None:
|
||||
# Classic X: rotate one endpoint about the other can uncross.
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
|
||||
]
|
||||
# h-a and b-c cross; freeze hub h as portal
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "h", "b_node_id": "b"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
st.meta = {
|
||||
"compose_views": {
|
||||
"rigid_groups": [
|
||||
{"key": "u1", "node_ids": ["h", "a", "b"], "pivots": ["h"]},
|
||||
]
|
||||
}
|
||||
}
|
||||
before = 1 # at least the X
|
||||
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=8, max_sweeps=3)
|
||||
assert op.state is not None
|
||||
assert int(op.params.get("end_crossings") or 0) <= before + 2
|
||||
|
||||
|
||||
def test_press_hot_edges_reels_in_long_spoke() -> None:
|
||||
"""max_disp must not block inward pulls that shorten metro bridges."""
|
||||
import math
|
||||
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "leaf", "name": "LEAF", "x": 8000, "y": 0},
|
||||
# Barrier segment the long spoke crosses.
|
||||
{"fabric_node_id": "p", "name": "P", "x": 4000, "y": -800},
|
||||
{"fabric_node_id": "q", "name": "Q", "x": 4000, "y": 800},
|
||||
{"fabric_node_id": "anchor", "name": "A", "x": -200, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "leaf"},
|
||||
{"a_node_id": "p", "b_node_id": "q"},
|
||||
{"a_node_id": "h", "b_node_id": "anchor"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
# Pad with dummy edges so large-graph budget (E>=400) applies.
|
||||
for i in range(420):
|
||||
a, b = f"d{i}", f"d{i+1}"
|
||||
st.positions[a] = (float(i), 5000.0)
|
||||
st.positions[b] = (float(i) + 1.0, 5000.0)
|
||||
st.names[a] = a
|
||||
st.names[b] = b
|
||||
st.links.append((a, b))
|
||||
st.adj.setdefault(a, []).append(b)
|
||||
st.adj.setdefault(b, []).append(a)
|
||||
before = count_edge_crossings(st.positions, st.links)
|
||||
assert before >= 1
|
||||
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=6, max_sweeps=2)
|
||||
after_len = math.hypot(
|
||||
op.state.positions["leaf"][0] - op.state.positions["h"][0],
|
||||
op.state.positions["leaf"][1] - op.state.positions["h"][1],
|
||||
)
|
||||
assert after_len < 4000.0 or int(op.params.get("end_crossings") or before) < before
|
||||
|
||||
|
||||
def test_run_layout_polish_crossings() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
]
|
||||
out = run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="polish_crossings",
|
||||
params={"straighten": True, "max_degree": 7, "untangle_rounds": 40},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["action"] == "polish_crossings"
|
||||
assert (out.get("overlap") or {}).get("footprint_pairs", 0) == 0
|
||||
145
packages/netx-topology-mcp/tests/test_rigid_orbit.py
Normal file
145
packages/netx-topology-mcp/tests/test_rigid_orbit.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Bridge-orbit densify: drag rigid exclusive body about external tip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.rigid_orbit import (
|
||||
rigid_orbit_candidates_for_group,
|
||||
rigid_orbit_round,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def test_actions_include_rigid_orbit() -> None:
|
||||
assert "rigid_orbit" not in ACTIONS
|
||||
|
||||
|
||||
def _sparse_two_units() -> tuple:
|
||||
# Unit A near origin; unit B far right; long bridge a2—b1.
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
|
||||
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "x": 3200, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "x": 3400, "y": 60},
|
||||
{"fabric_node_id": "b3", "name": "B3", "x": 3600, "y": -40},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b1", "b_node_id": "b3"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
groups = groups_from_membership(
|
||||
[
|
||||
("va", ["p", "a1", "a2"]),
|
||||
("vb", ["b1", "b2", "b3"]),
|
||||
]
|
||||
)
|
||||
st.meta = {"compose_views": {"rigid_groups": groups}}
|
||||
return st, groups
|
||||
|
||||
|
||||
def test_candidates_prefer_inward_pull() -> None:
|
||||
st, groups = _sparse_two_units()
|
||||
g = next(g for g in groups if g["key"] == "vb")
|
||||
parsed = {
|
||||
"key": g["key"],
|
||||
"members": g["node_ids"],
|
||||
"pivots": g["pivots"],
|
||||
"exclusive": [n for n in g["node_ids"] if n not in g["pivots"]],
|
||||
"shared": list(g["pivots"]),
|
||||
}
|
||||
# vb has no shared pivots with va (p not in vb) — exclusive = all members
|
||||
cands = rigid_orbit_candidates_for_group(
|
||||
st.positions,
|
||||
st.links,
|
||||
parsed,
|
||||
angle_step=45,
|
||||
radii=(0.5, 0.7, 1.0),
|
||||
bridges_per_group=2,
|
||||
cand_cap=40,
|
||||
x_slack=200,
|
||||
)
|
||||
assert cands, "expected bridge-orbit candidates for far unit"
|
||||
# Best should densify (area_ratio > 1) when pulling toward a2 tip
|
||||
assert cands[0]["area_ratio"] >= 1.0 or cands[0]["radius_scale"] < 1.0
|
||||
|
||||
|
||||
def test_rigid_orbit_round_shrinks_bbox() -> None:
|
||||
st, groups = _sparse_two_units()
|
||||
before = score_state(st, fast=True)
|
||||
util0 = float((before.get("summary") or {}).get("util") or 0.0)
|
||||
xs = [p[0] for p in st.positions.values()]
|
||||
ys = [p[1] for p in st.positions.values()]
|
||||
area0 = (max(xs) - min(xs)) * (max(ys) - min(ys))
|
||||
|
||||
op = rigid_orbit_round(
|
||||
st,
|
||||
groups=groups,
|
||||
top_n=4,
|
||||
bridges_per_group=2,
|
||||
angle_step=30,
|
||||
radii=(0.45, 0.6, 0.75, 0.9),
|
||||
x_slack=500,
|
||||
max_accepts=4,
|
||||
)
|
||||
assert op.op == "rigid_orbit"
|
||||
# Similarity: pairwise distance ratios inside vb stay equal (polar scale).
|
||||
def _pair_lens(pos: dict) -> tuple[float, float]:
|
||||
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
|
||||
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
|
||||
return d12, d13
|
||||
|
||||
L12_0, L13_0 = _pair_lens(st.positions)
|
||||
L12_1, L13_1 = _pair_lens(op.state.positions)
|
||||
assert L12_0 > 1 and L13_0 > 1
|
||||
r12 = L12_1 / L12_0
|
||||
r13 = L13_1 / L13_0
|
||||
assert abs(r12 - r13) < 1e-5
|
||||
|
||||
if not (op.params or {}).get("reverted"):
|
||||
xs1 = [p[0] for p in op.state.positions.values()]
|
||||
ys1 = [p[1] for p in op.state.positions.values()]
|
||||
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
|
||||
after = score_state(op.state, fast=True)
|
||||
util1 = float((after.get("summary") or {}).get("util") or 0.0)
|
||||
assert area1 < area0 * 0.98 or util1 > util0 + 1e-6
|
||||
|
||||
|
||||
def test_run_layout_rigid_orbit_action() -> None:
|
||||
st, groups = _sparse_two_units()
|
||||
nodes = [
|
||||
{
|
||||
"fabric_node_id": nid,
|
||||
"name": st.names.get(nid, nid),
|
||||
"x": st.positions[nid][0],
|
||||
"y": st.positions[nid][1],
|
||||
}
|
||||
for nid in st.positions
|
||||
]
|
||||
edges = [{"a_node_id": a, "b_node_id": b} for a, b in st.links]
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="rigid_orbit",
|
||||
params={
|
||||
"_rigid_membership": [
|
||||
{"key": g["key"], "node_ids": g["node_ids"], "pivots": g["pivots"]}
|
||||
for g in groups
|
||||
],
|
||||
"top_n": 4,
|
||||
"x_slack": 500,
|
||||
"radii": [0.5, 0.7, 0.9],
|
||||
},
|
||||
)
|
||||
raise AssertionError("rigid_orbit should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
151
packages/netx-topology-mcp/tests/test_rigid_units.py
Normal file
151
packages/netx-topology-mcp/tests/test_rigid_units.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"""Sub-regions stay rigid after compose / rigid_untangle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, strip_pack_blocks
|
||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
||||
groups_from_membership,
|
||||
rigid_untangle_groups,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams
|
||||
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
|
||||
|
||||
|
||||
def test_actions_include_rigid_untangle() -> None:
|
||||
assert "rigid_untangle" not in ACTIONS
|
||||
|
||||
|
||||
def test_protect_portals_freezes_shared_only() -> None:
|
||||
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
|
||||
st = LayoutState(
|
||||
positions={"p": (0, 0), "a": (1, 0), "b": (2, 0)},
|
||||
names={},
|
||||
adj={},
|
||||
links=[],
|
||||
layers={},
|
||||
meta={
|
||||
"compose_views": {
|
||||
"rigid_groups": [
|
||||
{"key": "va", "node_ids": ["p", "a"], "pivots": ["p"]},
|
||||
{"key": "vb", "node_ids": ["p", "b"], "pivots": ["p"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert frozen_ids_for_protect(st, "portals") == {"p"}
|
||||
assert frozen_ids_for_protect(st, "all") == {"p", "a", "b"}
|
||||
assert frozen_ids_for_protect(st, "off") == set()
|
||||
# Shared corridor `c` is multi-membership but not a pivot — must stay movable.
|
||||
st.meta["compose_views"]["rigid_groups"] = [
|
||||
{"key": "va", "node_ids": ["p", "a", "c"], "pivots": ["p"]},
|
||||
{"key": "vb", "node_ids": ["p", "b", "c"], "pivots": ["p"]},
|
||||
]
|
||||
assert frozen_ids_for_protect(st, "portals") == {"p"}
|
||||
assert "c" not in frozen_ids_for_protect(st, "portals")
|
||||
|
||||
|
||||
def test_compose_emits_rigid_groups() -> None:
|
||||
a = ComposeBlock(
|
||||
"va",
|
||||
{"p": (0.0, 0.0), "a1": (100.0, 50.0), "a2": (200.0, 0.0)},
|
||||
)
|
||||
b = ComposeBlock(
|
||||
"vb",
|
||||
{"p": (0.0, 0.0), "b1": (100.0, -40.0), "b2": (200.0, 0.0)},
|
||||
)
|
||||
_merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
|
||||
groups = meta.get("rigid_groups") or []
|
||||
assert len(groups) == 2
|
||||
by_key = {g["key"]: g for g in groups}
|
||||
assert "p" in by_key["vb"]["pivots"]
|
||||
assert set(by_key["va"]["node_ids"]) >= {"p", "a1", "a2"}
|
||||
|
||||
|
||||
def test_rigid_untangle_preserves_relative_geometry() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "A1", "x": 100, "y": 50},
|
||||
{"fabric_node_id": "a2", "name": "A2", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "x": 500, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "x": 600, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
groups = groups_from_membership(
|
||||
[
|
||||
("va", ["p", "a1", "a2"]),
|
||||
("vb", ["b1", "b2"]),
|
||||
]
|
||||
)
|
||||
# Capture relative offset inside va
|
||||
dx0 = st.positions["a2"][0] - st.positions["a1"][0]
|
||||
dy0 = st.positions["a2"][1] - st.positions["a1"][1]
|
||||
op = rigid_untangle_groups(st, LayoutParams(), groups=groups, max_rounds=8, step=200.0)
|
||||
dx1 = op.state.positions["a2"][0] - op.state.positions["a1"][0]
|
||||
dy1 = op.state.positions["a2"][1] - op.state.positions["a1"][1]
|
||||
# Distance preserved (rigid); angle may flip 90° so check length
|
||||
import math
|
||||
|
||||
assert abs(math.hypot(dx1, dy1) - math.hypot(dx0, dy0)) < 1e-6
|
||||
|
||||
|
||||
def test_rigid_fan_out_reduces_bbox_stack() -> None:
|
||||
"""Two eyes glued on one portal should prefer a non-stacked angle."""
|
||||
from netx_topology_mcp.layout_ops.rigid_units import rigid_fan_out_portals
|
||||
|
||||
# Same portal p; unit A along +x, unit B also along +x (stacked).
|
||||
pos = {
|
||||
"p": (0.0, 0.0),
|
||||
"a1": (100.0, 20.0),
|
||||
"a2": (200.0, 0.0),
|
||||
"b1": (100.0, 20.0),
|
||||
"b2": (200.0, 0.0),
|
||||
}
|
||||
groups = [
|
||||
{"key": "va", "node_ids": ["p", "a1", "a2"], "pivots": ["p"]},
|
||||
{"key": "vb", "node_ids": ["p", "b1", "b2"], "pivots": ["p"]},
|
||||
]
|
||||
links = [("p", "a1"), ("a1", "a2"), ("p", "b1"), ("b1", "b2")]
|
||||
out, n = rigid_fan_out_portals(pos, groups, links)
|
||||
assert n >= 1
|
||||
# After fan-out, B should not sit on A's exclusive stack.
|
||||
assert abs(out["b1"][0] - out["a1"][0]) > 1.0 or abs(
|
||||
out["b1"][1] - out["a1"][1]
|
||||
) > 1.0
|
||||
|
||||
|
||||
def test_run_layout_rigid_untangle() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "A2", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "B1", "x": 400, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "B2", "x": 500, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
]
|
||||
try:
|
||||
run_layout_on_graph(
|
||||
nodes,
|
||||
edges,
|
||||
action="rigid_untangle",
|
||||
params={
|
||||
"_rigid_membership": [
|
||||
{"key": "va", "node_ids": ["a1", "a2"]},
|
||||
{"key": "vb", "node_ids": ["b1", "b2"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
raise AssertionError("rigid_untangle should be unpublished")
|
||||
except ValueError as e:
|
||||
assert "unknown_action" in str(e)
|
||||
107
packages/netx-topology-mcp/tests/test_sink_dual_units.py
Normal file
107
packages/netx-topology-mcp/tests/test_sink_dual_units.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Tests for sinkTopologyDualUnits selection helpers + tool registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units
|
||||
from netx_topology_mcp.layout_ops.sink_dual_units import (
|
||||
batch_node_ids,
|
||||
layout_and_pack_batch,
|
||||
leftover_batch_ids,
|
||||
park_positions,
|
||||
select_dual_unit_batch,
|
||||
)
|
||||
|
||||
|
||||
def _u(uid: int, a: str, b: str, *mids: str) -> DualUnit:
|
||||
path = [a, *mids, b] if mids else [a, b]
|
||||
return DualUnit(portal_a=a, portal_b=b, paths=[path], tails=[], unit_id=uid)
|
||||
|
||||
|
||||
def test_select_prefers_less_shared_portals() -> None:
|
||||
units = [
|
||||
_u(1, "X", "A", "m1", "m2", "m3", "m4", "m5", "m6"),
|
||||
_u(2, "X", "B", "n1", "n2", "n3", "n4", "n5", "n6"),
|
||||
_u(3, "C", "D", "p1", "p2", "p3", "p4", "p5", "p6"),
|
||||
]
|
||||
picked = select_dual_unit_batch(units, max_units=1, min_nodes=8, max_nodes=80)
|
||||
assert len(picked) == 1
|
||||
assert picked[0].unit_id == 3
|
||||
|
||||
|
||||
def test_select_respects_batch_cap() -> None:
|
||||
units = [
|
||||
_u(1, "A", "B", "m1", "m2", "m3", "m4", "m5", "m6"),
|
||||
_u(2, "C", "D", "n1", "n2", "n3", "n4", "n5", "n6"),
|
||||
_u(3, "E", "F", "p1", "p2", "p3", "p4", "p5", "p6"),
|
||||
]
|
||||
picked = select_dual_unit_batch(
|
||||
units, max_units=3, min_nodes=8, max_nodes=80, max_batch_nodes=20
|
||||
)
|
||||
ids = batch_node_ids(picked)
|
||||
assert len(ids) <= 20
|
||||
assert len(picked) >= 1
|
||||
|
||||
|
||||
def test_leftover_and_park() -> None:
|
||||
left = leftover_batch_ids(
|
||||
["a", "region:x", "b", "c"], max_batch_nodes=2, exclude_ids={"b"}
|
||||
)
|
||||
assert left == ["a", "c"]
|
||||
pos = park_positions(
|
||||
{"a": (10.0, 10.0), "c": (30.0, 10.0)},
|
||||
["a", "c"],
|
||||
sink_pos={"z": (100.0, 50.0)},
|
||||
pad=40.0,
|
||||
)
|
||||
assert len(pos) == 2
|
||||
# Orbit block-sweep: not fixed right — just leave sink tip with clearance.
|
||||
assert all("x" in p and "y" in p for p in pos)
|
||||
assert max(abs(p["x"] - 100.0) for p in pos) >= 40.0 or max(
|
||||
abs(p["y"] - 50.0) for p in pos
|
||||
) >= 40.0
|
||||
|
||||
|
||||
def test_layout_and_pack_batch_zero_cross_units() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
|
||||
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "p1", "b_node_id": "a1"},
|
||||
{"a_node_id": "a1", "b_node_id": "a2"},
|
||||
{"a_node_id": "a2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "b1"},
|
||||
{"a_node_id": "b1", "b_node_id": "b2"},
|
||||
{"a_node_id": "b2", "b_node_id": "p2"},
|
||||
{"a_node_id": "p1", "b_node_id": "c1"},
|
||||
{"a_node_id": "c1", "b_node_id": "c2"},
|
||||
{"a_node_id": "c2", "b_node_id": "p2"},
|
||||
]
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
units = find_dual_portal_units(st)
|
||||
assert units
|
||||
world, reports, attach = layout_and_pack_batch(
|
||||
st, units[:1], sink_pos={"z": (500.0, 0.0)}, pad=50.0, links=list(st.links)
|
||||
)
|
||||
assert world
|
||||
assert attach.get("via") in {"orbit_orphan", "orbit_portal", "orbit_dual", "seed"}
|
||||
assert reports and reports[0].get("accepted") is True
|
||||
members = units[0].member_ids()
|
||||
unit_links = [e for e in st.links if e[0] in members and e[1] in members]
|
||||
assert count_edge_crossings(world, unit_links) == 0
|
||||
|
||||
|
||||
def test_tools_registered() -> None:
|
||||
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
|
||||
assert "sinkTopologyDualUnits" in names
|
||||
assert "copyTopologyViewNodes" in names
|
||||
assert len(names) == 14
|
||||
179
packages/netx-topology-mcp/tests/test_topology_quality.py
Normal file
179
packages/netx-topology-mcp/tests/test_topology_quality.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Tests for chain cohesion + min-ring integrity mid-tier metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components
|
||||
from netx_topology_mcp.layout_topology_quality import (
|
||||
compute_chain_cohesion,
|
||||
compute_ring_integrity,
|
||||
extract_chain_paths,
|
||||
)
|
||||
|
||||
|
||||
def _pos_links_from_nodes(nodes, edges):
|
||||
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
|
||||
links = [(e["a_node_id"], e["b_node_id"]) for e in edges]
|
||||
return pos, links
|
||||
|
||||
|
||||
def test_straight_chain_scores_high() -> None:
|
||||
# hub + straight corridor
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
pos, links = _pos_links_from_nodes(nodes, edges)
|
||||
from collections import defaultdict
|
||||
|
||||
adj: dict[str, set[str]] = defaultdict(set)
|
||||
for a, b in links:
|
||||
adj[a].add(b)
|
||||
adj[b].add(a)
|
||||
assert extract_chain_paths(adj)
|
||||
q = compute_chain_cohesion(pos, links)
|
||||
assert q["score"] >= 0.9
|
||||
assert q["kink_count"] == 0
|
||||
|
||||
|
||||
def test_kinked_chain_scores_lower() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 200},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "h", "b_node_id": "a"},
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
]
|
||||
pos, links = _pos_links_from_nodes(nodes, edges)
|
||||
bent = compute_chain_cohesion(pos, links)
|
||||
# straighten
|
||||
nodes2 = [
|
||||
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
|
||||
]
|
||||
pos2, _ = _pos_links_from_nodes(nodes2, edges)
|
||||
straight = compute_chain_cohesion(pos2, links)
|
||||
assert straight["score"] > bent["score"]
|
||||
assert bent["kink_count"] >= 1
|
||||
|
||||
|
||||
def test_ring_pierce_detected() -> None:
|
||||
# Square cycle + a chord that crosses through (diagonal would share verts;
|
||||
# use an external edge that crosses one side).
|
||||
# Cycle: a-b-c-d-a. External: e--f crosses a-b vertically.
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
|
||||
{"fabric_node_id": "e", "name": "E", "x": 100, "y": -50},
|
||||
{"fabric_node_id": "f", "name": "F", "x": 100, "y": 50},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "c", "b_node_id": "d"},
|
||||
{"a_node_id": "d", "b_node_id": "a"},
|
||||
{"a_node_id": "e", "b_node_id": "f"},
|
||||
]
|
||||
pos, links = _pos_links_from_nodes(nodes, edges)
|
||||
pierced = compute_ring_integrity(pos, links)
|
||||
assert pierced["ring_count"] >= 1
|
||||
assert pierced["rings_pierced"] >= 1
|
||||
assert pierced["score"] < 1.0
|
||||
|
||||
# move e-f away → no pierce
|
||||
nodes2 = [
|
||||
*nodes[:4],
|
||||
{"fabric_node_id": "e", "name": "E", "x": 300, "y": -50},
|
||||
{"fabric_node_id": "f", "name": "F", "x": 300, "y": 50},
|
||||
]
|
||||
pos2, _ = _pos_links_from_nodes(nodes2, edges)
|
||||
clean = compute_ring_integrity(pos2, links)
|
||||
assert clean["rings_pierced"] == 0
|
||||
assert clean["score"] == 1.0
|
||||
|
||||
|
||||
def test_score_includes_mid_tier_weights() -> None:
|
||||
s = score_layout_components(
|
||||
{
|
||||
"node_count": 40,
|
||||
"edge_crossings": 0,
|
||||
"crossings_per_link": 0.0,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.2,
|
||||
"hull_utilization": 0.25,
|
||||
"grid_occupancy": 0.4,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.2,
|
||||
"chain_score": 0.2,
|
||||
"rings_score": 0.2,
|
||||
"edge_clearance_score": 0.2,
|
||||
"edge_axis_score": 0.2,
|
||||
}
|
||||
)
|
||||
assert "chain" in s["parts"]
|
||||
assert "rings" in s["parts"]
|
||||
assert "edge_clearance" in s["parts"]
|
||||
assert "edge_axis" in s["parts"]
|
||||
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
|
||||
assert abs(s["weights"]["rings"] - 0.10) < 1e-9
|
||||
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
|
||||
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
|
||||
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
|
||||
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
|
||||
good = score_layout_components(
|
||||
{
|
||||
"node_count": 40,
|
||||
"edge_crossings": 0,
|
||||
"crossings_per_link": 0.0,
|
||||
"footprint_overlap_pairs": 0,
|
||||
"label_overlap_pairs": 0,
|
||||
"nn_p50": 170,
|
||||
"space_utilization": 0.2,
|
||||
"hull_utilization": 0.25,
|
||||
"grid_occupancy": 0.4,
|
||||
"edge_stretch_p50": 1.2,
|
||||
"whitespace_index": 0.2,
|
||||
"chain_score": 1.0,
|
||||
"rings_score": 1.0,
|
||||
"edge_clearance_score": 1.0,
|
||||
"edge_axis_score": 1.0,
|
||||
}
|
||||
)
|
||||
assert good["total"] > s["total"]
|
||||
|
||||
|
||||
def test_report_exposes_chains_and_rings() -> None:
|
||||
nodes = [
|
||||
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
|
||||
{"fabric_node_id": "c", "name": "C", "x": 400, "y": 0},
|
||||
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 200},
|
||||
]
|
||||
edges = [
|
||||
{"a_node_id": "a", "b_node_id": "b"},
|
||||
{"a_node_id": "b", "b_node_id": "c"},
|
||||
{"a_node_id": "b", "b_node_id": "d"},
|
||||
]
|
||||
r = analyze_layout_stats(nodes, edges)["report"]
|
||||
assert "chains" in r
|
||||
assert "rings" in r
|
||||
assert "chain" in r["score"]["parts"]
|
||||
assert "rings" in r["score"]["parts"]
|
||||
assert r["chains"]["status"] in {"ok", "warn", "fail"}
|
||||
assert r["rings"]["status"] in {"ok", "warn", "fail"}
|
||||
54
packages/netx-topology-mcp/tests/test_untangle.py
Normal file
54
packages/netx-topology-mcp/tests/test_untangle.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Untangle must not fling nodes across the canvas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
|
||||
from netx_topology_mcp.layout_ops.untangle import _MAX_JUMP, _candidates, untangle_crossings
|
||||
|
||||
|
||||
def test_candidates_stay_near_node() -> None:
|
||||
pos = {
|
||||
"a": (0.0, 0.0),
|
||||
"b": (5000.0, 0.0), # far neighbor — old code used dx*3.5 → 17k jump
|
||||
"c": (2500.0, 800.0),
|
||||
}
|
||||
adj = {"c": {"a", "b"}, "a": {"c"}, "b": {"c"}}
|
||||
import random
|
||||
|
||||
cands = _candidates(pos, "c", adj, random.Random(1))
|
||||
assert cands
|
||||
x0, y0 = pos["c"]
|
||||
for x, y in cands:
|
||||
assert math.hypot(x - x0, y - y0) <= _MAX_JUMP + 1e-6
|
||||
|
||||
|
||||
def test_untangle_preserves_bbox_order() -> None:
|
||||
# Long chain with one intentional crossing-ish layout; moves stay local.
|
||||
nodes = []
|
||||
edges = []
|
||||
for i in range(8):
|
||||
nodes.append(
|
||||
{
|
||||
"fabric_node_id": f"n{i}",
|
||||
"name": f"N{i}-EN-1",
|
||||
"x": float(i * 200),
|
||||
"y": 0.0 if i % 2 == 0 else 40.0,
|
||||
}
|
||||
)
|
||||
if i:
|
||||
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
|
||||
# add a long chord that crosses nothing badly but gives degree
|
||||
edges.append({"a_node_id": "n0", "b_node_id": "n7"})
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
before = dict(st.positions)
|
||||
xs0 = [p[0] for p in before.values()]
|
||||
ys0 = [p[1] for p in before.values()]
|
||||
span0 = max(max(xs0) - min(xs0), max(ys0) - min(ys0))
|
||||
out = untangle_crossings(st, max_rounds=40, max_degree=7, target_crossings=0)
|
||||
xs1 = [p[0] for p in out.state.positions.values()]
|
||||
ys1 = [p[1] for p in out.state.positions.values()]
|
||||
span1 = max(max(xs1) - min(xs1), max(ys1) - min(ys1))
|
||||
# Must not explode bbox by >2x
|
||||
assert span1 <= span0 * 2.0 + 800.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue