Files
homelable/backend/tests/test_handle_utils.py
T
Pouzor 08d85a40c1 fix(edges): reject an edge endpoint the node has no connection point for
React Flow draws nothing for an edge whose handle ID resolves to no element, so
such an edge persists in the DB and is simply invisible on the canvas, with no
error anywhere. Nothing could reach that state while only the UI wrote handles;
exposing source_handle/target_handle over MCP opens it up, which is the same
failure the shrink remap already prevents from the other direction.

- POST /edges and PATCH /edges/{id} answer 422 when a caller-supplied handle
  names a slot its node doesn't have — including a side it opted out of, since
  left/right default to no connection point at all.
- Handles outside the per-side vocabulary ('cluster-right' and friends) belong
  to their own renderer and are left alone.
- The auto-assignment corrects its own pick instead of raising: it always names
  a top/bottom slot 0, which a node that zeroed those sides no longer draws.
  A node with no connection point anywhere keeps the original.
- parse_side_handle in schemas/utils.py reads both the source form and the
  '<side>-t' target twin the auto-assignment stores.

ha-relevant: no
2026-09-09 01:19:19 +02:00

85 lines
2.9 KiB
Python

"""Unit tests for the per-side connection-point vocabulary.
Mirrors frontend/src/utils/handleUtils.ts — the two must agree, or the server
stores handles the canvas cannot draw.
"""
import pytest
from app.schemas.utils import (
MAX_HANDLES,
clamp_handles,
handle_count_field,
handle_id,
parse_side_handle,
removed_handle_ids,
side_default,
)
@pytest.mark.parametrize(("side", "expected"), [("top", 1), ("bottom", 1), ("left", 0), ("right", 0)])
def test_side_default(side: str, expected: int) -> None:
assert side_default(side) == expected
def test_handle_count_field() -> None:
assert handle_count_field("left") == "left_handles"
@pytest.mark.parametrize(("raw", "expected"), [(0, 0), (3, 3), (64, 64), (65, 64), (5000, MAX_HANDLES), (-1, 0)])
def test_clamp_handles_bounds(raw: int, expected: int) -> None:
assert clamp_handles("bottom", raw) == expected
@pytest.mark.parametrize("raw", [None, "4", float("nan"), float("inf"), True])
def test_clamp_handles_falls_back_to_the_side_default(raw: object) -> None:
# A missing or corrupt count still yields the historical number, per side.
assert clamp_handles("bottom", raw) == 1
assert clamp_handles("left", raw) == 0
def test_clamp_handles_truncates_a_fraction() -> None:
assert clamp_handles("bottom", 3.9) == 3
def test_handle_id_keeps_the_bare_side_name_for_slot_zero() -> None:
# Slot 0 stays 'bottom' so edges saved before the multi-handle expansion
# keep resolving.
assert handle_id("bottom", 0) == "bottom"
assert handle_id("bottom", 1) == "bottom-2"
assert handle_id("left", 3) == "left-4"
def test_removed_handle_ids() -> None:
assert removed_handle_ids("bottom", 4, 2) == {"bottom-3", "bottom-4"}
assert removed_handle_ids("bottom", 2, 2) == set()
assert removed_handle_ids("bottom", 1, 4) == set()
@pytest.mark.parametrize(
("handle", "expected"),
[
("top", ("top", 0)),
("bottom-2", ("bottom", 1)),
("left-4", ("left", 3)),
# The invisible target twin resolves to the same slot as its source.
("right-t", ("right", 0)),
("bottom-3-t", ("bottom", 2)),
],
)
def test_parse_side_handle(handle: str, expected: tuple[str, int]) -> None:
assert parse_side_handle(handle) == expected
@pytest.mark.parametrize("handle", ["cluster-right", "", "middle", "top-left", "bottom-x"])
def test_parse_side_handle_ignores_a_foreign_namespace(handle: str) -> None:
# Not a per-side handle: whoever owns it interprets it, not the side counts.
assert parse_side_handle(handle) is None
@pytest.mark.parametrize("handle", ["bottom-0", "bottom-1"])
def test_parse_side_handle_reports_an_impossible_slot(handle: str) -> None:
# The numbered form is 2-based, so these are IDs the canvas never emits;
# slot -1 means no count can satisfy them.
assert parse_side_handle(handle) == ("bottom", -1)