/
/
/
1"""Tests for HueAudioAnalyzer (beat-driven palette cycling)."""
2
3from __future__ import annotations
4
5import colorsys
6
7import pytest
8from aiosendspin.models.visualizer import BeatTiming
9from hue_entertainment import LightChannel, LightColorCommand
10
11from music_assistant.providers.hue_entertainment.analyzer import (
12 _NEUTRAL_GRADIENT,
13 DEFAULT_MODE,
14 HueAudioAnalyzer,
15 _distinct_hue_count,
16 _ScheduledBeat,
17)
18
19
20def _make_channels(count: int) -> list[LightChannel]:
21 """Create a list of test light channels."""
22 return [
23 LightChannel(channel_id=i, service_id=f"svc_{i}", name=f"Light {i}") for i in range(count)
24 ]
25
26
27def _beat(ts_us: int, *, downbeat: bool = False) -> BeatTiming:
28 """Shortcut to build a BeatTiming."""
29 return BeatTiming(timestamp_us=ts_us, is_downbeat=downbeat)
30
31
32class TestSettings:
33 """Tests for the analyzer's settings handling."""
34
35 def test_init_defaults(self) -> None:
36 """Test analyzer initialization with default settings."""
37 analyzer = HueAudioAnalyzer(_make_channels(3))
38 assert analyzer._brightness == 1.0
39 assert analyzer._color_mode == DEFAULT_MODE
40
41 def test_brightness_clamping(self) -> None:
42 """Test that brightness is clamped to 0-100."""
43 assert HueAudioAnalyzer(_make_channels(1), brightness=150)._brightness == 1.0
44 assert HueAudioAnalyzer(_make_channels(1), brightness=-10)._brightness == 0.0
45
46 def test_update_settings(self) -> None:
47 """Test live settings update."""
48 analyzer = HueAudioAnalyzer(_make_channels(2))
49 analyzer.update_settings(brightness=75)
50 assert analyzer._brightness == pytest.approx(0.75)
51
52
53class TestBeatScheduling:
54 """Tests for beat queue management and per-bar palette indexing."""
55
56 def test_push_beats_resolves_bar_positions(self) -> None:
57 """The beat counter increments continuously across the schedule."""
58 analyzer = HueAudioAnalyzer(_make_channels(1))
59 analyzer.push_beats(
60 [
61 _beat(1_000_000, downbeat=True),
62 _beat(1_500_000),
63 _beat(2_000_000),
64 _beat(2_500_000),
65 _beat(3_000_000, downbeat=True),
66 _beat(3_500_000),
67 ]
68 )
69 positions = [b.beat_in_bar for b in analyzer._beats]
70 assert positions == [0, 1, 2, 3, 4, 5]
71
72 def test_clear_beats_resets_counter(self) -> None:
73 """clear_beats also resets the per-bar counter for the next schedule."""
74 analyzer = HueAudioAnalyzer(_make_channels(1))
75 analyzer.push_beats([_beat(0), _beat(500_000), _beat(1_000_000)])
76 analyzer.clear_beats()
77 analyzer.push_beats([_beat(2_000_000)])
78 assert analyzer._beats[0].beat_in_bar == 0
79
80 def test_render_prunes_old_beats(self) -> None:
81 """Past beats older than the most recent one are pruned during render."""
82 analyzer = HueAudioAnalyzer(_make_channels(1))
83 analyzer.push_beats([_beat(i * 1_000_000) for i in range(5)])
84 analyzer.render(now_us=3_500_000)
85 # Only the most recent past beat is kept ahead of the cursor.
86 assert analyzer._beats[0].timestamp_us == 3_000_000
87
88
89class TestRenderColors:
90 """Tests for color rendering across the beat schedule."""
91
92 def test_render_with_no_beats_falls_back_to_peak_walker(self) -> None:
93 """No beats scheduled â renderer delegates to the peak-driven walker."""
94 analyzer = HueAudioAnalyzer(_make_channels(2))
95 commands = analyzer.render(now_us=0)
96 assert len(commands) == 2
97 assert _channel_sum(commands[0]) > 0
98
99 def test_render_no_channels_returns_empty(self) -> None:
100 """Empty channel list yields no commands."""
101 analyzer = HueAudioAnalyzer([])
102 analyzer.push_beats([_beat(0), _beat(500_000)])
103 assert analyzer.render(now_us=250_000) == []
104
105 def test_brightness_scales_output(self) -> None:
106 """Halving brightness halves the rendered channel values."""
107 full = HueAudioAnalyzer(_make_channels(1), brightness=100).render(now_us=0)[0]
108 half = HueAudioAnalyzer(_make_channels(1), brightness=50).render(now_us=0)[0]
109 assert half.green == pytest.approx(full.green // 2, abs=1)
110
111 def test_pulse_boosts_above_steady_level(self) -> None:
112 """In a pulsed mode, output on the beat exceeds the steady (off-beat) level."""
113 analyzer = HueAudioAnalyzer(_make_channels(1), color_mode="flashing")
114 analyzer.push_beats([_beat(0, downbeat=True), _beat(1_000_000), _beat(2_000_000)])
115 on_beat = analyzer.render(now_us=1_000_000)[0]
116 steady = analyzer.render(now_us=1_500_000)[0]
117 assert _channel_sum(on_beat) > _channel_sum(steady)
118
119 def test_pulse_higher_on_downbeat_than_regular(self) -> None:
120 """Identical timing but a downbeat flag produces a stronger pulse."""
121
122 def render_at_beat(*, downbeat: bool) -> LightColorCommand:
123 analyzer = HueAudioAnalyzer(_make_channels(1), color_mode="flashing")
124 analyzer.push_beats(
125 [_beat(0, downbeat=True), _beat(500_000, downbeat=downbeat), _beat(1_000_000)]
126 )
127 return analyzer.render(now_us=500_000)[0]
128
129 regular = render_at_beat(downbeat=False)
130 downbeat = render_at_beat(downbeat=True)
131 assert _channel_sum(downbeat) > _channel_sum(regular)
132
133
134class TestPalette:
135 """Tests for color@v1 palette selection."""
136
137 def test_no_color_uses_neutral_fallback(self) -> None:
138 """With no color@v1 update, the neutral tint gradient is used."""
139 raw, synthesized = HueAudioAnalyzer(_make_channels(1))._gather_raw_palette()
140 assert raw == _NEUTRAL_GRADIENT
141 assert synthesized is True
142
143 def test_two_distinct_hues_cycle_the_album_colors(self) -> None:
144 """Two hue-distant colors are used as-is, not synthesized."""
145 analyzer = HueAudioAnalyzer(_make_channels(1))
146 analyzer.apply_color_palette({"primary": (200, 0, 0), "accent": (0, 0, 200)})
147 raw, synthesized = analyzer._gather_raw_palette()
148 assert synthesized is False
149 assert _distinct_hue_count(raw) >= 2
150
151 def test_single_hue_expands_into_same_family(self) -> None:
152 """A single hue plus near-whites yields a gradient that stays in that hue."""
153 analyzer = HueAudioAnalyzer(_make_channels(1))
154 analyzer.apply_color_palette(
155 {
156 "primary": (20, 90, 30),
157 "background_light": (235, 245, 238),
158 "on_dark": (250, 250, 250),
159 }
160 )
161 palette = analyzer._active_palette()
162 # Every entry keeps green as its dominant channel (no wash to white).
163 assert all(g >= r and g >= b for r, g, b in palette)
164
165 def test_single_hue_palette_does_not_collapse(self) -> None:
166 """The single-hue gradient keeps more than one distinct color."""
167 analyzer = HueAudioAnalyzer(_make_channels(1))
168 analyzer.apply_color_palette({"primary": (20, 90, 30), "on_dark": (250, 250, 250)})
169 palette = analyzer._active_palette()
170 assert len({tuple(round(c, 3) for c in entry) for entry in palette}) > 1
171
172 def test_dark_saturated_color_is_not_achromatic(self) -> None:
173 """A dark-but-colored seed (brown) keeps its hue instead of washing neutral."""
174 analyzer = HueAudioAnalyzer(_make_channels(1))
175 analyzer.apply_color_palette({"primary": (60, 40, 25), "on_dark": (250, 250, 250)})
176 raw, synthesized = analyzer._gather_raw_palette()
177 assert synthesized is True
178 assert raw != _NEUTRAL_GRADIENT
179
180 def test_achromatic_uses_neutral_gradient(self) -> None:
181 """An all-grey palette produces the subtle neutral tint gradient."""
182 analyzer = HueAudioAnalyzer(_make_channels(1))
183 analyzer.apply_color_palette(
184 {
185 "primary": (128, 128, 128),
186 "background_light": (240, 240, 240),
187 "on_dark": (255, 255, 255),
188 }
189 )
190 raw, synthesized = analyzer._gather_raw_palette()
191 assert synthesized is True
192 assert raw == _NEUTRAL_GRADIENT
193
194 def test_undefined_palette_field_keeps_prior_value(self) -> None:
195 """apply_color_palette merges, leaving fields not in the update untouched."""
196 analyzer = HueAudioAnalyzer(_make_channels(1))
197 analyzer.apply_color_palette({"primary": (10, 20, 30), "accent": (40, 50, 60)})
198 analyzer.apply_color_palette({"primary": (100, 100, 100)})
199 assert analyzer._server_palette["primary"] == (100, 100, 100)
200 assert analyzer._server_palette["accent"] == (40, 50, 60)
201
202
203class TestDistinctHueCount:
204 """Tests for the hue-family grouping helper."""
205
206 def test_shades_of_one_hue_count_as_one(self) -> None:
207 """Dark and light shades of the same hue collapse to a single family."""
208 dark = colorsys.hsv_to_rgb(0.39, 0.8, 0.3)
209 light = colorsys.hsv_to_rgb(0.39, 0.5, 0.9)
210 assert _distinct_hue_count([dark, light]) == 1
211
212 def test_opposite_hues_count_separately(self) -> None:
213 """Hues far apart on the wheel count as separate families."""
214 red = colorsys.hsv_to_rgb(0.0, 0.9, 0.8)
215 cyan = colorsys.hsv_to_rgb(0.5, 0.9, 0.8)
216 assert _distinct_hue_count([red, cyan]) == 2
217
218
219class TestSpectrumScheduling:
220 """Tests for timestamped spectrum drain inside render()."""
221
222 def test_spectrum_not_applied_before_timestamp(self) -> None:
223 """A spectrum frame stays pending until render time reaches its ts."""
224 analyzer = HueAudioAnalyzer(_make_channels(1))
225 analyzer.apply_spectrum([60000] * 4, timestamp_us=1_000_000)
226 analyzer.render(now_us=500_000)
227 assert analyzer._spectrum == []
228
229 def test_spectrum_applied_once_due(self) -> None:
230 """Once now_us reaches the ts, the frame is promoted to active spectrum."""
231 analyzer = HueAudioAnalyzer(_make_channels(1))
232 analyzer.apply_spectrum([60000] * 4, timestamp_us=1_000_000)
233 analyzer.render(now_us=1_000_000)
234 assert len(analyzer._spectrum) == 4
235
236
237class TestPeakWalkFallback:
238 """Tests for the peak-driven palette walker used when no beat schedule exists."""
239
240 def test_peaks_advance_palette_when_no_beats(self) -> None:
241 """Each consumed peak bumps _peak_palette_position by mode.palette_advance."""
242 analyzer = HueAudioAnalyzer(_make_channels(1), color_mode="ambient")
243 step = analyzer._mode.palette_advance
244 analyzer.apply_peak(strength=200, timestamp_us=1_000_000)
245 analyzer.apply_peak(strength=200, timestamp_us=2_000_000)
246 analyzer.render(now_us=2_500_000)
247 assert analyzer._peak_palette_position == pytest.approx(step * 2)
248
249 def test_pending_peak_does_not_advance_before_due(self) -> None:
250 """Peaks scheduled in the future leave the walker untouched until promoted."""
251 analyzer = HueAudioAnalyzer(_make_channels(1), color_mode="ambient")
252 analyzer.apply_peak(strength=200, timestamp_us=5_000_000)
253 analyzer.render(now_us=1_000_000)
254 assert analyzer._peak_palette_position == 0.0
255
256 def test_no_beats_no_peaks_holds_first_palette_slot(self) -> None:
257 """Without any beats or peaks, the walker stays at slot 0."""
258 analyzer = HueAudioAnalyzer(_make_channels(2))
259 analyzer.render(now_us=0)
260 assert analyzer._peak_palette_position == 0.0
261
262 def test_clear_beats_resets_peak_walker(self) -> None:
263 """clear_beats wipes the peak-driven position so the next track starts fresh."""
264 analyzer = HueAudioAnalyzer(_make_channels(1), color_mode="ambient")
265 analyzer.apply_peak(strength=255, timestamp_us=500_000)
266 analyzer.render(now_us=500_000)
267 assert analyzer._peak_palette_position > 0.0
268 analyzer.clear_beats()
269 assert analyzer._peak_palette_position == 0.0
270
271
272class TestScheduledBeatModel:
273 """Sanity tests for the internal scheduled-beat dataclass."""
274
275 def test_scheduled_beat_is_frozen(self) -> None:
276 """The dataclass should be frozen so the queue is immutable per-entry."""
277 sb = _ScheduledBeat(timestamp_us=0, beat_in_bar=0, is_downbeat=True)
278 with pytest.raises(AttributeError):
279 sb.timestamp_us = 1 # type: ignore[misc]
280
281
282def _channel_sum(command: LightColorCommand) -> int:
283 """Total 16-bit energy across a command's RGB channels."""
284 return int(command.red + command.green + command.blue)
285