""" tests/test_duplicates.py -------------------------- Tests for exact (SHA256) and visual (perceptual hash) duplicate detection. Run with: pytest tests/test_duplicates.py -v """ import sys from pathlib import Path import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from duplicate_detector import ( TOTAL_HASH_BITS, compute_dhash, compute_sha256, find_duplicates, hamming_distance, ) from image_utils import image_to_png_bytes, to_pil def _sprite(color, size=16): arr = np.zeros((size, size, 4), dtype=np.uint8) arr[:, :, 3] = 255 arr[4:size - 4, 4:size - 4] = [*color, 255] return arr def test_identical_sprites_have_matching_sha256(): a = _sprite((200, 50, 50)) b = a.copy() sha_a = compute_sha256(image_to_png_bytes(to_pil(a))) sha_b = compute_sha256(image_to_png_bytes(to_pil(b))) assert sha_a == sha_b def test_different_sprites_have_different_sha256(): a = _sprite((200, 50, 50)) b = _sprite((50, 50, 200)) sha_a = compute_sha256(image_to_png_bytes(to_pil(a))) sha_b = compute_sha256(image_to_png_bytes(to_pil(b))) assert sha_a != sha_b def test_identical_sprites_have_zero_hamming_distance(): a = _sprite((200, 50, 50)) b = a.copy() assert hamming_distance(compute_dhash(a), compute_dhash(b)) == 0 def test_same_shape_different_color_not_flagged_as_near_duplicate(): """ CRITICAL: two sprites with the identical silhouette but different colors (e.g. a green plant vs. a purple plant) must NOT be flagged as visual duplicates -- color is part of the identity of a sprite. """ a = _sprite((50, 180, 50)) b = _sprite((180, 50, 180)) dist = hamming_distance(compute_dhash(a), compute_dhash(b)) threshold = round(0.06 * TOTAL_HASH_BITS) assert dist > threshold def test_near_identical_sprite_has_small_distance(): """A single differing pixel should produce only a small hash distance.""" a = _sprite((50, 180, 50)) b = a.copy() b[5, 5] = [45, 175, 45, 255] dist = hamming_distance(compute_dhash(a), compute_dhash(b)) assert dist <= 3 def test_find_duplicates_flags_exact_match(): a = _sprite((200, 50, 50)) b = a.copy() sha_a = compute_sha256(image_to_png_bytes(to_pil(a))) sha_b = compute_sha256(image_to_png_bytes(to_pil(b))) dhash_a = compute_dhash(a) dhash_b = compute_dhash(b) existing = [{"id": "sprite_0001", "sha256": sha_a, "phash": dhash_a}] matches = find_duplicates(sha_b, dhash_b, existing) assert len(matches) == 1 assert matches[0].exact is True assert matches[0].sprite_id == "sprite_0001" assert matches[0].similarity_percent == 100.0 def test_find_duplicates_returns_empty_for_distinct_sprite(): a = _sprite((200, 50, 50)) c = _sprite((30, 200, 30)) sha_a = compute_sha256(image_to_png_bytes(to_pil(a))) dhash_a = compute_dhash(a) sha_c = compute_sha256(image_to_png_bytes(to_pil(c))) dhash_c = compute_dhash(c) existing = [{"id": "sprite_0001", "sha256": sha_a, "phash": dhash_a}] matches = find_duplicates(sha_c, dhash_c, existing) assert matches == [] def test_empty_sprite_does_not_crash_hashing(): empty = np.zeros((0, 0, 4), dtype=np.uint8) h = compute_dhash(empty) assert isinstance(h, str) assert len(h) > 0 def test_tiny_sprite_hashing_does_not_crash(): tiny = _sprite((100, 150, 200), size=4) h = compute_dhash(tiny) assert isinstance(h, str) def test_duplicate_of_accepted_sprite_flagged_across_different_sheets(): """Duplicate detection must work even when sprites came from different sheets.""" a = _sprite((90, 90, 220)) b = a.copy() sha_a = compute_sha256(image_to_png_bytes(to_pil(a))) dhash_a = compute_dhash(a) sha_b = compute_sha256(image_to_png_bytes(to_pil(b))) dhash_b = compute_dhash(b) existing = [{"id": "sprite_from_sheet_1", "sha256": sha_a, "phash": dhash_a}] matches = find_duplicates(sha_b, dhash_b, existing) assert len(matches) == 1 assert matches[0].sprite_id == "sprite_from_sheet_1" if __name__ == "__main__": import traceback tests = [(name, fn) for name, fn in list(globals().items()) if name.startswith("test_") and callable(fn)] passed, failed = 0, 0 for name, fn in tests: try: fn() print(f"PASS {name}") passed += 1 except Exception: print(f"FAIL {name}") traceback.print_exc() failed += 1 print(f"\n{passed} passed, {failed} failed") sys.exit(1 if failed else 0)