Coverage for ivatar/test_generator_equivalence.py: 100%
111 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Byte-identity and cache-behavior regression tests for the accelerated
3pagan / robohash generator implementations.
5These tests compare the public ivatar APIs against the *pristine* upstream
6implementations (pagan.generator.generate + Robohash.assemble with the
7original functions restored), so a future change that alters rendered
8pixels fails here instead of silently shipping visual drift.
9"""
11import hashlib
12import io
13import threading
14from unittest.mock import patch
16from django.core.cache import cache
17from django.test import TestCase
19import pagan.generator as pristine_pagan_gen
21from ivatar.pagan_optimized import create_optimized_pagan
22import ivatar.pagan_optimized as pagan_mod
23import ivatar.robohash as robo_mod
24from ivatar.robohash import create_robohash, clear_robohash_cache
26# Digests chosen to exercise different robohash sets (set1 300px parts,
27# set3/4/5 1015/1024px parts) and both pagan aspect branches.
28DIGESTS = [hashlib.md5(f"equiv-{i}".encode()).hexdigest() for i in range(12)]
31def _clear_local_caches():
32 clear_robohash_cache()
33 robo_mod._robohash_png_cache.clear()
34 robo_mod.OptimizedRobohash._assembly_cache.clear()
35 pagan_mod.clear_pagan_cache()
36 pagan_mod._pagan_png_cache.clear()
39def _pristine_robohash(digest: str, size: int = 80) -> bytes:
40 """Render with upstream Robohash.assemble + _get_list_of_files restored."""
41 from robohash.robohash import Robohash
43 patched_assemble = Robohash.assemble
44 patched_listfiles = Robohash._get_list_of_files
45 Robohash.assemble = robo_mod._orig_assemble_ref
46 Robohash._get_list_of_files = robo_mod._orig_listfiles_ref
47 try:
48 robo = Robohash(digest)
49 robo.assemble(roboset="any", sizex=size, sizey=size)
50 buf = io.BytesIO()
51 robo.img.save(buf, "png")
52 return buf.getvalue()
53 finally:
54 Robohash.assemble = patched_assemble
55 Robohash._get_list_of_files = patched_listfiles
58def _pristine_pagan(digest: str, size: int = 80) -> bytes:
59 from PIL import Image
61 img128 = pristine_pagan_gen.generate(digest, 0)
62 img = img128.resize((size, size), Image.LANCZOS)
63 buf = io.BytesIO()
64 img.save(buf, "PNG")
65 return buf.getvalue()
68class ByteIdentityEquivalence(TestCase):
69 """Patched pipelines must render byte-identical to pristine upstream."""
71 def test_robohash_matches_pristine_upstream(self):
72 for digest in DIGESTS:
73 _clear_local_caches()
74 patched = create_robohash(digest, 80, "any").getvalue()
75 _clear_local_caches()
76 self.assertEqual(patched, _pristine_robohash(digest), digest)
78 def test_pagan_matches_pristine_generator(self):
79 for digest in DIGESTS:
80 _clear_local_caches()
81 patched = create_optimized_pagan(digest, 80).getvalue()
82 _clear_local_caches()
83 self.assertEqual(patched, _pristine_pagan(digest), digest)
85 def test_robohash_matches_pristine_other_sizes(self):
86 for digest in DIGESTS[:5]:
87 for size in (28, 128, 256):
88 _clear_local_caches()
89 patched = create_robohash(digest, size, "any").getvalue()
90 _clear_local_caches()
91 self.assertEqual(
92 patched, _pristine_robohash(digest, size), (digest, size)
93 )
96class StructureCacheSelection(TestCase):
97 """Structure cache must not freeze per-digest file selection."""
99 def test_selection_varies_with_digest(self):
100 # chosen parts come from digest bits: two digests must not always
101 # produce the same robot image
102 imgs = set()
103 for digest in DIGESTS[:8]:
104 _clear_local_caches()
105 imgs.add(create_robohash(digest, 80, "any").getvalue())
106 self.assertGreater(len(imgs), 1, "all digests rendered the same robot")
109class SharedRedisCacheBehavior(TestCase):
110 """pagan/robohash payloads get a cross-worker (default alias) cache."""
112 def setUp(self):
113 cache.clear()
114 _clear_local_caches()
116 def test_robohash_populates_shared_cache(self):
117 digest = DIGESTS[0]
118 create_robohash(digest, 80, "any")
119 self.assertIsNotNone(
120 cache.get(robo_mod._redis_default_avatar_key(digest, 80, "any"))
121 )
123 def test_pagan_populates_shared_cache(self):
124 digest = DIGESTS[0]
125 create_optimized_pagan(digest, 80)
126 self.assertIsNotNone(cache.get(pagan_mod._redis_pagan_key(digest, 80)))
128 def test_shared_hit_served_without_generation(self):
129 digest = DIGESTS[0]
130 payload = create_robohash(digest, 80, "any").getvalue()
131 _clear_local_caches()
132 with patch.object(
133 robo_mod.OptimizedRobohash,
134 "assemble_optimized",
135 side_effect=AssertionError("must not regenerate on shared hit"),
136 ):
137 served = create_robohash(digest, 80, "any").getvalue()
138 self.assertEqual(served, payload)
140 def test_shared_hit_pagan_without_generation(self):
141 digest = DIGESTS[1]
142 create_optimized_pagan(digest, 80)
143 _clear_local_caches()
144 with patch.object(
145 pagan_mod.OptimizedPagan,
146 "generate_optimized",
147 side_effect=AssertionError("must not regenerate"),
148 ) as mock:
149 create_optimized_pagan(digest, 80)
150 mock.assert_not_called()
152 def test_cache_failure_falls_back_to_generation(self):
153 digest = DIGESTS[2]
154 with patch.object(
155 cache,
156 "get",
157 side_effect=ConnectionError("Redis down"),
158 ):
159 # get fails -> generate normally
160 out = create_robohash(digest, 80, "any").getvalue()
161 self.assertTrue(out)
163 def test_threadsafe_concurrent_generation(self):
164 """Concurrent first-time requests for the same digest all succeed."""
165 digest = DIGESTS[3]
166 results: list = []
167 errors: list = []
169 def worker():
170 try:
171 results.append(create_robohash(digest, 80, "any").getvalue())
172 except Exception as exc: # pragma: no cover
173 errors.append(exc)
175 threads = [threading.Thread(target=worker) for _ in range(8)]
176 for t in threads:
177 t.start()
178 for t in threads:
179 t.join()
180 self.assertFalse(errors)
181 self.assertEqual(len(set(results)), 1)