- 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>
396 lines
15 KiB
Python
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"]) |