Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/tests/unit/spatial/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pytest

from visual_graph.spatial import BoundingBox, SpatialConfig


@pytest.fixture
def box() -> BoundingBox:
"""Reference box: 100x100 at the origin."""
return BoundingBox(0, 0, 100, 100)


@pytest.fixture
def config() -> SpatialConfig:
return SpatialConfig()
36 changes: 36 additions & 0 deletions backend/tests/unit/spatial/test_bbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import math

import pytest

from visual_graph.spatial import BoundingBox, InvalidBoundingBoxError


def test_derived_properties() -> None:
b = BoundingBox(10, 20, 50, 100)
assert (b.width, b.height, b.area) == (40, 80, 3200)
assert (b.center_x, b.center_y) == (30, 60)


@pytest.mark.parametrize(
"coords",
[
(0, 0, 0, 10), # zero width
(0, 0, 10, 0), # zero height
(0, 0, 0, 0), # zero area
(10, 0, 0, 10), # negative width
(0, 10, 10, 0), # negative height
(math.nan, 0, 10, 10),
(0, 0, math.inf, 10),
(0, -math.inf, 10, 10),
("0", 0, 10, 10),
(None, 0, 10, 10),
(True, 0, 10, 10),
],
)
def test_invalid_boxes_are_rejected(coords: tuple[object, ...]) -> None:
with pytest.raises(InvalidBoundingBoxError):
BoundingBox(*coords) # type: ignore[arg-type]


def test_invalid_box_error_is_value_error() -> None:
assert issubclass(InvalidBoundingBoxError, ValueError)
56 changes: 56 additions & 0 deletions backend/tests/unit/spatial/test_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

from visual_graph.spatial import (
BoundingBox,
Relationship,
RelationType,
SpatialObject,
compute_relationships,
)


def scene() -> list[SpatialObject]:
return [
SpatialObject("person", BoundingBox(0, 0, 100, 200)),
SpatialObject("dog", BoundingBox(120, 150, 200, 200)),
SpatialObject("bike", BoundingBox(500, 0, 700, 200)),
]


def test_no_self_relations() -> None:
for rel in compute_relationships(scene()):
assert rel.source_id != rel.target_id


def test_single_object_has_no_relationships() -> None:
assert compute_relationships(scene()[:1]) == []
assert compute_relationships([]) == []


def test_expected_relationships() -> None:
rels = set(compute_relationships(scene()))
assert Relationship("person", RelationType.LEFT_OF, "bike") in rels
assert Relationship("bike", RelationType.RIGHT_OF, "person") in rels
assert Relationship("person", RelationType.NEAR, "dog") in rels
assert Relationship("dog", RelationType.BELOW, "person") in rels


def test_symmetric_relations_emitted_once_per_pair() -> None:
objs = [
SpatialObject("a", BoundingBox(0, 0, 100, 100)),
SpatialObject("b", BoundingBox(50, 50, 150, 150)),
]
rels = compute_relationships(objs)
assert Relationship("a", RelationType.OVERLAPS, "b") in rels
assert Relationship("b", RelationType.OVERLAPS, "a") not in rels
assert sum(r.type is RelationType.NEAR for r in rels) == 1


def test_deterministic() -> None:
assert compute_relationships(scene()) == compute_relationships(scene())


def test_duplicate_ids_are_rejected() -> None:
box = BoundingBox(0, 0, 1, 1)
with pytest.raises(ValueError, match="unique"):
compute_relationships([SpatialObject("x", box), SpatialObject("x", box)])
150 changes: 150 additions & 0 deletions backend/tests/unit/spatial/test_relations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import pytest

from visual_graph.spatial import BoundingBox, SpatialConfig
from visual_graph.spatial.relations import (
above,
below,
gap_distance,
intersection_area,
left_of,
near,
overlaps,
right_of,
)

REF = BoundingBox(0, 0, 100, 100) # center (50, 50)


# --- horizontal -----------------------------------------------------------
def test_left_and_right_of() -> None:
right = BoundingBox(200, 0, 300, 100)
assert left_of(REF, right) and right_of(right, REF)
assert not left_of(right, REF) and not right_of(REF, right)


def test_equal_centers_are_neither_left_nor_right() -> None:
other = BoundingBox(25, 0, 75, 100) # same center_x
assert not left_of(REF, other) and not right_of(REF, other)


def test_left_of_with_partial_overlap_uses_centers() -> None:
other = BoundingBox(50, 0, 150, 100)
assert left_of(REF, other)


def test_directional_margin_boundary() -> None:
cfg = SpatialConfig(directional_margin=0.5)
exactly = BoundingBox(50, 0, 150, 100) # center offset 50 == 0.5 * 100
beyond = BoundingBox(51, 0, 151, 100)
assert not left_of(REF, exactly, cfg)
assert left_of(REF, beyond, cfg)


# --- vertical (y grows downward) --------------------------------------------
def test_above_and_below() -> None:
lower = BoundingBox(0, 200, 100, 300)
assert above(REF, lower) and below(lower, REF)
assert not above(lower, REF) and not below(REF, lower)


def test_equal_centers_are_neither_above_nor_below() -> None:
other = BoundingBox(0, 25, 100, 75)
assert not above(REF, other) and not below(REF, other)


def test_diagonal_pair_satisfies_both_axes() -> None:
other = BoundingBox(200, 200, 300, 300)
assert left_of(REF, other) and above(REF, other)


# --- NEAR -------------------------------------------------------------------
def test_near_exactly_at_threshold_is_inclusive() -> None:
# near_ratio 0.5 * scale 100 = 50
at = BoundingBox(150, 0, 250, 100)
assert gap_distance(REF, at) == 50
assert near(REF, at)


def test_near_slightly_outside_threshold() -> None:
outside = BoundingBox(150.001, 0, 250.001, 100)
assert not near(REF, outside)


def test_near_uses_euclidean_gap_on_diagonals() -> None:
cfg = SpatialConfig(near_ratio=0.5)
diagonal = BoundingBox(130, 140, 230, 240) # dx=30, dy=40 -> gap 50
assert gap_distance(REF, diagonal) == pytest.approx(50)
assert near(REF, diagonal, cfg)
assert not near(REF, BoundingBox(130, 141, 230, 241), cfg)


def test_near_threshold_is_configurable() -> None:
far = BoundingBox(300, 0, 400, 100) # gap 200
assert not near(REF, far)
assert near(REF, far, SpatialConfig(near_ratio=2.0))


def test_touching_and_overlapping_boxes_are_near() -> None:
assert near(REF, BoundingBox(100, 0, 200, 100))
assert near(REF, BoundingBox(50, 50, 150, 150))


def test_near_is_symmetric() -> None:
other = BoundingBox(150, 0, 250, 100)
assert near(REF, other) == near(other, REF)


# --- OVERLAPS -----------------------------------------------------------------
def test_partial_overlap() -> None:
other = BoundingBox(50, 50, 150, 150)
assert intersection_area(REF, other) == 2500
assert overlaps(REF, other) and overlaps(other, REF)


def test_complete_containment_overlaps() -> None:
inner = BoundingBox(25, 25, 75, 75)
assert overlaps(REF, inner) and overlaps(inner, REF)


def test_identical_boxes_overlap() -> None:
assert overlaps(REF, BoundingBox(0, 0, 100, 100))


@pytest.mark.parametrize(
"other",
[
BoundingBox(100, 0, 200, 100), # shared vertical edge
BoundingBox(0, 100, 100, 200), # shared horizontal edge
BoundingBox(100, 100, 200, 200), # shared corner
],
)
def test_touching_boundaries_do_not_overlap(other: BoundingBox) -> None:
assert intersection_area(REF, other) == 0
assert not overlaps(REF, other)


def test_disjoint_boxes_do_not_overlap() -> None:
assert not overlaps(REF, BoundingBox(200, 200, 300, 300))


def test_min_overlap_ratio_boundary() -> None:
cfg = SpatialConfig(min_overlap_ratio=0.25)
exactly = BoundingBox(50, 50, 150, 150) # 2500 / 10000 == 0.25
more = BoundingBox(49, 49, 149, 149)
assert not overlaps(REF, exactly, cfg)
assert overlaps(REF, more, cfg)


# --- config validation -------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
{"directional_margin": -0.1},
{"near_ratio": -1},
{"min_overlap_ratio": -0.1},
{"min_overlap_ratio": 1.0},
],
)
def test_invalid_config_is_rejected(kwargs: dict[str, float]) -> None:
with pytest.raises(ValueError):
SpatialConfig(**kwargs)
Loading