Files
rpa_vision_v3/tests/unit/test_bbox_parser.py
Dom bfbf0f9c3e refactor(grounding): centralise parser bbox_2d
Avant : 4 occurrences de parsing en cascade dans resolve_engine.py
(L840-885, L903-915, L2569-2580, ~110 lignes au total).

Après : centralisation dans core/grounding/bbox_parser.py avec
paramètre formats= permettant de filtrer les formats reconnus
selon le contrat sémantique de chaque site d'appel.

Préservation des contrats sémantiques (strict no-op) :
- Occ 1+2 (cascade principale) : tous formats (par défaut)
- Occ 3 (retry multi-image) : formats={"xy_json", "raw_array"}
  pour respecter le prompt qui impose {"x": NNN, "y": NNN} in pixels
- Occ 4 (_locate_popup_button) : formats={"bbox_2d"} pour respecter
  le prompt qui demande "bounding box"

Notes :
- Mini-bug Occ 3 retry multi-image (division systématique sans
  heuristique x>1, produisait coordonnées aberrantes ~0.0004 si
  VLM retournait déjà du pourcentage) corrigé incidemment via
  centralisation. Pas de régression possible (résultat précédent
  aberrant par construction).
- Occ 4 : bbox_2d strict 4-coords élargi à bbox_2d 2 ou 4 coords.
  Contrat sémantique "bounding box" respecté ; un point 2-coords
  interprété comme centre de bbox.

Tests : 26 cas dans test_bbox_parser.py (tous formats × cascade
+ filtre formats= + validated). 121 PASS / 0 FAIL sur le périmètre
refactor (5 fichiers ciblés).

Net : -96 lignes dans resolve_engine.py, +120 lignes module
+ 250 lignes tests.

refs DETTE-006 (étape 2/5 du fix smart_resize)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:30:25 +02:00

268 lines
8.9 KiB
Python

"""
Tests unitaires pour core.grounding.bbox_parser.
Module pur, indépendant de resolve_engine.py / agent_v0 / cv2.
Plan :
- A. Format 1 (bbox_2d) — 4 cas
- B. Format 2 (x/y JSON) — 3 cas
- C. Format 3 (x_pct/y_pct) — 1 cas
- D. Format 4 (array brut) — 3 cas
- E. Cascade et edge cases — 4 cas
- E-bis. Filtre formats= (parse_bbox_to_norm + parse_bbox_to_norm_validated) — 4 cas
- F. parse_bbox_to_norm_validated — 5 cas
- G. Type retour — 2 cas
Total : 26 cas.
"""
from core.grounding.bbox_parser import (
parse_bbox_to_norm,
parse_bbox_to_norm_validated,
)
# Dimensions de référence pour les divisions
W = 2560
H = 1600
# =====================================================================
# A. Format 1 — bbox_2d
# =====================================================================
class TestFormat1Bbox2d:
def test_2_coords_point(self):
# bbox_2d [x, y] : pixels divisés
content = '{"bbox_2d": [1280, 800], "label": "btn"}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 1280 / W
assert y == 800 / H
def test_4_coords_rect_center(self):
# bbox_2d [x1, y1, x2, y2] : centre du rect divisé
content = '{"bbox_2d": [100, 200, 300, 400]}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == (100 + 300) / 2 / W
assert y == (200 + 400) / 2 / H
def test_floats(self):
content = '{"bbox_2d": [1280.5, 800.25, 1300.0, 850.0]}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == (1280.5 + 1300.0) / 2 / W
assert y == (800.25 + 850.0) / 2 / H
def test_5_coords_uses_first_4(self):
# >= 4 coords : prend les 4 premières comme rect (comportement original)
content = '{"bbox_2d": [10, 20, 30, 40, 99]}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == (10 + 30) / 2 / W
assert y == (20 + 40) / 2 / H
# =====================================================================
# B. Format 2 — x/y JSON
# =====================================================================
class TestFormat2XYJson:
def test_pixels_x_above_1(self):
# x > 1 → divise par W (heuristique pixels)
content = '{"x": 1280, "y": 800}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 1280 / W
assert y == 800 / H
def test_already_pct_x_below_1(self):
# x <= 1 → considéré comme déjà normalisé, pas de division
content = '{"x": 0.5, "y": 0.3}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 0.5
assert y == 0.3
def test_x_exactly_1_treated_as_pct(self):
# x == 1 : x > 1 est False → traité comme pct (non divisé)
# Comportement original Occ 1+2 — fige la limite.
content = '{"x": 1.0, "y": 1.0}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 1.0
assert y == 1.0
# =====================================================================
# C. Format 3 — x_pct/y_pct
# =====================================================================
class TestFormat3XPctYPct:
def test_already_normalized(self):
content = '{"x_pct": 0.42, "y_pct": 0.68}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 0.42
assert y == 0.68
# =====================================================================
# D. Format 4 — array brut
# =====================================================================
class TestFormat4RawArray:
def test_2_coords(self):
content = "[1280, 800]"
x, y = parse_bbox_to_norm(content, W, H)
assert x == 1280 / W
assert y == 800 / H
def test_4_coords(self):
content = "[100, 200, 300, 400]"
x, y = parse_bbox_to_norm(content, W, H)
assert x == (100 + 300) / 2 / W
assert y == (200 + 400) / 2 / H
def test_floats(self):
content = "[100.5, 200.25, 300.0, 400.75]"
x, y = parse_bbox_to_norm(content, W, H)
assert x == (100.5 + 300.0) / 2 / W
assert y == (200.25 + 400.75) / 2 / H
# =====================================================================
# E. Cascade et edge cases
# =====================================================================
class TestCascadeAndEdge:
def test_bbox_2d_priority_over_array(self):
# bbox_2d présent ET array brut présent → bbox_2d gagne (testé en premier)
content = '{"bbox_2d": [10, 20], "extra": [9999, 9999]}'
x, y = parse_bbox_to_norm(content, W, H)
assert x == 10 / W
assert y == 20 / H
def test_empty_content_returns_none(self):
x, y = parse_bbox_to_norm("", W, H)
assert x is None
assert y is None
def test_no_match_returns_none(self):
# Texte ne contenant aucun format reconnu
x, y = parse_bbox_to_norm("Sorry, I cannot locate this element.", W, H)
assert x is None
assert y is None
def test_malformed_json_no_coords(self):
# JSON sans coordonnées
content = '{"label": "ok", "confidence": 0.9}'
x, y = parse_bbox_to_norm(content, W, H)
assert x is None
assert y is None
# =====================================================================
# E-bis. Filtre formats= (paramètre kwarg)
# =====================================================================
class TestFormatsFilter:
def test_formats_xy_json_only_excludes_bbox_2d(self):
# bbox_2d présent dans le content, mais formats=xy_json seul.
# Avec ce filtre : bbox_2d skipped, raw_array skipped, xy_json
# ne matche pas (regex `"x"\s*:` ne capture pas `"bbox_2d"`).
# → (None, None) confirmé.
content = '{"bbox_2d": [10, 20]}'
x, y = parse_bbox_to_norm(content, W, H, formats={"xy_json"})
assert x is None
assert y is None
def test_formats_xy_json_and_raw_array_excludes_xy_pct(self):
# Sous-ensemble Occ 3 : restreint à xy_json + raw_array.
# Content avec x_pct/y_pct uniquement → format 3 filtré, autres
# ne matchent pas → (None, None).
content = '{"x_pct": 0.42, "y_pct": 0.68}'
x, y = parse_bbox_to_norm(
content, W, H, formats={"xy_json", "raw_array"}
)
assert x is None
assert y is None
# =====================================================================
# F. parse_bbox_to_norm_validated
# =====================================================================
class TestValidated:
def test_inside_domain_returns_value(self):
# x_pct, y_pct ∈ [0, 1] → valeurs retournées
content = '{"x_pct": 0.42, "y_pct": 0.68}'
x, y = parse_bbox_to_norm_validated(content, W, H)
assert x == 0.42
assert y == 0.68
def test_x_negative_returns_none(self):
content = '{"x_pct": -0.1, "y_pct": 0.5}'
x, y = parse_bbox_to_norm_validated(content, W, H)
assert x is None
assert y is None
def test_x_above_1_returns_none(self):
# bbox_2d en pixels > divisor → x_pct > 1
content = '{"bbox_2d": [9999, 800]}'
x, y = parse_bbox_to_norm_validated(content, W, H)
assert x is None
assert y is None
def test_y_out_of_range_returns_none(self):
content = '{"x_pct": 0.5, "y_pct": 1.5}'
x, y = parse_bbox_to_norm_validated(content, W, H)
assert x is None
assert y is None
def test_no_parse_returns_none(self):
x, y = parse_bbox_to_norm_validated("nope", W, H)
assert x is None
assert y is None
def test_validated_formats_bbox_2d_only_valid(self):
# Sous-ensemble Occ 4 : restreint à bbox_2d, validation [0, 1].
# bbox_2d 4-coords valide → coordonnées normalisées dans le domaine.
content = '{"bbox_2d": [100, 200, 300, 400]}'
x, y = parse_bbox_to_norm_validated(
content, W, H, formats={"bbox_2d"}
)
assert x == (100 + 300) / 2 / W
assert y == (200 + 400) / 2 / H
def test_validated_formats_bbox_2d_only_excludes_xy_json(self):
# Sous-ensemble Occ 4 : si le VLM retourne {"x":..., "y":...}
# au lieu du bbox_2d demandé, le format est filtré → (None, None).
content = '{"x": 1280, "y": 800}'
x, y = parse_bbox_to_norm_validated(
content, W, H, formats={"bbox_2d"}
)
assert x is None
assert y is None
# =====================================================================
# G. Type retour
# =====================================================================
class TestReturnType:
def test_tuple_of_two(self):
result = parse_bbox_to_norm('{"bbox_2d": [10, 20]}', W, H)
assert isinstance(result, tuple)
assert len(result) == 2
def test_floats_or_none(self):
x, y = parse_bbox_to_norm('{"bbox_2d": [10, 20]}', W, H)
assert isinstance(x, float)
assert isinstance(y, float)
x_none, y_none = parse_bbox_to_norm("nope", W, H)
assert x_none is None
assert y_none is None