test_spectral_sigil.py
python
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923
fix(issues): use issue number as pagination cursor, not cre…
Sonnet 4.6
patch
8 days ago
| 1 | """TDD tests for Spectral Sigil — content-addressed SVG avatar generation. |
| 2 | |
| 3 | Route: GET /avatars/{identity_id}.svg |
| 4 | Generator: musehub.services.spectral_sigil |
| 5 | |
| 6 | Tests are RED-first. Each assertion drives one concrete implementation decision. |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import hashlib |
| 11 | import re |
| 12 | import xml.etree.ElementTree as ET |
| 13 | from unittest.mock import AsyncMock, MagicMock, patch |
| 14 | |
| 15 | import pytest |
| 16 | from httpx import ASGITransport, AsyncClient |
| 17 | |
| 18 | from musehub.main import app |
| 19 | |
| 20 | # --------------------------------------------------------------------------- |
| 21 | # Fixtures and helpers |
| 22 | # --------------------------------------------------------------------------- |
| 23 | |
| 24 | # A valid sha256-style identity_id (no "sha256:" prefix — bare hex) |
| 25 | _HUMAN_ID = "a" * 64 |
| 26 | _AGENT_ID = "b" * 64 |
| 27 | _ORG_ID = "c" * 64 |
| 28 | |
| 29 | # identity_id whose bytes[0:4] set hue to a known value for assertions |
| 30 | _KNOWN_HUE_ID = "00000000" + "a" * 56 # bytes 0-3 = 0x00000000 → hue 0 (red) |
| 31 | |
| 32 | |
| 33 | def _parse_svg(content: bytes) -> ET.Element: |
| 34 | return ET.fromstring(content) |
| 35 | |
| 36 | |
| 37 | def _make_identity_mock( |
| 38 | identity_id: str, |
| 39 | handle: str, |
| 40 | identity_type: str, |
| 41 | domain_counts: dict[str, int] | None = None, |
| 42 | ) -> MagicMock: |
| 43 | mock = MagicMock() |
| 44 | mock.identity_id = identity_id |
| 45 | mock.handle = handle |
| 46 | mock.identity_type = identity_type |
| 47 | mock.domain_counts = domain_counts or {} |
| 48 | return mock |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # Module import — must exist |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | |
| 56 | def test_spectral_sigil_module_importable() -> None: |
| 57 | from musehub.services import spectral_sigil # noqa: F401 |
| 58 | |
| 59 | |
| 60 | def test_spectral_sigil_generator_callable() -> None: |
| 61 | from musehub.services.spectral_sigil import generate_sigil |
| 62 | |
| 63 | assert callable(generate_sigil) |
| 64 | |
| 65 | |
| 66 | # --------------------------------------------------------------------------- |
| 67 | # Core generation contract |
| 68 | # --------------------------------------------------------------------------- |
| 69 | |
| 70 | |
| 71 | def test_generate_sigil_returns_bytes() -> None: |
| 72 | from musehub.services.spectral_sigil import generate_sigil |
| 73 | |
| 74 | result = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 75 | assert isinstance(result, bytes) |
| 76 | |
| 77 | |
| 78 | def test_generate_sigil_is_valid_svg() -> None: |
| 79 | from musehub.services.spectral_sigil import generate_sigil |
| 80 | |
| 81 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 82 | root = _parse_svg(svg_bytes) |
| 83 | # Root element must be <svg> |
| 84 | assert root.tag in {"svg", "{http://www.w3.org/2000/svg}svg"} |
| 85 | |
| 86 | |
| 87 | def test_generate_sigil_has_viewbox() -> None: |
| 88 | from musehub.services.spectral_sigil import generate_sigil |
| 89 | |
| 90 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 91 | root = _parse_svg(svg_bytes) |
| 92 | assert "viewBox" in root.attrib |
| 93 | |
| 94 | |
| 95 | def test_generate_sigil_deterministic_same_id() -> None: |
| 96 | from musehub.services.spectral_sigil import generate_sigil |
| 97 | |
| 98 | a = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 99 | b = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 100 | assert a == b |
| 101 | |
| 102 | |
| 103 | def test_generate_sigil_deterministic_same_domains() -> None: |
| 104 | from musehub.services.spectral_sigil import generate_sigil |
| 105 | |
| 106 | domains = {"code": 10, "music": 5} |
| 107 | a = generate_sigil(_HUMAN_ID, "gabriel", "human", domains) |
| 108 | b = generate_sigil(_HUMAN_ID, "gabriel", "human", domains) |
| 109 | assert a == b |
| 110 | |
| 111 | |
| 112 | def test_generate_sigil_differs_by_identity_id() -> None: |
| 113 | from musehub.services.spectral_sigil import generate_sigil |
| 114 | |
| 115 | a = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 116 | b = generate_sigil(_AGENT_ID, "gabriel", "human", {}) |
| 117 | assert a != b |
| 118 | |
| 119 | |
| 120 | # --------------------------------------------------------------------------- |
| 121 | # Archetype — distinct SVG structure |
| 122 | # --------------------------------------------------------------------------- |
| 123 | |
| 124 | |
| 125 | def test_human_sigil_contains_path_element() -> None: |
| 126 | """Human archetype uses organic bezier blob → at least one <path>.""" |
| 127 | from musehub.services.spectral_sigil import generate_sigil |
| 128 | |
| 129 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 130 | root = _parse_svg(svg_bytes) |
| 131 | # ET uses Clark notation {ns}tag for namespaced elements |
| 132 | paths = ( |
| 133 | root.findall(".//{http://www.w3.org/2000/svg}path") |
| 134 | or root.findall(".//path") |
| 135 | ) |
| 136 | assert len(paths) >= 1, "human sigil must contain at least one <path> (bezier blob)" |
| 137 | |
| 138 | |
| 139 | def test_agent_sigil_contains_polygon_or_path() -> None: |
| 140 | """Agent archetype uses hexagonal circuit trace → polygon or path.""" |
| 141 | from musehub.services.spectral_sigil import generate_sigil |
| 142 | |
| 143 | svg_bytes = generate_sigil(_AGENT_ID, "aria", "agent", {}) |
| 144 | root = _parse_svg(svg_bytes) |
| 145 | polygons = root.findall(".//polygon") or root.findall( |
| 146 | ".//{http://www.w3.org/2000/svg}polygon" |
| 147 | ) |
| 148 | paths = root.findall(".//path") or root.findall(".//{http://www.w3.org/2000/svg}path") |
| 149 | assert len(polygons) + len(paths) >= 1, "agent sigil must contain polygon or path (hex)" |
| 150 | |
| 151 | |
| 152 | def test_org_sigil_contains_polygon_or_path() -> None: |
| 153 | """Org archetype uses quorum polygon → polygon or path.""" |
| 154 | from musehub.services.spectral_sigil import generate_sigil |
| 155 | |
| 156 | svg_bytes = generate_sigil(_ORG_ID, "tellurstori", "org", {}) |
| 157 | root = _parse_svg(svg_bytes) |
| 158 | polygons = root.findall(".//polygon") or root.findall( |
| 159 | ".//{http://www.w3.org/2000/svg}polygon" |
| 160 | ) |
| 161 | paths = root.findall(".//path") or root.findall(".//{http://www.w3.org/2000/svg}path") |
| 162 | assert len(polygons) + len(paths) >= 1, "org sigil must contain polygon or path" |
| 163 | |
| 164 | |
| 165 | def test_archetypes_produce_distinct_svgs() -> None: |
| 166 | """Same identity_id bytes but different archetype → different output.""" |
| 167 | from musehub.services.spectral_sigil import generate_sigil |
| 168 | |
| 169 | human_svg = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 170 | agent_svg = generate_sigil(_HUMAN_ID, "gabriel", "agent", {}) |
| 171 | org_svg = generate_sigil(_HUMAN_ID, "gabriel", "org", {}) |
| 172 | # All three must differ |
| 173 | assert human_svg != agent_svg |
| 174 | assert human_svg != org_svg |
| 175 | assert agent_svg != org_svg |
| 176 | |
| 177 | |
| 178 | # --------------------------------------------------------------------------- |
| 179 | # Hue derivation from identity_id bytes |
| 180 | # --------------------------------------------------------------------------- |
| 181 | |
| 182 | |
| 183 | def test_hue_derives_from_identity_id_bytes() -> None: |
| 184 | """bytes[0:4] of identity_id → hue in 0-360. Verify formula matches implementation.""" |
| 185 | from musehub.services.spectral_sigil import derive_hue |
| 186 | |
| 187 | # 0x00000000 → hue 0 |
| 188 | assert derive_hue("00000000" + "a" * 56) == 0 |
| 189 | # 0xffffffff = 4294967295; 4294967295 % 360 = 255 |
| 190 | assert derive_hue("ffffffff" + "a" * 56) == 4294967295 % 360 |
| 191 | |
| 192 | |
| 193 | def test_hue_function_importable() -> None: |
| 194 | from musehub.services.spectral_sigil import derive_hue # noqa: F401 |
| 195 | |
| 196 | assert callable(derive_hue) |
| 197 | |
| 198 | |
| 199 | # --------------------------------------------------------------------------- |
| 200 | # Orbital rings — derived from bytes 8-15 |
| 201 | # --------------------------------------------------------------------------- |
| 202 | |
| 203 | |
| 204 | def test_sigil_contains_ellipse_elements() -> None: |
| 205 | """Orbital rings are rendered as <ellipse> or <circle> elements.""" |
| 206 | from musehub.services.spectral_sigil import generate_sigil |
| 207 | |
| 208 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 209 | root = _parse_svg(svg_bytes) |
| 210 | ellipses = root.findall(".//ellipse") or root.findall( |
| 211 | ".//{http://www.w3.org/2000/svg}ellipse" |
| 212 | ) |
| 213 | circles = root.findall(".//circle") or root.findall( |
| 214 | ".//{http://www.w3.org/2000/svg}circle" |
| 215 | ) |
| 216 | assert len(ellipses) + len(circles) >= 2, "at least 2 orbital rings expected" |
| 217 | |
| 218 | |
| 219 | # --------------------------------------------------------------------------- |
| 220 | # Domain dots — colored by domain activity |
| 221 | # --------------------------------------------------------------------------- |
| 222 | |
| 223 | |
| 224 | def test_domain_dots_absent_when_no_activity() -> None: |
| 225 | """With no domain counts, domain dots should be grayscale / muted.""" |
| 226 | from musehub.services.spectral_sigil import generate_sigil |
| 227 | |
| 228 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 229 | svg_text = svg_bytes.decode() |
| 230 | # Domain-specific colors must NOT appear when there's no activity |
| 231 | domain_colors = ["#388bfd", "#bc8cff", "#3fb950", "#f0883e", "#d29922"] |
| 232 | for color in domain_colors: |
| 233 | assert color.lower() not in svg_text.lower(), ( |
| 234 | f"domain color {color} should not appear with no activity" |
| 235 | ) |
| 236 | |
| 237 | |
| 238 | def test_domain_dot_code_color_present_when_active() -> None: |
| 239 | """With code activity, the code domain color #388bfd must appear in SVG.""" |
| 240 | from musehub.services.spectral_sigil import generate_sigil |
| 241 | |
| 242 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {"code": 42}) |
| 243 | svg_text = svg_bytes.decode() |
| 244 | assert "#388bfd" in svg_text.lower() or "388bfd" in svg_text.lower(), ( |
| 245 | "code domain color #388bfd expected when code activity > 0" |
| 246 | ) |
| 247 | |
| 248 | |
| 249 | def test_domain_dot_music_color_present_when_active() -> None: |
| 250 | from musehub.services.spectral_sigil import generate_sigil |
| 251 | |
| 252 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {"music": 7}) |
| 253 | svg_text = svg_bytes.decode() |
| 254 | assert "#bc8cff" in svg_text.lower() or "bc8cff" in svg_text.lower() |
| 255 | |
| 256 | |
| 257 | def test_domain_dot_midi_color_present_when_active() -> None: |
| 258 | from musehub.services.spectral_sigil import generate_sigil |
| 259 | |
| 260 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {"midi": 3}) |
| 261 | svg_text = svg_bytes.decode() |
| 262 | assert "#3fb950" in svg_text.lower() or "3fb950" in svg_text.lower() |
| 263 | |
| 264 | |
| 265 | def test_domain_dot_mpay_color_present_when_active() -> None: |
| 266 | from musehub.services.spectral_sigil import generate_sigil |
| 267 | |
| 268 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {"mpay": 1}) |
| 269 | svg_text = svg_bytes.decode() |
| 270 | assert "#d29922" in svg_text.lower() or "d29922" in svg_text.lower() |
| 271 | |
| 272 | |
| 273 | # --------------------------------------------------------------------------- |
| 274 | # Handle initial — center text |
| 275 | # --------------------------------------------------------------------------- |
| 276 | |
| 277 | |
| 278 | def test_sigil_contains_text_with_first_char() -> None: |
| 279 | """The SVG must contain a <text> element with the first char of the handle.""" |
| 280 | from musehub.services.spectral_sigil import generate_sigil |
| 281 | |
| 282 | svg_bytes = generate_sigil(_HUMAN_ID, "gabriel", "human", {}) |
| 283 | root = _parse_svg(svg_bytes) |
| 284 | texts = root.findall(".//text") or root.findall( |
| 285 | ".//{http://www.w3.org/2000/svg}text" |
| 286 | ) |
| 287 | assert len(texts) >= 1, "sigil must contain at least one <text> element" |
| 288 | found = any("g" in (t.text or "").lower() for t in texts) |
| 289 | assert found, "first char 'g' of handle 'gabriel' must appear in a <text> element" |
| 290 | |
| 291 | |
| 292 | def test_sigil_text_initial_matches_handle() -> None: |
| 293 | from musehub.services.spectral_sigil import generate_sigil |
| 294 | |
| 295 | svg_bytes = generate_sigil(_AGENT_ID, "aria", "agent", {}) |
| 296 | root = _parse_svg(svg_bytes) |
| 297 | texts = root.findall(".//text") or root.findall( |
| 298 | ".//{http://www.w3.org/2000/svg}text" |
| 299 | ) |
| 300 | found = any("a" in (t.text or "").lower() for t in texts) |
| 301 | assert found, "first char 'a' of handle 'aria' must appear in a <text> element" |
| 302 | |
| 303 | |
| 304 | # --------------------------------------------------------------------------- |
| 305 | # HTTP route — GET /avatars/{algo}/{hex}.svg |
| 306 | # --------------------------------------------------------------------------- |
| 307 | |
| 308 | _SHA256_URL = f"/avatars/sha256/{_HUMAN_ID}.svg" |
| 309 | |
| 310 | |
| 311 | @pytest.mark.asyncio |
| 312 | async def test_avatar_route_returns_200_for_valid_id(db_session: AsyncMock) -> None: |
| 313 | """GET /avatars/sha256/{hex}.svg → 200 image/svg+xml.""" |
| 314 | mock_identity = _make_identity_mock(_HUMAN_ID, "gabriel", "human", {}) |
| 315 | |
| 316 | with patch( |
| 317 | "musehub.api.routes.musehub.ui_avatars._fetch_identity", |
| 318 | new=AsyncMock(return_value=mock_identity), |
| 319 | ): |
| 320 | async with AsyncClient( |
| 321 | transport=ASGITransport(app=app), base_url="http://test" |
| 322 | ) as client: |
| 323 | response = await client.get(_SHA256_URL) |
| 324 | |
| 325 | assert response.status_code == 200 |
| 326 | assert "image/svg+xml" in response.headers["content-type"] |
| 327 | |
| 328 | |
| 329 | @pytest.mark.asyncio |
| 330 | async def test_avatar_route_svg_body_is_valid(db_session: AsyncMock) -> None: |
| 331 | mock_identity = _make_identity_mock(_HUMAN_ID, "gabriel", "human", {}) |
| 332 | |
| 333 | with patch( |
| 334 | "musehub.api.routes.musehub.ui_avatars._fetch_identity", |
| 335 | new=AsyncMock(return_value=mock_identity), |
| 336 | ): |
| 337 | async with AsyncClient( |
| 338 | transport=ASGITransport(app=app), base_url="http://test" |
| 339 | ) as client: |
| 340 | response = await client.get(_SHA256_URL) |
| 341 | |
| 342 | root = _parse_svg(response.content) |
| 343 | assert root.tag in {"svg", "{http://www.w3.org/2000/svg}svg"} |
| 344 | |
| 345 | |
| 346 | @pytest.mark.asyncio |
| 347 | async def test_avatar_route_has_immutable_cache_headers(db_session: AsyncMock) -> None: |
| 348 | """Content-addressed: same id → same bytes forever → immutable cache.""" |
| 349 | mock_identity = _make_identity_mock(_HUMAN_ID, "gabriel", "human", {}) |
| 350 | |
| 351 | with patch( |
| 352 | "musehub.api.routes.musehub.ui_avatars._fetch_identity", |
| 353 | new=AsyncMock(return_value=mock_identity), |
| 354 | ): |
| 355 | async with AsyncClient( |
| 356 | transport=ASGITransport(app=app), base_url="http://test" |
| 357 | ) as client: |
| 358 | response = await client.get(_SHA256_URL) |
| 359 | |
| 360 | cache = response.headers.get("cache-control", "") |
| 361 | assert "immutable" in cache or "max-age=31536000" in cache |
| 362 | |
| 363 | |
| 364 | @pytest.mark.asyncio |
| 365 | async def test_avatar_route_returns_404_for_unknown_id(db_session: AsyncMock) -> None: |
| 366 | """Unknown identity_id → 404.""" |
| 367 | with patch( |
| 368 | "musehub.api.routes.musehub.ui_avatars._fetch_identity", |
| 369 | new=AsyncMock(return_value=None), |
| 370 | ): |
| 371 | async with AsyncClient( |
| 372 | transport=ASGITransport(app=app), base_url="http://test" |
| 373 | ) as client: |
| 374 | response = await client.get(_SHA256_URL) |
| 375 | |
| 376 | assert response.status_code == 404 |
| 377 | |
| 378 | |
| 379 | @pytest.mark.asyncio |
| 380 | async def test_avatar_route_returns_400_for_invalid_hex(db_session: AsyncMock) -> None: |
| 381 | """Non-hex or wrong-length digest → 400.""" |
| 382 | async with AsyncClient( |
| 383 | transport=ASGITransport(app=app), base_url="http://test" |
| 384 | ) as client: |
| 385 | response = await client.get("/avatars/sha256/not-a-valid-hex.svg") |
| 386 | |
| 387 | assert response.status_code == 400 |
| 388 | |
| 389 | |
| 390 | @pytest.mark.asyncio |
| 391 | async def test_avatar_route_returns_400_for_unknown_algo(db_session: AsyncMock) -> None: |
| 392 | """Unknown algo → 400.""" |
| 393 | async with AsyncClient( |
| 394 | transport=ASGITransport(app=app), base_url="http://test" |
| 395 | ) as client: |
| 396 | response = await client.get(f"/avatars/md5/{_HUMAN_ID}.svg") |
| 397 | |
| 398 | assert response.status_code == 400 |
| 399 | |
| 400 | |
| 401 | @pytest.mark.asyncio |
| 402 | async def test_avatar_route_deterministic_across_requests(db_session: AsyncMock) -> None: |
| 403 | """Two identical requests must return identical bodies.""" |
| 404 | mock_identity = _make_identity_mock(_HUMAN_ID, "gabriel", "human", {"code": 5}) |
| 405 | |
| 406 | with patch( |
| 407 | "musehub.api.routes.musehub.ui_avatars._fetch_identity", |
| 408 | new=AsyncMock(return_value=mock_identity), |
| 409 | ): |
| 410 | async with AsyncClient( |
| 411 | transport=ASGITransport(app=app), base_url="http://test" |
| 412 | ) as client: |
| 413 | r1 = await client.get(_SHA256_URL) |
| 414 | r2 = await client.get(_SHA256_URL) |
| 415 | |
| 416 | assert r1.content == r2.content |
File History
1 commit
sha256:0997d6250ae6476362f6fe2025af7789f46d03df3e9f34356d5e8ee79b201923
fix(issues): use issue number as pagination cursor, not cre…
Sonnet 4.6
patch
8 days ago