"""Tests unitaires anchor_relative — Phase 1 standalone. Couvre : - catalog : match sur titre Save As, no-match sur titre random. - find_target_via_anchor : ancre absente, ancre hors zone, ancre OK bas-droite + offset, cross-check target OK, cross-check absent. Tous les tests utilisent un détecteur mocké : pas d'OCR réel, pas d'image, pas de dépendance externe (cv2/PIL). Exécution ~10ms total. """ from __future__ import annotations import sys from pathlib import Path from typing import Dict, Optional, Tuple import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from agent_v0.agent_v1.core.anchor_catalog import ( # noqa: E402 ANCHOR_ENTRIES, find_entry_for_title, ) from agent_v0.agent_v1.core.anchor_relative import ( # noqa: E402 AnchorMatch, find_target_via_anchor, ) pytestmark = pytest.mark.unit # --------------------------------------------------------------------------- # Helpers — fabrique de détecteurs mockés # --------------------------------------------------------------------------- def _make_detector(positions: Dict[str, Optional[Tuple[int, int]]]): """Construit un détecteur fake : label → position fixe (ou None).""" def _det(_screenshot_b64: str, label: str) -> Optional[Tuple[int, int]]: return positions.get(label) return _det # Geometry typique Save As Notepad : ancre attendue bas-droite, # offset -100 px à gauche, écran 1920x1080. _SAVE_AS_GEOMETRY = { "region": "bottom-right", "min_x_norm": 0.55, "min_y_norm": 0.75, "max_x_norm": 1.0, "max_y_norm": 1.0, "offset_from_anchor": {"x_px": -100, "y_px": 0}, } _SCREEN_W, _SCREEN_H = 1920, 1080 # --------------------------------------------------------------------------- # Catalog # --------------------------------------------------------------------------- def test_catalog_match_save_as_title(): """Le titre 'Enregistrer sous' matche l'entrée notepad_save_as.""" entry = find_entry_for_title("Enregistrer sous") assert entry is not None assert entry["id"] == "notepad_save_as_enregistrer" assert "Annuler" in entry["anchor_label"] assert entry["target_label"] == "Enregistrer" # Insensible à la casse + substring (vrai titre Win11) entry2 = find_entry_for_title("*test - Bloc-notes — Enregistrer sous") assert entry2 is not None assert entry2["id"] == "notepad_save_as_enregistrer" def test_catalog_no_match_random_title(): """Un titre inconnu retourne None, pas d'exception.""" assert find_entry_for_title("Firefox - Mozilla") is None assert find_entry_for_title("") is None assert find_entry_for_title(None) is None # tolérance def test_catalog_entries_are_well_formed(): """Chaque entrée du catalog a les champs requis (garde anti-régression).""" required = {"id", "title_patterns", "anchor_label", "target_label", "geometry_hint"} for entry in ANCHOR_ENTRIES: missing = required - set(entry.keys()) assert not missing, f"entry {entry.get('id')} missing fields: {missing}" gh = entry["geometry_hint"] assert "offset_from_anchor" in gh for key in ("min_x_norm", "max_x_norm", "min_y_norm", "max_y_norm"): assert key in gh, f"{entry['id']}.geometry_hint missing {key}" # --------------------------------------------------------------------------- # find_target_via_anchor — cas d'erreur # --------------------------------------------------------------------------- def test_find_target_anchor_absent_returns_not_found(): """Ancre OCR pas trouvée → found=False, reason='anchor_not_found'.""" det = _make_detector({}) # rien ne matche result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, ) assert isinstance(result, AnchorMatch) assert result.found is False assert result.reason == "anchor_not_found" assert result.confidence == 0.0 assert result.target_x_pct == 0.0 assert result.target_y_pct == 0.0 assert "anchor_candidates_tried" in result.evidence def test_find_target_anchor_out_of_geometry_zone(): """Ancre trouvée mais hors zone bas-droite → found=False, reason explicite.""" # "Annuler" détecté en haut-gauche (faux positif probable du menu Fichier) det = _make_detector({"Annuler": (100, 50)}) result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, ) assert result.found is False assert result.reason == "anchor_out_of_zone" # L'ancre a été localisée → on remonte sa position pour debug assert result.anchor_x_pct == pytest.approx(100 / _SCREEN_W) assert result.anchor_y_pct == pytest.approx(50 / _SCREEN_H) assert result.evidence.get("anchor_matched_label") == "Annuler" def test_find_target_offset_out_of_bounds_returns_not_found(): """Offset qui sort de l'ecran → pas de candidat cliquable.""" det = _make_detector({"Annuler": (1700, 900)}) geometry = { **_SAVE_AS_GEOMETRY, "offset_from_anchor": {"x_px": 400, "y_px": 0}, } result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=geometry, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, ) assert result.found is False assert result.reason == "target_out_of_bounds" assert result.target_x_pct > 1.0 # --------------------------------------------------------------------------- # find_target_via_anchor — cas nominaux # --------------------------------------------------------------------------- def test_find_target_bottom_right_anchor_with_offset_returns_candidate(): """Ancre dans la zone + offset → coordonnées candidate correctes.""" # "Annuler" à (1600, 900), pas de target visible (cross_check absent). det = _make_detector({"Annuler": (1600, 900)}) result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=True, ) assert result.found is True # Target attendu : 1600 - 100 = 1500 px → 1500/1920 = 0.78125 assert result.target_x_pct == pytest.approx(1500 / _SCREEN_W) assert result.target_y_pct == pytest.approx(900 / _SCREEN_H) assert result.anchor_x_pct == pytest.approx(1600 / _SCREEN_W) # Cross-check tenté mais absent → confidence reste à 0.5 assert result.confidence == pytest.approx(0.5) def test_find_target_anchor_fallback_en_when_fr_absent(): """Fallback EN ('Cancel') quand 'Annuler' absent.""" det = _make_detector({"Cancel": (1700, 950)}) result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Save", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=False, ) assert result.found is True assert result.evidence["anchor_matched_label"] == "Cancel" # --------------------------------------------------------------------------- # find_target_via_anchor — cross-check # --------------------------------------------------------------------------- def test_find_target_with_cross_check_target_visible_high_confidence(): """Cross-check target_label OK proche de l'offset → confidence > 0.8.""" # "Annuler" à (1600, 900), "Enregistrer" à (1505, 902) — très proche de # la position calculée (1500, 900), dist ~ 5 px. det = _make_detector({ "Annuler": (1600, 900), "Enregistrer": (1505, 902), }) result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=True, ) assert result.found is True assert result.confidence > 0.8 assert result.reason == "anchor_plus_target_cross_check" # Position raffinée sur la détection target assert result.target_x_pct == pytest.approx(1505 / _SCREEN_W) assert result.target_y_pct == pytest.approx(902 / _SCREEN_H) assert result.evidence["target_cross_check_dist_px"] < 10 def test_find_target_cross_check_absent_documented_behaviour(): """Cross-check absent → comportement documenté : found=True, confidence=0.5. Décision design (cf. design unifié §3, confidence.anchor_only=0.5) : on retourne quand même un candidat utilisable, mais avec confidence réduite, pour que l'orchestrateur (Phase 2) puisse décider de demander supervision. """ det = _make_detector({"Annuler": (1600, 900)}) # target absent result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=True, ) assert result.found is True assert result.confidence == pytest.approx(0.5) assert result.reason == "anchor_only_target_not_visible" assert result.evidence.get("target_cross_check_dist_px") is None def test_find_target_cross_check_target_far_degrades_confidence(): """Target détecté mais loin de l'offset attendu → confidence dégradée.""" # "Annuler" à (1600, 900). Offset attendu cible = (1500, 900). # "Enregistrer" détecté à (300, 200) — clairement un faux positif (menu). det = _make_detector({ "Annuler": (1600, 900), "Enregistrer": (300, 200), }) result = find_target_via_anchor( anchor_label=["Annuler", "Cancel"], target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=True, ) assert result.found is True assert result.confidence < 0.5 assert result.reason == "anchor_ok_target_drift_high" # On garde la position offset, pas celle du faux positif assert result.target_x_pct == pytest.approx(1500 / _SCREEN_W) def test_find_target_cross_check_disabled_returns_anchor_only_confidence(): """cross_check_target=False → confidence=0.5, reason='anchor_only'.""" det = _make_detector({"Annuler": (1600, 900)}) result = find_target_via_anchor( anchor_label="Annuler", # accepte aussi un string simple target_label="Enregistrer", geometry_hint=_SAVE_AS_GEOMETRY, screenshot_b64="fake_b64", screen_width=_SCREEN_W, screen_height=_SCREEN_H, detector=det, cross_check_target=False, ) assert result.found is True assert result.confidence == pytest.approx(0.5) assert result.reason == "anchor_only" # Détecteur n'a été appelé QUE pour l'ancre (1 hit), pas pour la cible assert "target_detected_px" not in result.evidence