""" 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"])