""" 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