/
/
/
1"""Tests for the color palette helper."""
2
3from __future__ import annotations
4
5import asyncio
6import io
7from typing import TYPE_CHECKING, Any
8from unittest.mock import MagicMock
9
10import pytest
11from aiohttp.client_exceptions import ClientError
12from PIL import Image, ImageDraw
13
14from music_assistant.helpers import images
15from music_assistant.helpers.colors import (
16 _adjust_until_contrast,
17 _contrast_ratio,
18 _derive_palette,
19 _pick_accent,
20 _pick_on_color,
21 _relative_luminance,
22 extract_palette,
23 get_palette,
24)
25from music_assistant.helpers.images import invalidate_cached_image
26from tests.common import collect_loop_errors
27
28if TYPE_CHECKING:
29 from music_assistant.mass import MusicAssistant
30
31_MIN_CONTRAST = 4.5
32
33
34def _make_image_bytes(colors: list[tuple[int, int, int]]) -> bytes:
35 """Build a PNG composed of rectangles for each input color."""
36 width = 60
37 height = 60
38 img = Image.new("RGB", (width * len(colors), height), colors[0])
39 draw = ImageDraw.Draw(img)
40 for idx, color in enumerate(colors):
41 draw.rectangle([idx * width, 0, (idx + 1) * width, height], fill=color)
42 buf = io.BytesIO()
43 img.save(buf, "PNG")
44 return buf.getvalue()
45
46
47def test_relative_luminance_extremes() -> None:
48 """Black is 0, white is 1."""
49 assert _relative_luminance((0, 0, 0)) == 0.0
50 assert abs(_relative_luminance((255, 255, 255)) - 1.0) < 1e-9
51
52
53def test_contrast_ratio_black_vs_white() -> None:
54 """Black vs white is the maximum 21:1 ratio."""
55 assert abs(_contrast_ratio((0, 0, 0), (255, 255, 255)) - 21.0) < 1e-9
56
57
58def test_adjust_until_contrast_darkens_when_needed() -> None:
59 """Mixing a mid-gray toward black yields a darker color that clears MIN vs white."""
60 result = _adjust_until_contrast((180, 180, 180), (0, 0, 0), ((255, 255, 255),))
61 assert result is not None
62 assert _contrast_ratio(result, (255, 255, 255)) >= _MIN_CONTRAST
63
64
65def test_adjust_until_contrast_returns_none_for_unsolvable() -> None:
66 """Mixing toward white can never satisfy contrast vs white."""
67 assert _adjust_until_contrast((128, 128, 128), (255, 255, 255), ((255, 255, 255),)) is None
68
69
70def test_pick_accent_skips_similar_to_primary() -> None:
71 """_pick_accent returns the first candidate far enough from primary."""
72 primary = (200, 30, 30)
73 assert _pick_accent(primary, [primary, (205, 35, 35), (10, 50, 200)]) == (10, 50, 200)
74 assert _pick_accent(primary, [primary, (205, 35, 35)]) is None
75
76
77def test_pick_on_color_requires_min_contrast() -> None:
78 """Only candidates clearing MIN_CONTRAST vs the target are considered."""
79 candidates = [(255, 255, 255), (128, 128, 128), (20, 20, 20)]
80 # vs near-black target, white wins, gray fails MIN_CONTRAST
81 assert _pick_on_color(candidates, (20, 20, 20)) == (255, 255, 255)
82
83
84def test_derive_palette_synthesizes_on_colors_when_no_candidate() -> None:
85 """When no image candidate clears contrast, on_dark/on_light are synthesized."""
86 # All candidates near mid-gray â none can clear 4.5 vs pure black or pure white.
87 candidates = [(120, 120, 120), (130, 125, 128), (115, 118, 122)]
88 palette = _derive_palette(candidates)
89 assert palette.on_dark is not None
90 assert palette.on_light is not None
91 assert _contrast_ratio(palette.on_dark, (0, 0, 0)) >= _MIN_CONTRAST
92 assert _contrast_ratio(palette.on_light, (255, 255, 255)) >= _MIN_CONTRAST
93
94
95def test_derive_palette_empty() -> None:
96 """Empty candidates yield a fully-empty palette, not an exception."""
97 palette = _derive_palette([])
98 assert palette.primary is None
99 assert palette.background_dark is None
100
101
102def test_palette_invariants_over_random_candidates() -> None:
103 """
104 Across many synthetic candidate sets, every emitted contrast pair holds.
105
106 Sweeps a fixed-seed RNG over candidate counts and RGB values to catch
107 regressions in the contrast invariants. Calibrate the iteration count so
108 the test stays cheap in CI.
109 """
110 import random # noqa: PLC0415
111
112 rng = random.Random(42)
113 # Calibrated for CI: 50 iterations runs in a few ms. Bump if invariants
114 # regress and we need denser coverage.
115 for _ in range(50):
116 n = rng.randint(1, 8)
117 cands = [(rng.randrange(256), rng.randrange(256), rng.randrange(256)) for _ in range(n)]
118 p = _derive_palette(cands)
119 if p.background_dark is not None:
120 assert _contrast_ratio(p.background_dark, (255, 255, 255)) >= _MIN_CONTRAST
121 if p.background_light is not None:
122 assert _contrast_ratio(p.background_light, (0, 0, 0)) >= _MIN_CONTRAST
123 if p.on_dark is not None:
124 assert _contrast_ratio(p.on_dark, (0, 0, 0)) >= _MIN_CONTRAST
125 if p.on_light is not None:
126 assert _contrast_ratio(p.on_light, (255, 255, 255)) >= _MIN_CONTRAST
127
128
129def test_extract_palette_handles_invalid_bytes() -> None:
130 """Invalid image bytes return an empty palette."""
131 palette = extract_palette(b"not an image")
132 assert palette.primary is None
133
134
135def test_extract_palette_end_to_end() -> None:
136 """An image with multiple distinct colors yields a palette that meets WCAG."""
137 image_bytes = _make_image_bytes([(60, 30, 90), (220, 200, 150), (250, 250, 245)])
138 palette = extract_palette(image_bytes)
139 assert palette.primary is not None
140 if palette.background_dark is not None:
141 assert _contrast_ratio(palette.background_dark, (255, 255, 255)) >= _MIN_CONTRAST
142
143
144async def test_cancelled_caller_of_a_failing_extraction_logs_no_loop_error(
145 mass_minimal: MusicAssistant, monkeypatch: pytest.MonkeyPatch
146) -> None:
147 """Palette extraction failing after its caller gave up is not reported to the loop handler."""
148 # mass_minimal constructs the cache controller without initializing it
149 cache_config = await mass_minimal.config.get_core_config(mass_minimal.cache.domain)
150 await mass_minimal.cache.setup(cache_config)
151 entered = asyncio.Event()
152 release = asyncio.Event()
153 extraction: list[asyncio.Task[Any]] = []
154
155 async def failing_source(_mass: MusicAssistant, path_or_url: str, _provider: str) -> bytes:
156 current = asyncio.current_task()
157 assert current is not None
158 extraction.append(current)
159 entered.set()
160 await release.wait()
161 raise PermissionError(f"Permission denied: {path_or_url}")
162
163 monkeypatch.setattr("music_assistant.helpers.colors.get_image_data", failing_source)
164 with collect_loop_errors() as reported:
165 caller = asyncio.create_task(get_palette(mass_minimal, "/some/image.png", "builtin"))
166 await entered.wait()
167 caller.cancel()
168 with pytest.raises(asyncio.CancelledError):
169 await caller
170 # only fail the extraction once the cancellation is fully processed
171 release.set()
172 await asyncio.wait(extraction)
173
174 assert isinstance(extraction[0].exception(), PermissionError)
175 assert reported == []
176
177
178async def test_get_palette_tolerates_unavailable_image(
179 mass_minimal: MusicAssistant, monkeypatch: pytest.MonkeyPatch
180) -> None:
181 """An unfetchable image yields an empty palette; extraction retries once it is back."""
182 mass_minimal.webserver = MagicMock(base_url="http://127.0.0.1:8095")
183 mass_minimal.streams = MagicMock(base_url="http://127.0.0.1:8097")
184 # mass_minimal constructs the cache controller without initializing it
185 cache_config = await mass_minimal.config.get_core_config(mass_minimal.cache.domain)
186 await mass_minimal.cache.setup(cache_config)
187 remote_url = "http://sonos.example.com:1400/getaa?u=gone.flac"
188
189 async def failing_remote_fetch(_mass: MusicAssistant, _url: str) -> bytes:
190 raise ClientError("404, message='Not Found'")
191
192 monkeypatch.setattr(images, "_fetch_remote_image", failing_remote_fetch)
193 try:
194 palette = await get_palette(mass_minimal, remote_url, "builtin")
195 assert palette is not None
196 assert palette.primary is None # empty palette, but no exception raised
197
198 # nothing was cached for the failure, so once the artwork is reachable
199 # again the real palette is extracted
200 async def ok_remote_fetch(_mass: MusicAssistant, _url: str) -> bytes:
201 return _make_image_bytes([(200, 30, 30), (30, 30, 200)])
202
203 monkeypatch.setattr(images, "_fetch_remote_image", ok_remote_fetch)
204 await invalidate_cached_image(mass_minimal, "builtin", remote_url)
205 palette = await get_palette(mass_minimal, remote_url, "builtin")
206 assert palette is not None
207 assert palette.primary is not None
208 finally:
209 # drop the module-global image cache entries this test created
210 await invalidate_cached_image(mass_minimal, "builtin", remote_url)
211