Files
rpa_vision_v3/tests/property/test_data_contracts_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

396 lines
15 KiB
Python

"""
Tests de propriétés pour la standardisation des contrats de données - Tâche 4.5
Propriété 4: Cohérence des contrats de données BBox
Propriété 5: Cohérence des timestamps
Utilise Hypothesis pour générer des données aléatoires et valider que les contrats
sont respectés dans tous les cas.
Auteur : Dom, Alice Kiro
Date : 20 décembre 2024
"""
import pytest
from hypothesis import given, strategies as st, assume
from datetime import datetime, timezone
import uuid
from core.models.base_models import BBox, Timestamp, StandardID, DataConverter
from core.models import UIElement, ScreenState, UIElementEmbeddings, VisualFeatures
from pydantic import ValidationError
# Stratégies Hypothesis pour générer des données de test
@st.composite
def valid_bbox_data(draw):
"""Génère des données BBox valides"""
x = draw(st.integers(min_value=0, max_value=10000))
y = draw(st.integers(min_value=0, max_value=10000))
width = draw(st.integers(min_value=1, max_value=5000))
height = draw(st.integers(min_value=1, max_value=5000))
return (x, y, width, height)
@st.composite
def valid_timestamp_data(draw):
"""Génère des données timestamp valides"""
return draw(st.one_of(
st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31)),
st.text(min_size=19, max_size=19).filter(lambda x: x.count('-') == 2 and x.count(':') == 2),
st.floats(min_value=1577836800.0, max_value=1924991999.0) # 2020-2030 en timestamp
))
@st.composite
def valid_id_data(draw):
"""Génère des données ID valides"""
return draw(st.one_of(
st.text(min_size=1, max_size=100).filter(lambda x: x.strip()),
st.integers(min_value=1, max_value=999999999),
st.floats(min_value=1.0, max_value=999999999.0),
st.uuids()
))
@st.composite
def bbox_tuple_data(draw):
"""Génère des tuples bbox (x, y, w, h)"""
x, y, width, height = draw(valid_bbox_data())
return (x, y, width, height)
@st.composite
def bbox_xyxy_data(draw):
"""Génère des données bbox au format (x1, y1, x2, y2)"""
x, y, width, height = draw(valid_bbox_data())
return (x, y, x + width, y + height)
@st.composite
def bbox_dict_xywh_data(draw):
"""Génère des dictionnaires bbox (x, y, w, h)"""
x, y, width, height = draw(valid_bbox_data())
return {'x': x, 'y': y, 'width': width, 'height': height}
@st.composite
def bbox_dict_xyxy_data(draw):
"""Génère des dictionnaires bbox (x1, y1, x2, y2)"""
x, y, width, height = draw(valid_bbox_data())
return {'x1': x, 'y1': y, 'x2': x + width, 'y2': y + height}
class TestBBoxProperties:
"""Tests de propriétés pour BBox"""
@given(valid_bbox_data())
def test_bbox_creation_always_valid(self, bbox_data):
"""Propriété 4.1: Toute BBox créée avec des données valides doit être valide"""
x, y, width, height = bbox_data
bbox = BBox(x=x, y=y, width=width, height=height)
assert bbox.x == x
assert bbox.y == y
assert bbox.width == width
assert bbox.height == height
assert bbox.x >= 0
assert bbox.y >= 0
assert bbox.width > 0
assert bbox.height > 0
@given(bbox_tuple_data())
def test_bbox_tuple_roundtrip(self, bbox_tuple):
"""Propriété 4.2: Conversion tuple -> BBox -> tuple doit être identique"""
bbox = BBox.from_tuple(bbox_tuple)
result_tuple = bbox.to_tuple()
assert result_tuple == bbox_tuple
@given(bbox_xyxy_data())
def test_bbox_xyxy_roundtrip(self, xyxy_data):
"""Propriété 4.3: Conversion xyxy -> BBox -> xyxy doit être cohérente"""
x1, y1, x2, y2 = xyxy_data
bbox = BBox.from_xyxy(x1, y1, x2, y2)
result_xyxy = bbox.to_xyxy()
# Les coordonnées peuvent être réorganisées (min/max)
assert result_xyxy[0] <= result_xyxy[2] # x1 <= x2
assert result_xyxy[1] <= result_xyxy[3] # y1 <= y2
assert result_xyxy[2] - result_xyxy[0] == abs(x2 - x1) # width preserved
assert result_xyxy[3] - result_xyxy[1] == abs(y2 - y1) # height preserved
@given(valid_bbox_data())
def test_bbox_center_calculation(self, bbox_data):
"""Propriété 4.4: Le centre doit toujours être dans la bbox"""
x, y, width, height = bbox_data
bbox = BBox(x=x, y=y, width=width, height=height)
center_x, center_y = bbox.center()
assert bbox.contains_point(center_x, center_y)
assert center_x == x + width // 2
assert center_y == y + height // 2
@given(valid_bbox_data())
def test_bbox_area_positive(self, bbox_data):
"""Propriété 4.5: L'aire doit toujours être positive"""
x, y, width, height = bbox_data
bbox = BBox(x=x, y=y, width=width, height=height)
assert bbox.area() > 0
assert bbox.area() == width * height
@given(valid_bbox_data(), valid_bbox_data())
def test_bbox_intersection_properties(self, bbox1_data, bbox2_data):
"""Propriété 4.6: Propriétés de l'intersection"""
bbox1 = BBox(x=bbox1_data[0], y=bbox1_data[1], width=bbox1_data[2], height=bbox1_data[3])
bbox2 = BBox(x=bbox2_data[0], y=bbox2_data[1], width=bbox2_data[2], height=bbox2_data[3])
intersection = bbox1.intersection(bbox2)
if intersection is not None:
# L'intersection doit être dans les deux bboxes
assert bbox1.intersects(bbox2)
assert bbox2.intersects(bbox1)
assert intersection.area() > 0
# L'intersection ne peut pas être plus grande que les bboxes originales
assert intersection.area() <= bbox1.area()
assert intersection.area() <= bbox2.area()
else:
# Pas d'intersection
assert not bbox1.intersects(bbox2)
assert not bbox2.intersects(bbox1)
@given(valid_bbox_data(), valid_bbox_data())
def test_bbox_union_properties(self, bbox1_data, bbox2_data):
"""Propriété 4.7: Propriétés de l'union"""
bbox1 = BBox(x=bbox1_data[0], y=bbox1_data[1], width=bbox1_data[2], height=bbox1_data[3])
bbox2 = BBox(x=bbox2_data[0], y=bbox2_data[1], width=bbox2_data[2], height=bbox2_data[3])
union = bbox1.union(bbox2)
# L'union doit contenir les deux bboxes
assert union.area() >= bbox1.area()
assert union.area() >= bbox2.area()
# L'union doit être au moins aussi grande que la plus grande bbox
assert union.area() >= max(bbox1.area(), bbox2.area())
class TestTimestampProperties:
"""Tests de propriétés pour Timestamp"""
@given(st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31)))
def test_timestamp_datetime_roundtrip(self, dt):
"""Propriété 5.1: Conversion datetime -> Timestamp -> datetime doit être identique"""
ts = Timestamp(value=dt)
result_dt = ts.value
assert result_dt == dt
assert isinstance(result_dt, datetime)
@given(st.floats(min_value=1577836800.0, max_value=1924991999.0))
def test_timestamp_unix_roundtrip(self, unix_ts):
"""Propriété 5.2: Conversion unix -> Timestamp -> unix doit être cohérente"""
assume(unix_ts > 0) # Éviter les timestamps négatifs
ts = Timestamp(value=unix_ts)
result_unix = ts.to_timestamp()
# Tolérance pour les erreurs de précision flottante
assert abs(result_unix - unix_ts) < 0.001
@given(st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31)))
def test_timestamp_iso_creation(self, dt):
"""Propriété 5.3: Création depuis datetime doit produire un ISO valide"""
ts = Timestamp(value=dt)
iso_string = ts.to_iso()
assert isinstance(iso_string, str)
assert 'T' in iso_string or ' ' in iso_string
# La conversion retour doit être cohérente
ts2 = Timestamp.from_iso(iso_string)
assert abs((ts2.value - ts.value).total_seconds()) < 1.0
def test_timestamp_now_is_recent(self):
"""Propriété 5.4: Timestamp.now() doit être récent"""
ts = Timestamp.now()
now = datetime.now()
# Doit être dans les dernières secondes
diff = abs((now - ts.value).total_seconds())
assert diff < 1.0
class TestStandardIDProperties:
"""Tests de propriétés pour StandardID"""
@given(valid_id_data())
def test_id_creation_always_string(self, id_data):
"""Propriété 5.5: Tout ID créé doit être une string non-vide"""
try:
id_obj = StandardID(value=id_data)
assert isinstance(id_obj.value, str)
assert len(id_obj.value) > 0
assert id_obj.value.strip() == id_obj.value # Pas d'espaces en début/fin
except ValidationError:
# Certaines valeurs peuvent être invalides, c'est acceptable
pass
@given(st.text(min_size=1, max_size=100).filter(lambda x: x.strip() and x == x.strip()))
def test_id_string_equality(self, id_string):
"""Propriété 5.6: Égalité des IDs doit être cohérente"""
id1 = StandardID(value=id_string)
id2 = StandardID(value=id_string)
assert id1 == id2
assert id1 == id_string
assert str(id1) == id_string
assert hash(id1) == hash(id2)
def test_id_generate_uniqueness(self):
"""Propriété 5.7: Les IDs générés doivent être uniques"""
ids = [StandardID.generate() for _ in range(100)]
id_values = [id_obj.value for id_obj in ids]
# Tous les IDs doivent être uniques
assert len(set(id_values)) == len(id_values)
# Tous doivent être des UUIDs valides
for id_value in id_values:
uuid.UUID(id_value) # Doit pas lever d'exception
class TestDataConverterProperties:
"""Tests de propriétés pour DataConverter"""
@given(st.one_of(
bbox_tuple_data(),
bbox_dict_xywh_data(),
bbox_dict_xyxy_data()
))
def test_ensure_bbox_always_produces_bbox(self, bbox_data):
"""Propriété 4.8: ensure_bbox doit toujours produire une BBox valide"""
result = DataConverter.ensure_bbox(bbox_data)
assert isinstance(result, BBox)
assert result.x >= 0
assert result.y >= 0
assert result.width > 0
assert result.height > 0
@given(st.one_of(
st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31)),
st.floats(min_value=1577836800.0, max_value=1924991999.0),
st.text(min_size=19, max_size=26).filter(
lambda x: x.count('-') >= 2 and x.count(':') >= 2 and 'T' in x
)
))
def test_ensure_timestamp_always_produces_timestamp(self, timestamp_data):
"""Propriété 5.8: ensure_timestamp doit toujours produire un Timestamp valide"""
try:
result = DataConverter.ensure_timestamp(timestamp_data)
assert isinstance(result, Timestamp)
assert isinstance(result.value, datetime)
except (ValidationError, ValueError):
# Certaines données peuvent être invalides
pass
@given(valid_id_data())
def test_ensure_id_always_produces_id(self, id_data):
"""Propriété 5.9: ensure_id doit toujours produire un StandardID valide"""
try:
result = DataConverter.ensure_id(id_data)
assert isinstance(result, StandardID)
assert isinstance(result.value, str)
assert len(result.value) > 0
except ValidationError:
# Certaines données peuvent être invalides
pass
@given(st.dictionaries(
st.text(min_size=1, max_size=20),
st.one_of(
bbox_tuple_data(),
bbox_dict_xywh_data(),
st.text(min_size=1, max_size=50)
),
min_size=1,
max_size=10
))
def test_migrate_bbox_dict_preserves_other_fields(self, data_dict):
"""Propriété 4.9: Migration bbox doit préserver les autres champs"""
# Ajouter un champ bbox
data_dict['bbox'] = (10, 20, 100, 50)
original_keys = set(data_dict.keys())
result = DataConverter.migrate_bbox_dict(data_dict)
result_keys = set(result.keys())
# Toutes les clés originales doivent être préservées
assert original_keys == result_keys
# Le champ bbox doit être migré
if 'bbox' in result:
assert isinstance(result['bbox'], dict)
class TestUIElementContractProperties:
"""Tests de propriétés pour les contrats UIElement"""
@st.composite
def ui_element_data(draw):
"""Génère des données UIElement valides"""
return {
'element_id': draw(st.text(min_size=1, max_size=50).filter(lambda x: x.strip() and x == x.strip())),
'type': draw(st.sampled_from(['button', 'text_input', 'checkbox', 'dropdown'])),
'role': draw(st.sampled_from(['primary_action', 'cancel', 'form_input', 'navigation'])),
'bbox': draw(bbox_tuple_data()),
'label': draw(st.text(min_size=0, max_size=100)),
'label_confidence': draw(st.floats(min_value=0.0, max_value=1.0)),
'embeddings': {
'image': {'vector': [0.1, 0.2, 0.3]},
'text': {'vector': [0.4, 0.5, 0.6]}
},
'visual_features': {
'dominant_color': 'blue',
'has_icon': True,
'shape': 'rectangle',
'size_category': 'medium'
}
}
@given(ui_element_data())
def test_uielement_serialization_roundtrip(self, element_data):
"""Propriété 4.10: Sérialisation/désérialisation UIElement doit être cohérente"""
# Créer les objets nécessaires
embeddings = UIElementEmbeddings.from_dict(element_data['embeddings'])
visual_features = VisualFeatures.from_dict(element_data['visual_features'])
# Créer UIElement avec bbox tuple
element = UIElement.create_with_bbox_tuple(
element_id=element_data['element_id'],
type=element_data['type'],
role=element_data['role'],
bbox_tuple=element_data['bbox'],
label=element_data['label'],
label_confidence=element_data['label_confidence'],
embeddings=embeddings,
visual_features=visual_features
)
# Sérialiser et désérialiser
serialized = element.to_dict()
deserialized = UIElement.from_dict(serialized)
# Vérifier la cohérence
assert deserialized.element_id == element.element_id
assert deserialized.type == element.type
assert deserialized.role == element.role
assert isinstance(deserialized.bbox, BBox)
assert deserialized.bbox.to_tuple() == element.bbox.to_tuple()
assert deserialized.label == element.label
assert abs(deserialized.label_confidence - element.label_confidence) < 0.001
if __name__ == "__main__":
pytest.main([__file__, "-v"])