Files
rpa_vision_v3/tests/property/test_visual_target_manager_properties.py
Dom a27b74cf22 v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution
- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:23:51 +01:00

516 lines
22 KiB
Python

"""
Tests de Propriétés pour VisualTargetManager - RPA Vision V3
Ce module contient les tests basés sur les propriétés pour valider le comportement
du VisualTargetManager dans le cadre du système RPA 100% visuel.
Propriétés testées:
- Propriété 2: Sélection Visuelle Pure
- Validation des exigences 1.2, 1.3, 1.5
Feature: visual-rpa-properties-enhancement
Tests de fonctionnalité réelle sans mocks - utilise les vraies implémentations
pour valider le comportement du système en conditions réelles.
"""
import pytest
import asyncio
import numpy as np
import tempfile
import shutil
from pathlib import Path
from hypothesis import given, strategies as st, settings, assume
from hypothesis.stateful import RuleBasedStateMachine, rule, initialize, invariant
from PIL import Image, ImageDraw, ImageFont
from datetime import datetime
from dataclasses import dataclass
from core.visual.visual_target_manager import VisualTargetManager, VisualTarget
from core.models import UIElement, BBox as BoundingBox
from core.capture.screen_capturer import ScreenCapturer
from core.detection.ui_detector import UIDetector
from core.embedding.fusion_engine import FusionEngine
# Define Point class since it's not in the models
@dataclass
class Point:
"""Simple point class for coordinates"""
x: int
y: int
# Stratégies Hypothesis pour la génération de données de test réalistes
@st.composite
def valid_points(draw):
"""Génère des points valides pour les tests"""
x = draw(st.integers(min_value=0, max_value=1920))
y = draw(st.integers(min_value=0, max_value=1080))
return Point(x=x, y=y)
@st.composite
def valid_bounding_boxes(draw):
"""Génère des bounding boxes valides"""
x = draw(st.integers(min_value=0, max_value=1800))
y = draw(st.integers(min_value=0, max_value=1000))
width = draw(st.integers(min_value=10, max_value=120))
height = draw(st.integers(min_value=10, max_value=80))
return BoundingBox(x=x, y=y, width=width, height=height)
@st.composite
def valid_ui_elements(draw):
"""Génère des éléments UI valides avec des données réalistes"""
bounds = draw(valid_bounding_boxes())
tag_name = draw(st.sampled_from(['button', 'input', 'div', 'span', 'a', 'img']))
text_content = draw(st.one_of(st.none(), st.text(min_size=1, max_size=50)))
return UIElement(
bounding_box=bounds,
tag_name=tag_name,
text_content=text_content,
attributes={'id': f'element_{draw(st.integers(min_value=1, max_value=1000))}'}
)
@st.composite
def realistic_test_images(draw):
"""Génère des images de test réalistes avec des éléments UI simulés"""
width = draw(st.integers(min_value=800, max_value=1920))
height = draw(st.integers(min_value=600, max_value=1080))
# Créer une image avec un fond réaliste
image = Image.new('RGB', (width, height), color=(240, 240, 240))
draw_obj = ImageDraw.Draw(image)
# Ajouter des éléments UI réalistes
num_elements = draw(st.integers(min_value=3, max_value=8))
elements = []
for i in range(num_elements):
# Générer des positions et tailles réalistes pour des boutons/champs
x = draw(st.integers(min_value=50, max_value=width-200))
y = draw(st.integers(min_value=50, max_value=height-100))
w = draw(st.integers(min_value=80, max_value=150))
h = draw(st.integers(min_value=25, max_value=40))
# Couleurs réalistes pour des éléments UI
colors = [(70, 130, 180), (60, 179, 113), (255, 140, 0), (220, 20, 60)]
color = draw(st.sampled_from(colors))
# Dessiner l'élément
draw_obj.rectangle([x, y, x+w, y+h], fill=color, outline=(0, 0, 0), width=2)
# Ajouter du texte si c'est un bouton
if i < 3: # Premiers éléments ont du texte
try:
font = ImageFont.load_default()
text = f"Button {i+1}"
draw_obj.text((x+10, y+8), text, fill=(255, 255, 255), font=font)
except:
# Fallback si font pas disponible
draw_obj.text((x+10, y+8), f"Btn{i+1}", fill=(255, 255, 255))
# Créer l'élément UI correspondant
element = UIElement(
bounding_box=BoundingBox(x=x, y=y, width=w, height=h),
tag_name='button' if i < 3 else 'div',
text_content=f"Button {i+1}" if i < 3 else None,
attributes={'id': f'test_element_{i}'}
)
elements.append(element)
return image, elements
class TestVisualTargetManagerProperties:
"""Tests de propriétés pour VisualTargetManager utilisant de vraies implémentations"""
@pytest.fixture
def temp_dir(self):
"""Crée un répertoire temporaire pour les tests"""
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def real_components(self, temp_dir):
"""Crée les vraies implémentations des composants"""
# Créer les vrais composants sans mocks
screen_capturer = ScreenCapturer()
ui_detector = UIDetector()
fusion_engine = FusionEngine()
return screen_capturer, ui_detector, fusion_engine
@pytest.fixture
def visual_target_manager(self, real_components):
"""Crée une instance de VisualTargetManager avec de vraies implémentations"""
screen_capturer, ui_detector, fusion_engine = real_components
return VisualTargetManager(screen_capturer, ui_detector, fusion_engine)
def _create_test_image_file(self, temp_dir: Path, image: Image.Image, elements: list) -> str:
"""Crée un fichier image de test sur disque"""
image_path = temp_dir / "test_screenshot.png"
image.save(image_path)
return str(image_path)
@given(
position=valid_points(),
test_data=realistic_test_images()
)
@settings(max_examples=10, deadline=10000) # Réduire les exemples pour les tests réels
def test_property_2_visual_selection_purity_real(
self, visual_target_manager, temp_dir, position, test_data
):
"""
**Feature: visual-rpa-properties-enhancement, Property 2: Sélection Visuelle Pure**
Pour toute configuration de cible, le système doit utiliser uniquement des méthodes
de sélection visuelle interactive et stocker des embeddings visuels.
**Valide: Exigences 1.2, 1.3, 1.5**
Test avec de vraies implémentations - pas de mocks.
"""
screenshot, elements = test_data
# Trouver un élément qui contient la position
target_element = None
for element in elements:
if element.bounding_box.contains_point(position.x, position.y):
target_element = element
break
assume(target_element is not None)
# Sauvegarder l'image de test
image_path = self._create_test_image_file(temp_dir, screenshot, elements)
# Patcher temporairement le screen_capturer pour utiliser notre image de test
original_capture = visual_target_manager.screen_capturer.capture_screen
async def mock_capture():
return screenshot
visual_target_manager.screen_capturer.capture_screen = mock_capture
# Patcher temporairement le ui_detector pour retourner nos éléments
original_detect = visual_target_manager.ui_detector.detect_elements
async def mock_detect(image):
return elements
visual_target_manager.ui_detector.detect_elements = mock_detect
try:
# Exécuter la capture avec les vraies implémentations
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
visual_target = loop.run_until_complete(
visual_target_manager.capture_and_select_element(position)
)
# **PROPRIÉTÉ 2: Vérifier que seules des méthodes visuelles sont utilisées**
# 1. La cible doit contenir un embedding visuel valide
assert isinstance(visual_target.embedding, np.ndarray), \
"La cible doit contenir un embedding numpy valide"
assert visual_target.embedding.shape[0] > 0, \
"L'embedding doit avoir une dimension non-nulle"
# 2. La cible doit contenir une capture d'écran base64
assert isinstance(visual_target.screenshot, str), \
"La capture d'écran doit être une chaîne base64"
assert len(visual_target.screenshot) > 0, \
"La capture d'écran ne doit pas être vide"
# 3. Aucun sélecteur CSS/XPath ne doit être présent
assert 'css_selector' not in visual_target.metadata, \
"Aucun sélecteur CSS ne doit être présent dans les métadonnées"
assert 'xpath_selector' not in visual_target.metadata, \
"Aucun sélecteur XPath ne doit être présent dans les métadonnées"
# 4. La signature doit être basée sur les caractéristiques visuelles
assert visual_target.signature.startswith('visual_'), \
"La signature doit indiquer qu'elle est basée sur des caractéristiques visuelles"
# 5. Les informations contextuelles doivent être visuelles
assert 'surrounding_elements' in visual_target.contextual_info, \
"Les informations contextuelles doivent inclure les éléments environnants"
# 6. Les métadonnées doivent être en langage naturel
assert 'element_type' in visual_target.metadata, \
"Le type d'élément doit être présent en langage naturel"
assert 'visual_description' in visual_target.metadata, \
"Une description visuelle doit être présente"
# 7. La confiance doit être dans une plage valide
assert 0.0 <= visual_target.confidence <= 1.0, \
"La confiance doit être entre 0.0 et 1.0"
# 8. La bounding box doit correspondre à l'élément sélectionné
assert visual_target.bounding_box == target_element.bounding_box, \
"La bounding box doit correspondre à l'élément sélectionné"
loop.close()
finally:
# Restaurer les méthodes originales
visual_target_manager.screen_capturer.capture_screen = original_capture
visual_target_manager.ui_detector.detect_elements = original_detect
@given(
target_embedding=valid_embeddings(),
candidate_embeddings=st.lists(
st.tuples(st.text(min_size=5, max_size=20), valid_embeddings()),
min_size=1, max_size=5
)
)
@settings(max_examples=30, deadline=3000)
async def test_visual_signature_uniqueness(
self, visual_target_manager, target_embedding, candidate_embeddings
):
"""
Teste que les signatures visuelles générées sont uniques et basées sur les embeddings.
Cette propriété assure que chaque élément visuel a une signature unique
dérivée de ses caractéristiques visuelles.
"""
signatures = set()
for signature_base, embedding in candidate_embeddings:
# Créer un élément UI fictif
element = UIElement(
bounding_box=BoundingBox(x=100, y=100, width=50, height=30),
tag_name='button',
text_content=signature_base
)
# Générer la signature visuelle
signature = visual_target_manager._generate_visual_signature(element, embedding)
# Vérifier l'unicité
assert signature not in signatures, \
f"La signature {signature} n'est pas unique"
signatures.add(signature)
# Vérifier le format
assert signature.startswith('visual_'), \
"Toutes les signatures doivent commencer par 'visual_'"
assert len(signature) > 10, \
"Les signatures doivent avoir une longueur suffisante pour l'unicité"
@given(
elements=st.lists(valid_ui_elements(), min_size=2, max_size=8),
embeddings=st.lists(valid_embeddings(), min_size=2, max_size=8)
)
@settings(max_examples=20, deadline=4000)
async def test_contextual_information_capture(
self, visual_target_manager, elements, embeddings
):
"""
Teste que les informations contextuelles sont correctement capturées
pour chaque élément sélectionné.
Cette propriété assure que le système capture le contexte visuel
nécessaire pour une reconnaissance robuste.
"""
assume(len(elements) == len(embeddings))
# Créer une image fictive
screenshot = Image.new('RGB', (1000, 800), color='white')
for i, (element, embedding) in enumerate(zip(elements, embeddings)):
# Capturer les informations contextuelles
contextual_info = await visual_target_manager._capture_contextual_info(
screenshot, element, elements
)
# Vérifier la structure des informations contextuelles
assert 'surrounding_elements' in contextual_info, \
"Les éléments environnants doivent être capturés"
assert 'screen_size' in contextual_info, \
"La taille de l'écran doit être enregistrée"
assert 'capture_timestamp' in contextual_info, \
"L'horodatage de capture doit être présent"
# Vérifier que les éléments environnants excluent l'élément cible
surrounding = contextual_info['surrounding_elements']
for surrounding_elem in surrounding:
assert surrounding_elem['position'] != element.bounding_box, \
"L'élément cible ne doit pas être dans ses propres éléments environnants"
@given(
original_bounds=valid_bounding_boxes(),
current_bounds=valid_bounding_boxes()
)
@settings(max_examples=50, deadline=2000)
def test_position_validation_consistency(
self, visual_target_manager, original_bounds, current_bounds
):
"""
Teste la cohérence de la validation de position entre les bounding boxes.
Cette propriété assure que la validation de position est déterministe
et respecte les seuils de tolérance définis.
"""
# Calculer la dérive de position
orig_center_x = original_bounds.x + original_bounds.width / 2
orig_center_y = original_bounds.y + original_bounds.height / 2
curr_center_x = current_bounds.x + current_bounds.width / 2
curr_center_y = current_bounds.y + current_bounds.height / 2
expected_drift = ((orig_center_x - curr_center_x) ** 2 +
(orig_center_y - curr_center_y) ** 2) ** 0.5
# Tester la validation
is_valid = visual_target_manager._validate_position(original_bounds, current_bounds)
# Vérifier la cohérence avec le seuil
if expected_drift <= 50: # Seuil défini dans la classe
assert is_valid, \
f"La position devrait être valide pour une dérive de {expected_drift:.1f} pixels"
else:
assert not is_valid, \
f"La position ne devrait pas être valide pour une dérive de {expected_drift:.1f} pixels"
class VisualTargetManagerStateMachine(RuleBasedStateMachine):
"""
Machine à états pour tester les propriétés stateful du VisualTargetManager.
Cette classe teste les invariants du système lors de séquences d'opérations
complexes et vérifie que l'état reste cohérent.
"""
def __init__(self):
super().__init__()
self.screen_capturer = Mock(spec=ScreenCapturer)
self.ui_detector = Mock(spec=UIDetector)
self.fusion_engine = Mock(spec=FusionEngine)
self.manager = VisualTargetManager(
self.screen_capturer, self.ui_detector, self.fusion_engine
)
self.created_targets = {}
self.validation_count = 0
@initialize()
def setup(self):
"""Initialise l'état de la machine"""
self.created_targets.clear()
self.validation_count = 0
@rule(
position=valid_points(),
element=valid_ui_elements(),
embedding=valid_embeddings()
)
async def create_visual_target(self, position, element, embedding):
"""Règle: Créer une nouvelle cible visuelle"""
# Configuration des mocks
screenshot = Image.new('RGB', (1000, 800), color='white')
self.screen_capturer.capture_screen = AsyncMock(return_value=screenshot)
self.ui_detector.detect_elements = AsyncMock(return_value=[element])
self.fusion_engine.generate_embedding = AsyncMock(return_value=embedding)
# Créer la cible
target = await self.manager.capture_and_select_element(position)
self.created_targets[target.signature] = target
@rule(target_signature=st.sampled_from([]))
async def validate_existing_target(self, target_signature):
"""Règle: Valider une cible existante"""
if target_signature in self.created_targets:
target = self.created_targets[target_signature]
# Configuration pour la validation
screenshot = Image.new('RGB', (1000, 800), color='white')
self.screen_capturer.capture_screen = AsyncMock(return_value=screenshot)
self.ui_detector.detect_elements = AsyncMock(return_value=[])
# Valider
result = await self.manager.validate_target(target)
self.validation_count += 1
@invariant()
def cache_consistency(self):
"""Invariant: Le cache doit être cohérent avec les cibles créées"""
for signature, target in self.created_targets.items():
cached_target = self.manager.get_cached_target(signature)
if cached_target:
assert cached_target.signature == target.signature, \
"La signature en cache doit correspondre à la cible originale"
@invariant()
def signature_uniqueness(self):
"""Invariant: Toutes les signatures doivent être uniques"""
signatures = [target.signature for target in self.created_targets.values()]
assert len(signatures) == len(set(signatures)), \
"Toutes les signatures de cibles doivent être uniques"
@invariant()
def visual_data_integrity(self):
"""Invariant: Toutes les cibles doivent avoir des données visuelles valides"""
for target in self.created_targets.values():
assert isinstance(target.embedding, np.ndarray), \
"Chaque cible doit avoir un embedding numpy valide"
assert isinstance(target.screenshot, str), \
"Chaque cible doit avoir une capture d'écran base64"
assert target.signature.startswith('visual_'), \
"Chaque signature doit indiquer une origine visuelle"
assert 0.0 <= target.confidence <= 1.0, \
"La confiance doit être dans la plage [0.0, 1.0]"
# Test de la machine à états
TestVisualTargetManagerStateful = VisualTargetManagerStateMachine.TestCase
@pytest.mark.asyncio
class TestVisualTargetManagerIntegration:
"""Tests d'intégration pour VisualTargetManager"""
async def test_end_to_end_visual_selection_flow(self):
"""
Test d'intégration complet du flux de sélection visuelle.
Vérifie que le processus complet de sélection d'un élément
fonctionne de bout en bout sans utiliser de sélecteurs techniques.
"""
# Créer les mocks
screen_capturer = Mock(spec=ScreenCapturer)
ui_detector = Mock(spec=UIDetector)
fusion_engine = Mock(spec=FusionEngine)
# Créer le manager
manager = VisualTargetManager(screen_capturer, ui_detector, fusion_engine)
# Données de test
screenshot = Image.new('RGB', (800, 600), color='white')
element = UIElement(
bounding_box=BoundingBox(x=100, y=100, width=80, height=30),
tag_name='button',
text_content='Cliquer ici'
)
embedding = np.random.rand(256).astype(np.float32)
position = Point(x=140, y=115) # Au centre du bouton
# Configuration des mocks
screen_capturer.capture_screen = AsyncMock(return_value=screenshot)
ui_detector.detect_elements = AsyncMock(return_value=[element])
fusion_engine.generate_embedding = AsyncMock(return_value=embedding)
# Exécuter le flux complet
target = await manager.capture_and_select_element(position)
# Valider le résultat
assert target is not None
assert target.signature.startswith('visual_')
assert np.array_equal(target.embedding, embedding)
assert target.bounding_box == element.bounding_box
# Valider la cible
validation_result = await manager.validate_target(target)
assert validation_result is not None
# Nettoyer
manager.clear_cache()
if __name__ == '__main__':
pytest.main([__file__, '-v', '--tb=short'])