mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
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>
185 lines
7.2 KiB
Python
185 lines
7.2 KiB
Python
"""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)
|