Files
rpa_vision_v3/tests/unit/test_anchor_relative.py

318 lines
12 KiB
Python

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