- 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>
516 lines
22 KiB
Python
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']) |