Files
rpa_vision_v3/tests/unit/test_surface_and_uia.py
Dom ac9c207474 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>
2026-04-10 10:54:19 +02:00

354 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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