mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 06:10:47 +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>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
"""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
|