""" Tests du SurfaceClassifier et du UIAHelper. Vérifie : - Détection correcte des 4 types de surfaces (citrix, windows_native, web, unknown) - Paramètres adaptés par surface (timeouts, seuils) - Fallback gracieux si helper UIA absent - Sérialisation des profils - Wrapper UIAHelper avec mocks subprocess """ import json import sys from pathlib import Path 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) from core.workflow.surface_classifier import ( SurfaceClassifier, SurfaceProfile, SurfaceType, ) from core.workflow.uia_helper import UIAHelper, UiaElement # ========================================================================= # SurfaceClassifier — détection par processus # ========================================================================= class TestSurfaceClassifier: """Tests de détection des surfaces.""" def _classifier(self): """Classifier sans helper UIA (pour que les tests soient reproductibles).""" return SurfaceClassifier(uia_helper_path="") def test_detection_citrix_wfica(self): """wfica32.exe → Citrix.""" c = self._classifier() profile = c.classify(process_name="wfica32.exe", window_title="Session Citrix") assert profile.surface_type == SurfaceType.CITRIX assert profile.uia_available is False assert profile.ocr_threshold < 0.75 # Plus tolérant assert profile.max_retries >= 3 def test_detection_citrix_mstsc(self): """mstsc.exe → Citrix (RDP).""" c = self._classifier() profile = c.classify(process_name="mstsc.exe", window_title="Remote Desktop") assert profile.surface_type == SurfaceType.CITRIX def test_detection_citrix_par_titre(self): """Titre 'Citrix' → Citrix même si process non listé.""" c = self._classifier() profile = c.classify(process_name="chrome.exe", window_title="DxCare - Citrix Receiver") assert profile.surface_type == SurfaceType.CITRIX def test_detection_windows_natif_notepad(self): """notepad.exe → Windows natif.""" c = self._classifier() profile = c.classify(process_name="notepad.exe", window_title="Sans titre – Bloc-notes") assert profile.surface_type == SurfaceType.WINDOWS_NATIVE assert profile.ocr_threshold == 0.75 assert profile.timeout_click_ms == 8000 def test_detection_windows_natif_explorer(self): """explorer.exe → Windows natif (cas spécial).""" c = self._classifier() profile = c.classify(process_name="explorer.exe", window_title="Lea") assert profile.surface_type == SurfaceType.WINDOWS_NATIVE def test_detection_windows_natif_dxcare(self): """dxcare.exe (DPI hospitalier) → Windows natif.""" c = self._classifier() profile = c.classify(process_name="dxcare.exe", window_title="DxCare - Dossier 12345") assert profile.surface_type == SurfaceType.WINDOWS_NATIVE def test_detection_web_chrome(self): """chrome.exe → Web local.""" c = self._classifier() profile = c.classify(process_name="chrome.exe", window_title="Google - Google Chrome") assert profile.surface_type == SurfaceType.WEB_LOCAL assert profile.ocr_threshold == 0.80 # Plus strict (texte bien rendu) assert profile.max_retries == 1 # Rapide def test_detection_web_edge(self): c = self._classifier() profile = c.classify(process_name="msedge.exe", window_title="Edge") assert profile.surface_type == SurfaceType.WEB_LOCAL def test_detection_web_firefox(self): c = self._classifier() profile = c.classify(process_name="firefox.exe", window_title="Firefox") assert profile.surface_type == SurfaceType.WEB_LOCAL def test_detection_unknown_fallback(self): """Process non reconnu → unknown avec paramètres sûrs.""" c = self._classifier() profile = c.classify(process_name="", window_title="") assert profile.surface_type == SurfaceType.UNKNOWN assert profile.confidence < 1.0 assert profile.ocr_available is True # OCR toujours dispo def test_citrix_dans_navigateur(self): """Citrix embedded dans Chrome → Citrix.""" c = self._classifier() profile = c.classify( process_name="chrome.exe", window_title="Citrix Workspace - DxCare", ) assert profile.surface_type == SurfaceType.CITRIX def test_resolve_order_par_surface(self): """Ordre de résolution cohérent avec la surface.""" c = self._classifier() citrix = c.classify("wfica32.exe", "Session") assert "uia" not in citrix.resolve_order() assert "ocr" in citrix.resolve_order() windows = c.classify("notepad.exe", "Bloc-notes") # UIA pas dispo (helper path vide) donc absent assert "ocr" in windows.resolve_order() web = c.classify("chrome.exe", "Google") assert "ocr" in web.resolve_order() class TestSurfaceProfile: """Tests du dataclass SurfaceProfile.""" def test_to_dict_structure(self): p = SurfaceProfile( surface_type=SurfaceType.WINDOWS_NATIVE, process_name="notepad.exe", window_title="Test", ) d = p.to_dict() assert d["surface_type"] == "windows_native" assert "capabilities" in d assert "parameters" in d assert d["capabilities"]["ocr"] is True assert d["capabilities"]["uia"] is False # Par défaut def test_resolve_order_construction(self): """L'ordre de résolution utilise les capacités dispo.""" p = SurfaceProfile( surface_type=SurfaceType.WINDOWS_NATIVE, uia_available=True, ) order = p.resolve_order() assert order[0] == "uia" # UIA en premier si dispo assert "ocr" in order assert "vlm" in order def test_resolve_order_sans_uia(self): p = SurfaceProfile( surface_type=SurfaceType.CITRIX, uia_available=False, ) order = p.resolve_order() assert "uia" not in order assert order[0] == "ocr" # OCR en premier # ========================================================================= # UIAHelper — wrapper Python # ========================================================================= class TestUIAHelper: """Tests du wrapper UIAHelper.""" def test_initialization_sans_helper(self): """Sans helper trouvé, available = False.""" helper = UIAHelper(helper_path="/chemin/inexistant.exe") assert helper.available is False def test_query_retourne_none_si_indispo(self): """Si le helper n'est pas dispo, query retourne None.""" helper = UIAHelper(helper_path="/chemin/inexistant.exe") result = helper.query_at(100, 200) assert result is None def test_find_retourne_none_si_indispo(self): helper = UIAHelper(helper_path="/chemin/inexistant.exe") result = helper.find_by_name("Enregistrer") assert result is None def test_health_retourne_false_si_indispo(self): helper = UIAHelper(helper_path="/chemin/inexistant.exe") assert helper.health() is False @patch("core.workflow.uia_helper.os.path.isfile", return_value=True) @patch("core.workflow.uia_helper.platform.system", return_value="Windows") @patch("core.workflow.uia_helper.subprocess.run") def test_query_success_mock(self, mock_run, mock_platform, mock_isfile): """Query avec mock subprocess retourne un UiaElement.""" mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = json.dumps({ "status": "ok", "element": { "name": "Enregistrer", "control_type": "bouton", "class_name": "Button", "automation_id": "btnSave", "bounding_rect": [100, 200, 200, 250], "is_enabled": True, "is_offscreen": False, "parent_path": [ {"name": "Bloc-notes", "control_type": "fenêtre"} ], }, "elapsed_ms": 15, }) mock_run.return_value = mock_result helper = UIAHelper(helper_path="fake_lea_uia.exe") element = helper.query_at(150, 225) assert element is not None assert element.name == "Enregistrer" assert element.control_type == "bouton" assert element.bounding_rect == (100, 200, 200, 250) assert element.center() == (150, 225) assert element.is_clickable() is True assert len(element.parent_path) == 1 @patch("core.workflow.uia_helper.os.path.isfile", return_value=True) @patch("core.workflow.uia_helper.platform.system", return_value="Windows") @patch("core.workflow.uia_helper.subprocess.run") def test_find_success_mock(self, mock_run, mock_platform, mock_isfile): mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = json.dumps({ "status": "ok", "element": { "name": "Fichier", "control_type": "menu", "bounding_rect": [0, 20, 50, 40], "is_enabled": True, "is_offscreen": False, }, }) mock_run.return_value = mock_result helper = UIAHelper(helper_path="fake.exe") element = helper.find_by_name("Fichier", control_type="menu") assert element is not None assert element.name == "Fichier" @patch("core.workflow.uia_helper.os.path.isfile", return_value=True) @patch("core.workflow.uia_helper.platform.system", return_value="Windows") @patch("core.workflow.uia_helper.subprocess.run") def test_not_found(self, mock_run, mock_platform, mock_isfile): mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = json.dumps({ "status": "not_found", "reason": "Pas d'élément", "elapsed_ms": 5, }) mock_run.return_value = mock_result helper = UIAHelper(helper_path="fake.exe") assert helper.query_at(999, 999) is None @patch("core.workflow.uia_helper.os.path.isfile", return_value=True) @patch("core.workflow.uia_helper.platform.system", return_value="Windows") @patch("core.workflow.uia_helper.subprocess.run") def test_timeout(self, mock_run, mock_platform, mock_isfile): """Un timeout subprocess ne fait pas crash le helper.""" import subprocess as sp mock_run.side_effect = sp.TimeoutExpired("lea_uia", 5) helper = UIAHelper(helper_path="fake.exe") assert helper.query_at(100, 100) is None @patch("core.workflow.uia_helper.os.path.isfile", return_value=True) @patch("core.workflow.uia_helper.platform.system", return_value="Windows") @patch("core.workflow.uia_helper.subprocess.run") def test_json_invalide(self, mock_run, mock_platform, mock_isfile): """Une sortie non-JSON ne fait pas crash.""" mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "pas du JSON" mock_run.return_value = mock_result helper = UIAHelper(helper_path="fake.exe") assert helper.query_at(100, 100) is None class TestUiaElement: """Tests du dataclass UiaElement.""" def test_from_dict_minimal(self): e = UiaElement.from_dict({"name": "test"}) assert e.name == "test" assert e.bounding_rect == (0, 0, 0, 0) def test_center(self): e = UiaElement(bounding_rect=(100, 200, 200, 300)) assert e.center() == (150, 250) def test_is_clickable(self): e = UiaElement( bounding_rect=(100, 100, 200, 150), is_enabled=True, is_offscreen=False, ) assert e.is_clickable() is True e2 = UiaElement( bounding_rect=(100, 100, 200, 150), is_enabled=False, is_offscreen=False, ) assert e2.is_clickable() is False def test_path_signature(self): e = UiaElement( name="Enregistrer", control_type="bouton", parent_path=[ {"name": "Bloc-notes", "control_type": "fenêtre"}, {"name": "Fichier", "control_type": "menu"}, ], ) sig = e.path_signature() assert "Bloc-notes" in sig assert "Enregistrer" in sig assert " > " in sig def test_roundtrip_dict(self): original = UiaElement( name="test", control_type="bouton", bounding_rect=(10, 20, 30, 40), is_enabled=True, is_offscreen=False, ) d = original.to_dict() copy = UiaElement.from_dict(d) assert copy.name == original.name assert copy.bounding_rect == original.bounding_rect assert copy.is_enabled == original.is_enabled