test: cover auto-position and auto-edge-handle; fix negative-cell clamp
Add backend API tests for the two issue #265 features that shipped untested: auto-positioning root nodes into a free grid slot (first at origin, collision avoidance, child origin default, explicit coords and explicit zero preserved) and auto-assigning edge handles from absolute canvas Y (source above/below/equal target, partial handle fill, child abs-Y resolved through parent). Drop the max(0, round()) clamp in _find_free_position: it folded negative-positioned nodes onto cell (0,0), falsely blocking or freeing the origin slot. Negative nodes now keep their true cells and never intersect the positive search space.
This commit is contained in:
@@ -37,10 +37,13 @@ async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[
|
||||
if not positions:
|
||||
return 0.0, 0.0
|
||||
|
||||
# Snap each existing node to its true grid cell (negatives kept as-is). The
|
||||
# search below only visits col >= 0 / row >= 0, so nodes parked in negative
|
||||
# space never falsely block — or falsely free — a positive slot.
|
||||
occupied: set[tuple[int, int]] = set()
|
||||
for (px, py) in positions:
|
||||
col = max(0, round(px / _SLOT_W))
|
||||
row = max(0, round(py / _SLOT_H))
|
||||
col = round(px / _SLOT_W)
|
||||
row = round(py / _SLOT_H)
|
||||
occupied.add((col, row))
|
||||
|
||||
for row in range(10_000):
|
||||
|
||||
@@ -220,3 +220,113 @@ async def test_source_and_target_handle_persist_through_update(client: AsyncClie
|
||||
assert data["source_handle"] == "cluster-right"
|
||||
assert data["target_handle"] == "cluster-left"
|
||||
assert data["label"] == "corosync"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
|
||||
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def stacked_nodes(client: AsyncClient, headers: dict):
|
||||
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
|
||||
top = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
bottom = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
return top["id"], bottom["id"]
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""Source above target -> downstream flow: exit bottom, enter top-t."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""Source below target -> upstream flow: exit top, enter bottom-t."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "top"
|
||||
assert data["target_handle"] == "bottom-t"
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
|
||||
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
|
||||
src, tgt = two_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "cluster-right"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
|
||||
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
|
||||
container still resolves as downstream of a node above the container."""
|
||||
parent = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
child = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
below = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
@@ -286,3 +286,80 @@ async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["properties"] == props
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-positioning (issue #265): omitting pos_x/pos_y snaps the node to the
|
||||
# first free 200x100 grid slot instead of stacking everything at (0, 0).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_node_auto_positions_first_at_origin(client: AsyncClient, headers: dict):
|
||||
"""First root node with no coordinates lands at (0, 0)."""
|
||||
res = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_auto_position_avoids_collision(client: AsyncClient, headers: dict):
|
||||
"""A second auto-placed root node takes the next free grid cell, not (0, 0)."""
|
||||
first = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert (first.json()["pos_x"], first.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "B", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert second.status_code == 201
|
||||
# Next free cell in row 0 is column 1 -> x = 1 * 200.
|
||||
assert (second.json()["pos_x"], second.json()["pos_y"]) == (200.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_child_defaults_to_parent_origin(client: AsyncClient, headers: dict):
|
||||
"""A child node (parent_id set) with no coordinates defaults to (0, 0) relative to its parent."""
|
||||
parent = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "vm", "label": "VM1", "status": "online", "parent_id": parent["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_explicit_position_preserved(client: AsyncClient, headers: dict):
|
||||
"""Explicit coordinates are honored, never auto-placed."""
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "A", "status": "unknown", "pos_x": 512.0, "pos_y": 384.0},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (512.0, 384.0)
|
||||
|
||||
|
||||
async def test_create_node_explicit_zero_position_preserved(client: AsyncClient, headers: dict):
|
||||
"""pos=0 is an explicit value, not 'omitted' — auto-position must not treat 0 as None."""
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
# Second node explicitly pinned to (0, 0) even though the cell is taken.
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "B", "status": "unknown", "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
Reference in New Issue
Block a user