mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +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>
151 lines
5.5 KiB
Python
151 lines
5.5 KiB
Python
"""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)
|