diff --git a/backend/app/api/routes/nodes.py b/backend/app/api/routes/nodes.py index 28ea124..0ef1bc2 100644 --- a/backend/app/api/routes/nodes.py +++ b/backend/app/api/routes/nodes.py @@ -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): diff --git a/backend/tests/test_edges.py b/backend/tests/test_edges.py index 09b51d1..4110291 100644 --- a/backend/tests/test_edges.py +++ b/backend/tests/test_edges.py @@ -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" diff --git a/backend/tests/test_nodes.py b/backend/tests/test_nodes.py index 0d5ad59..a11b7e4 100644 --- a/backend/tests/test_nodes.py +++ b/backend/tests/test_nodes.py @@ -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)