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>
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""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)
|