""" Tests visuels sur captures d'écran réelles — Grounding benchmark. Vérifie que le système trouve les bons éléments UI sur des screenshots Windows réels. Pas besoin de VM — juste les images et le serveur. Chaque test : 1. Charge un screenshot réel (sessions enregistrées) 2. Demande au serveur de localiser un élément (via /resolve_target) 3. Vérifie que les coordonnées retournées sont dans la zone attendue C'est l'apprentissage de l'environnement Windows : - Rechercher un programme - Fermer/réduire/agrandir une fenêtre - Naviguer dans les onglets - Utiliser les menus """ import base64 import io import json import os import sys from pathlib import Path from typing import Optional, Tuple from unittest.mock import MagicMock, patch import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) # Répertoire des screenshots de test _SHOTS_DIR = Path(_ROOT) / "data/training/live_sessions/DESKTOP-ST3VBSD_windows/sess_20260404T135010_cec5c8/shots" # Résolution des screenshots _SCREEN_W = 1280 _SCREEN_H = 800 def _load_screenshot(name: str) -> Optional[str]: """Charger un screenshot en base64.""" path = _SHOTS_DIR / name if not path.is_file(): pytest.skip(f"Screenshot {name} non disponible") return base64.b64encode(path.read_bytes()).decode() def _in_zone(x_pct: float, y_pct: float, zone: dict) -> bool: """Vérifier si un point est dans une zone attendue. zone = {"x_min": 0.3, "x_max": 0.5, "y_min": 0.9, "y_max": 1.0} """ return ( zone["x_min"] <= x_pct <= zone["x_max"] and zone["y_min"] <= y_pct <= zone["y_max"] ) def _resolve_via_server( screenshot_b64: str, target_spec: dict, strict: bool = True, ) -> Optional[dict]: """Résoudre une cible visuellement via le VLM (qwen2.5vl grounding direct). Appelle qwen2.5vl directement pour le grounding (bbox_2d). Si le VLM ne trouve pas, essaie aussi via l'endpoint serveur. """ import requests import re # ── Stratégie 1 : Grounding VLM direct (qwen2.5vl) ── by_text = target_spec.get("by_text", "") vlm_desc = target_spec.get("vlm_description", "") search_text = by_text or vlm_desc if search_text: try: prompt = f"Detect the element '{search_text}' with a bounding box." resp = requests.post( "http://localhost:11434/api/chat", json={ "model": "qwen2.5vl:7b", "messages": [{"role": "user", "content": prompt, "images": [screenshot_b64]}], "stream": False, "options": {"temperature": 0.0, "num_predict": 100}, }, timeout=30, ) if resp.ok: content = resp.json().get("message", {}).get("content", "") # Parser bbox_2d — qwen2.5vl retourne des pixels relatifs à l'image, # PAS une grille 1000x1000. bbox_match = re.search( r'"bbox_2d"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]', content, ) if bbox_match: x1, y1, x2, y2 = [int(bbox_match.group(i)) for i in range(1, 5)] # Normaliser par les dimensions de l'image (pixels → 0-1) cx = (x1 + x2) / 2 / _SCREEN_W cy = (y1 + y2) / 2 / _SCREEN_H if 0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0: return { "resolved": True, "method": "vlm_grounding", "x_pct": cx, "y_pct": cy, "score": 0.8, "raw_bbox": [x1, y1, x2, y2], } except requests.Timeout: pytest.skip("qwen2.5vl timeout — premier chargement ?") except requests.ConnectionError: pytest.skip("Ollama non disponible (localhost:11434)") # ── Stratégie 2 : Endpoint serveur (fallback) ── token = os.environ.get("RPA_API_TOKEN", "") if not token: env_file = Path(_ROOT) / ".env.local" if env_file.is_file(): for line in env_file.read_text().splitlines(): if line.startswith("RPA_API_TOKEN="): token = line.split("=", 1)[1].strip() headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" try: resp = requests.post( "http://localhost:5005/api/v1/traces/stream/replay/resolve_target", json={ "session_id": "visual_test", "screenshot_b64": screenshot_b64, "target_spec": target_spec, "screen_width": _SCREEN_W, "screen_height": _SCREEN_H, "fallback_x_pct": 0.5, "fallback_y_pct": 0.5, "strict_mode": strict, }, headers=headers, timeout=30, ) if resp.ok: data = resp.json() if data.get("resolved"): return data except Exception: pass return None def _assert_found_in_zone(result: dict, zone: dict, element_name: str): """Vérifier qu'un élément a été trouvé dans la zone attendue.""" assert result is not None, f"{element_name}: pas de réponse du serveur" assert result.get("resolved"), ( f"{element_name}: non trouvé (reason={result.get('reason', '?')})" ) x = result.get("x_pct", 0) y = result.get("y_pct", 0) assert _in_zone(x, y, zone), ( f"{element_name}: trouvé à ({x:.3f}, {y:.3f}) " f"mais attendu dans zone x=[{zone['x_min']:.2f}-{zone['x_max']:.2f}] " f"y=[{zone['y_min']:.2f}-{zone['y_max']:.2f}]" ) # ========================================================================= # shot_0001 : Explorateur de fichiers Windows # ========================================================================= @pytest.mark.visual class TestExplorateurFichiers: """Tests sur l'Explorateur de fichiers Windows (shot_0001).""" @pytest.fixture def screenshot(self): return _load_screenshot("shot_0001_full.png") def test_trouver_rechercher_taskbar(self, screenshot): """Trouver 'Rechercher' dans la barre des tâches.""" result = _resolve_via_server(screenshot, { "by_text": "Rechercher", "vlm_description": "La barre de recherche Windows dans la barre des tâches, en bas de l'écran", }) _assert_found_in_zone(result, { "x_min": 0.20, "x_max": 0.50, "y_min": 0.90, "y_max": 1.00, }, "Rechercher (taskbar)") def test_trouver_bouton_fermer_explorateur(self, screenshot): """Trouver le bouton X (fermer) de l'Explorateur.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton fermer (X) de la fenêtre Explorateur de fichiers, en haut à droite", }) _assert_found_in_zone(result, { "x_min": 0.90, "x_max": 1.00, "y_min": 0.00, "y_max": 0.05, }, "Bouton fermer (X)") def test_trouver_bouton_reduire(self, screenshot): """Trouver le bouton réduire (-) de l'Explorateur.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton réduire (minimize, -) de la fenêtre, en haut à droite à gauche du X", }) _assert_found_in_zone(result, { "x_min": 0.85, "x_max": 0.95, "y_min": 0.00, "y_max": 0.05, }, "Bouton réduire (-)") def test_trouver_dossier_agent_v1(self, screenshot): """Trouver le dossier 'agent_v1' dans la liste des fichiers.""" result = _resolve_via_server(screenshot, { "by_text": "agent_v1", "vlm_description": "Le dossier agent_v1 dans la liste des fichiers de l'Explorateur", }) _assert_found_in_zone(result, { "x_min": 0.05, "x_max": 0.50, "y_min": 0.10, "y_max": 0.30, }, "Dossier agent_v1") def test_trouver_bouton_demarrer(self, screenshot): """Trouver le bouton Démarrer (Windows) dans la barre des tâches.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton Démarrer (logo Windows) dans la barre des tâches, en bas", }) _assert_found_in_zone(result, { "x_min": 0.18, "x_max": 0.30, "y_min": 0.90, "y_max": 1.00, }, "Bouton Démarrer") def test_trouver_ce_pc(self, screenshot): """Trouver 'Ce PC' dans le panneau latéral de l'Explorateur.""" result = _resolve_via_server(screenshot, { "by_text": "Ce PC", "vlm_description": "L'élément 'Ce PC' dans le panneau de navigation gauche de l'Explorateur", }) _assert_found_in_zone(result, { "x_min": 0.00, "x_max": 0.12, "y_min": 0.40, "y_max": 0.55, }, "Ce PC") # ========================================================================= # shot_0004 : Bloc-notes avec onglets + Explorateur derrière # ========================================================================= @pytest.mark.visual class TestBlocNotesOnglets: """Tests sur le Bloc-notes avec plusieurs onglets (shot_0004).""" @pytest.fixture def screenshot(self): return _load_screenshot("shot_0004_full.png") def test_trouver_menu_fichier(self, screenshot): """Trouver le menu 'Fichier' du Bloc-notes.""" result = _resolve_via_server(screenshot, { "by_text": "Fichier", "vlm_description": "Le menu Fichier dans la barre de menus du Bloc-notes", }) _assert_found_in_zone(result, { "x_min": 0.02, "x_max": 0.10, "y_min": 0.08, "y_max": 0.15, }, "Menu Fichier") def test_trouver_onglet_ceci_est_un_test(self, screenshot): """Trouver l'onglet 'Ceci est un test.txt' dans le Bloc-notes.""" result = _resolve_via_server(screenshot, { "by_text": "Ceci est un test", "vlm_description": "L'onglet 'Ceci est un test.txt' dans le Bloc-notes", }) _assert_found_in_zone(result, { "x_min": 0.40, "x_max": 0.70, "y_min": 0.03, "y_max": 0.10, }, "Onglet 'Ceci est un test.txt'") def test_trouver_nouvel_onglet_plus(self, screenshot): """Trouver le bouton '+' pour ajouter un nouvel onglet.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton + (plus) pour ajouter un nouvel onglet dans le Bloc-notes, à droite des onglets", }) _assert_found_in_zone(result, { "x_min": 0.55, "x_max": 0.70, "y_min": 0.03, "y_max": 0.10, }, "Bouton + (nouvel onglet)") def test_trouver_bouton_fermer_onglet(self, screenshot): """Trouver le X de fermeture de l'onglet actif.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton X pour fermer l'onglet actif 'Ceci est un test.txt' dans le Bloc-notes", }) _assert_found_in_zone(result, { "x_min": 0.50, "x_max": 0.65, "y_min": 0.03, "y_max": 0.10, }, "Fermer onglet (X)") def test_trouver_menu_modifier(self, screenshot): """Trouver le menu 'Modifier' du Bloc-notes.""" result = _resolve_via_server(screenshot, { "by_text": "Modifier", "vlm_description": "Le menu Modifier dans la barre de menus du Bloc-notes", }) _assert_found_in_zone(result, { "x_min": 0.07, "x_max": 0.16, "y_min": 0.08, "y_max": 0.15, }, "Menu Modifier") def test_trouver_encodage_utf8(self, screenshot): """Trouver l'indicateur d'encodage UTF-8 dans la barre de statut.""" result = _resolve_via_server(screenshot, { "by_text": "UTF-8", "vlm_description": "L'indicateur d'encodage UTF-8 dans la barre de statut en bas du Bloc-notes", }) _assert_found_in_zone(result, { "x_min": 0.60, "x_max": 0.80, "y_min": 0.90, "y_max": 1.00, }, "UTF-8 (barre de statut)") # ========================================================================= # shot_0014 : Google Chrome page d'accueil # ========================================================================= @pytest.mark.visual class TestGoogleChrome: """Tests sur Google Chrome avec page d'accueil (shot_0014).""" @pytest.fixture def screenshot(self): return _load_screenshot("shot_0014_full.png") def test_trouver_barre_recherche_google(self, screenshot): """Trouver la barre de recherche Google au centre.""" result = _resolve_via_server(screenshot, { "by_text": "Rechercher sur Google", "vlm_description": "La barre de recherche Google au centre de la page d'accueil", }) _assert_found_in_zone(result, { "x_min": 0.10, "x_max": 0.60, "y_min": 0.30, "y_max": 0.50, }, "Barre recherche Google") def test_trouver_barre_adresse_chrome(self, screenshot): """Trouver la barre d'adresse de Chrome en haut.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "La barre d'adresse URL de Google Chrome, en haut du navigateur", }) _assert_found_in_zone(result, { "x_min": 0.10, "x_max": 0.60, "y_min": 0.05, "y_max": 0.15, }, "Barre d'adresse Chrome") def test_trouver_nouvel_onglet_chrome(self, screenshot): """Trouver le bouton '+' pour un nouvel onglet Chrome.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton + pour ouvrir un nouvel onglet dans Google Chrome", }) _assert_found_in_zone(result, { "x_min": 0.15, "x_max": 0.25, "y_min": 0.00, "y_max": 0.06, }, "Nouvel onglet (+) Chrome") def test_trouver_fermer_chrome(self, screenshot): """Trouver le bouton X pour fermer Chrome.""" result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton fermer (X) de la fenêtre Google Chrome, en haut à droite", }) _assert_found_in_zone(result, { "x_min": 0.90, "x_max": 1.00, "y_min": 0.00, "y_max": 0.06, }, "Fermer Chrome (X)") def test_trouver_gmail(self, screenshot): """Trouver le lien Gmail sur la page d'accueil Google.""" result = _resolve_via_server(screenshot, { "by_text": "Gmail", "vlm_description": "Le lien Gmail en haut à droite de la page Google", }) _assert_found_in_zone(result, { "x_min": 0.50, "x_max": 0.80, "y_min": 0.10, "y_max": 0.20, }, "Gmail") # ========================================================================= # Tests transversaux (connaissances de base Windows) # ========================================================================= @pytest.mark.visual class TestConnaissancesWindowsBase: """Connaissances de base Windows que tout utilisateur connaît.""" def test_rechercher_programme_depuis_explorateur(self): """Depuis l'Explorateur, trouver la barre de recherche Windows.""" screenshot = _load_screenshot("shot_0001_full.png") result = _resolve_via_server(screenshot, { "by_text": "Rechercher", "vlm_description": "La barre de recherche dans la barre des tâches Windows en bas de l'écran", }) assert result and result.get("resolved"), "Rechercher non trouvé" def test_fermer_programme_depuis_blocnotes(self): """Depuis le Bloc-notes, trouver le bouton fermer.""" screenshot = _load_screenshot("shot_0004_full.png") result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton X pour fermer la fenêtre du Bloc-notes, en haut à droite", }) assert result and result.get("resolved"), "Bouton fermer non trouvé" def test_ajouter_onglet_blocnotes(self): """Ajouter un nouvel onglet dans le Bloc-notes.""" screenshot = _load_screenshot("shot_0004_full.png") result = _resolve_via_server(screenshot, { "by_text": "", "vlm_description": "Le bouton + pour ajouter un nouvel onglet dans le Bloc-notes", }) assert result and result.get("resolved"), "Bouton + non trouvé" def test_rechercher_sur_google(self): """Taper dans la barre de recherche Google.""" screenshot = _load_screenshot("shot_0014_full.png") result = _resolve_via_server(screenshot, { "by_text": "Rechercher sur Google", "vlm_description": "Le champ de recherche Google", }) assert result and result.get("resolved"), "Recherche Google non trouvée"