import unittest from datetime import datetime from semantica.normalize.data_cleaner import ( DataCleaner, DuplicateDetector, DataValidator, MissingValueHandler, DuplicateGroup, ValidationResult, ) class TestDataCleaner(unittest.TestCase): def setUp(self): self.cleaner = DataCleaner() self.dataset = [ {"id": 1, "name": "John Doe", "age": 30, "email": "john@example.com"}, {"id": 2, "name": "Jane Smith", "age": 25, "email": "jane@example.com"}, {"id": 3, "name": "John Doe", "age": 30, "email": "john@example.com"}, # Duplicate {"id": 4, "name": "Bob", "age": None, "email": "bob@example.com"}, # Missing age ] def test_clean_data_comprehensive(self): # Test full cleaning pipeline cleaned = self.cleaner.clean_data( self.dataset, remove_duplicates=True, duplicate_criteria={"key_fields": ["name", "age", "email"]}, validate=False, # Skip validation for this simple test handle_missing=True, missing_strategy="remove" ) # Expecting: # id 3 removed (duplicate of 1) # id 4 removed (missing age) # Remaining: id 1 and id 2 self.assertEqual(len(cleaned), 2) ids = [r["id"] for r in cleaned] self.assertIn(1, ids) self.assertIn(2, ids) self.assertNotIn(3, ids) self.assertNotIn(4, ids) def test_clean_data_fill_missing(self): cleaned = self.cleaner.clean_data( self.dataset, remove_duplicates=True, duplicate_criteria={"key_fields": ["name", "age", "email"]}, validate=False, handle_missing=True, missing_strategy="fill", fill_value=0 ) # Expecting: # id 3 removed (duplicate) # id 4 kept (age filled with 0) self.assertEqual(len(cleaned), 3) ids = [r["id"] for r in cleaned] self.assertIn(1, ids) self.assertIn(2, ids) self.assertIn(4, ids) # Check filled value bob = next(r for r in cleaned if r["id"] == 4) self.assertEqual(bob["age"], 0) class TestDuplicateDetector(unittest.TestCase): def setUp(self): self.detector = DuplicateDetector(similarity_threshold=0.8) self.dataset = [ {"id": 1, "name": "John Doe", "city": "New York"}, {"id": 2, "name": "Jane Smith", "city": "Los Angeles"}, {"id": 3, "name": "John Doe", "city": "New York"}, # Exact duplicate of 1 {"id": 4, "name": "Jon Doe", "city": "New York"}, # Similar to 1 {"id": 5, "name": "Alice", "city": "Chicago"}, ] def test_detect_exact_duplicates(self): duplicates = self.detector.detect_duplicates( self.dataset, threshold=1.0, key_fields=["name", "city"] ) # Should find group [id 1, id 3] self.assertEqual(len(duplicates), 1) group = duplicates[0] self.assertEqual(len(group.records), 2) ids = {r["id"] for r in group.records} self.assertEqual(ids, {1, 3}) self.assertEqual(group.similarity_score, 1.0) def test_detect_fuzzy_duplicates(self): # "John Doe" vs "Jon Doe" similarity # "New York" vs "New York" is 1.0 # Average similarity should be high duplicates = self.detector.detect_duplicates( self.dataset, threshold=0.8, key_fields=["name", "city"] ) # Expecting group for John Doe variants # Depending on string similarity implementation, 1, 3, and 4 might be grouped # id 1 and 3 are identical. id 4 is similar. # Let's check groups # We might get one big group or multiple. # Since the detector groups greedily: # 1 matches 3 (score 1.0) -> group [1, 3] # 1 matches 4? # Similarity("John Doe", "Jon Doe") -> "john doe" vs "jon doe" # Intersection: j,o,n, ,d,e (6 chars). Union: j,o,h,n, ,d,e (7 chars). 6/7 = 0.857 # Similarity("New York", "New York") = 1.0 # Avg = (0.857 + 1.0) / 2 = 0.928 > 0.8 # So 4 should be in the group too. self.assertTrue(len(duplicates) >= 1) # Find group containing id 1 group = next((g for g in duplicates if any(r["id"] == 1 for r in g.records)), None) self.assertIsNotNone(group) ids = {r["id"] for r in group.records} self.assertIn(1, ids) self.assertIn(3, ids) self.assertIn(4, ids) def test_calculate_similarity(self): r1 = {"a": "hello", "b": 10} r2 = {"a": "hello", "b": 10} self.assertEqual(self.detector.calculate_similarity(r1, r2), 1.0) r3 = {"a": "hallo", "b": 10} # "hello" vs "hallo": intersect(h,l,o) union(h,e,l,a,o). # h,e,l,l,o -> set(h,e,l,o) # h,a,l,l,o -> set(h,a,l,o) # inter: h,l,o (3). union: h,e,l,o,a (5). 3/5 = 0.6 # b: 10 vs 10 = 1.0 # avg = (0.6 + 1.0) / 2 = 0.8 self.assertAlmostEqual(self.detector.calculate_similarity(r1, r3), 0.8) def test_resolve_duplicates_keep_first(self): group = DuplicateGroup( records=[ {"id": 1, "val": "A", "extra": None}, {"id": 2, "val": "A", "extra": "data"} ], similarity_score=1.0, canonical_record={"id": 1, "val": "A", "extra": None} ) resolved = self.detector.resolve_duplicates([group], strategy="keep_first") self.assertEqual(len(resolved), 1) self.assertEqual(resolved[0]["id"], 1) def test_resolve_duplicates_merge(self): group = DuplicateGroup( records=[ {"id": 1, "val": "A", "extra": None}, {"id": 2, "val": "A", "extra": "data"} ], similarity_score=1.0, canonical_record={"id": 1, "val": "A", "extra": None} ) resolved = self.detector.resolve_duplicates([group], strategy="merge") self.assertEqual(len(resolved), 1) # Should have taken 'extra' from second record since first was None self.assertEqual(resolved[0]["extra"], "data") self.assertEqual(resolved[0]["val"], "A") class TestDataValidator(unittest.TestCase): def setUp(self): self.validator = DataValidator() self.schema = { "fields": { "name": {"type": "str", "required": True}, "age": {"type": "int", "required": False}, "tags": {"type": "list", "required": False} } } def test_validate_valid_record(self): record = {"name": "Test", "age": 20, "tags": ["a", "b"]} result = self.validator.validate_record(record, self.schema) self.assertTrue(result.valid) self.assertEqual(len(result.errors), 0) def test_validate_missing_required(self): record = {"age": 20} # Missing name result = self.validator.validate_record(record, self.schema) self.assertFalse(result.valid) self.assertTrue(any(e["field"] == "name" for e in result.errors)) def test_validate_wrong_type(self): record = {"name": "Test", "age": "twenty"} # age should be int result = self.validator.validate_record(record, self.schema) self.assertFalse(result.valid) self.assertTrue(any(e["field"] == "age" for e in result.errors)) def test_check_data_types(self): self.assertTrue(self.validator.check_data_types("test", str)) self.assertTrue(self.validator.check_data_types(123, int)) self.assertTrue(self.validator.check_data_types(123, [str, int])) self.assertTrue(self.validator.check_data_types("123", ["str", "int"])) self.assertFalse(self.validator.check_data_types(123, str)) class TestMissingValueHandler(unittest.TestCase): def setUp(self): self.handler = MissingValueHandler() self.dataset = [ {"a": 1, "b": 2}, {"a": None, "b": 2}, {"a": 3, "b": None}, {"a": 10, "b": 20}, ] def test_identify_missing_values(self): info = self.handler.identify_missing_values(self.dataset) self.assertEqual(info["total_records"], 4) self.assertEqual(info["missing_counts"]["a"], 1) self.assertEqual(info["missing_counts"]["b"], 1) def test_handle_missing_remove(self): cleaned = self.handler.handle_missing_values(self.dataset, strategy="remove") self.assertEqual(len(cleaned), 2) # Should keep only records with no missing values for r in cleaned: self.assertIsNotNone(r["a"]) self.assertIsNotNone(r["b"]) def test_handle_missing_fill(self): cleaned = self.handler.handle_missing_values( self.dataset, strategy="fill", fill_value=0 ) self.assertEqual(len(cleaned), 4) # Check filled values self.assertEqual(cleaned[1]["a"], 0) self.assertEqual(cleaned[2]["b"], 0) def test_handle_missing_impute_mean(self): # a: 1, 3, 10. Mean = 14/3 = 4.66 # b: 2, 2, 20. Mean = 24/3 = 8.0 cleaned = self.handler.handle_missing_values( self.dataset, strategy="impute", method="mean" ) self.assertEqual(len(cleaned), 4) # Check imputed 'a' in record 1 self.assertAlmostEqual(cleaned[1]["a"], 4.6666666, places=5) # Check imputed 'b' in record 2 self.assertEqual(cleaned[2]["b"], 8.0) def test_handle_missing_impute_median(self): dataset = [ {"a": 1}, {"a": 3}, {"a": 10}, {"a": None} ] # 1, 3, 10. Median = 3 cleaned = self.handler.handle_missing_values( dataset, strategy="impute", method="median" ) self.assertEqual(cleaned[3]["a"], 3) def test_handle_missing_impute_zero(self): dataset = [ {"a": 1}, {"a": None} ] cleaned = self.handler.handle_missing_values( dataset, strategy="impute", method="zero" ) self.assertEqual(cleaned[1]["a"], 0) if __name__ == "__main__": unittest.main()