feat: SurfaceClassifier + UIAHelper — détection et wrapper Python
SurfaceClassifier — détecte le type d'application au runtime - 4 surfaces : citrix / windows_native / web_local / unknown - Paramètres adaptés par surface : * Citrix : OCR 0.65, timeouts 15s, retries 3x (compression JPEG tolérée) * Windows natif : OCR 0.75, timeouts 8s, UIA bonus si dispo * Web : OCR 0.80, timeouts 5s, paramètres rapides * Unknown : fallback sûr - resolve_order() construit la chaîne selon les capacités disponibles - Détection UIA via health check du helper Rust - Détection CDP via localhost:9222 UIAHelper — wrapper Python pour lea_uia.exe - Subprocess + JSON stdin/stdout - 3 méthodes : query_at(x,y), find_by_name(name,...), capture_focused() - Fallback silencieux (None) si helper absent, timeout, crash - Singleton global get_shared_helper() - Dataclass UiaElement avec center(), is_clickable(), path_signature() 29 nouveaux tests (détection 4 surfaces, dataclass, wrapper, mocks). 485 tests au total, 0 régression. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
353
tests/unit/test_surface_and_uia.py
Normal file
353
tests/unit/test_surface_and_uia.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user