From 5be2446734e0fedc56157c9c384842021c7d6b93 Mon Sep 17 00:00:00 2001 From: raphaellyzzz Date: Fri, 6 Nov 2026 21:53:39 -0300 Subject: [PATCH] test(spatial): add unit tests for spatial relationships Closes #8 --- backend/tests/unit/spatial/conftest.py | 14 ++ backend/tests/unit/spatial/test_bbox.py | 36 +++++ backend/tests/unit/spatial/test_engine.py | 56 +++++++ backend/tests/unit/spatial/test_relations.py | 150 +++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 backend/tests/unit/spatial/conftest.py create mode 100644 backend/tests/unit/spatial/test_bbox.py create mode 100644 backend/tests/unit/spatial/test_engine.py create mode 100644 backend/tests/unit/spatial/test_relations.py diff --git a/backend/tests/unit/spatial/conftest.py b/backend/tests/unit/spatial/conftest.py new file mode 100644 index 0000000..3ccb257 --- /dev/null +++ b/backend/tests/unit/spatial/conftest.py @@ -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() diff --git a/backend/tests/unit/spatial/test_bbox.py b/backend/tests/unit/spatial/test_bbox.py new file mode 100644 index 0000000..2c3491d --- /dev/null +++ b/backend/tests/unit/spatial/test_bbox.py @@ -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) diff --git a/backend/tests/unit/spatial/test_engine.py b/backend/tests/unit/spatial/test_engine.py new file mode 100644 index 0000000..80a02cd --- /dev/null +++ b/backend/tests/unit/spatial/test_engine.py @@ -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)]) diff --git a/backend/tests/unit/spatial/test_relations.py b/backend/tests/unit/spatial/test_relations.py new file mode 100644 index 0000000..aa58256 --- /dev/null +++ b/backend/tests/unit/spatial/test_relations.py @@ -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)