318 lines
12 KiB
Python
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
|