/
/
/
1"""Tests for Music Quiz suggestion helpers."""
2
3from __future__ import annotations
4
5import json
6import random
7from unittest.mock import AsyncMock, MagicMock, patch
8
9import pytest
10
11from music_assistant.models.plugin import AIEngine, PluginProvider
12from music_assistant.providers.music_quiz.ai_distractors import (
13 MAX_AI_LABEL_LENGTH,
14 parse_ai_distractor_response,
15 request_ai_distractors,
16)
17from music_assistant.providers.music_quiz.constants import (
18 MAX_AI_PROMPT_BYTES,
19 MAX_AI_RESPONSE_BYTES,
20 MAX_AI_RESPONSE_LINES,
21)
22from music_assistant.providers.music_quiz.suggestions import (
23 SuggestionCandidate,
24 answer_labels_are_too_close,
25 build_answer_label,
26 build_suggestions,
27 filter_suggestion_candidates,
28 normalize_answer_label,
29 suggestion_candidates_are_too_close,
30)
31
32
33def test_build_answer_label_with_artist_and_title() -> None:
34 """Build the v1 Artist - Track title answer label."""
35 assert build_answer_label("Massive Attack", "Teardrop") == "Massive Attack - Teardrop"
36
37
38def test_build_answer_label_without_artist() -> None:
39 """Fall back to title when the artist is unknown."""
40 assert build_answer_label(None, "Untitled") == "Untitled"
41
42
43def test_normalize_answer_label_ignores_case_and_punctuation() -> None:
44 """Normalize answer labels for duplicate detection."""
45 assert normalize_answer_label("Daft Punk - One More Time!") == "daft punk one more time"
46
47
48def test_normalize_answer_label_handles_canonical_unicode_equivalence() -> None:
49 """Normalize composed and decomposed Unicode answer labels identically."""
50 assert normalize_answer_label("Beyoncé") == normalize_answer_label("Beyonce\u0301")
51
52
53def test_answer_labels_are_too_close_detects_title_versions() -> None:
54 """Treat radio edits and remasters as too close for answer choices."""
55 assert answer_labels_are_too_close(
56 "Massive Attack - Teardrop",
57 "Massive Attack - Teardrop [Radio Edit]",
58 )
59 assert answer_labels_are_too_close(
60 "Massive Attack - Teardrop",
61 "Massive Attack - Teardrop (Remastered 2019)",
62 )
63 assert not answer_labels_are_too_close(
64 "Massive Attack - Teardrop",
65 "Portishead - Glory Box",
66 )
67
68
69def test_suggestion_candidates_compare_track_titles_first() -> None:
70 """Use raw track titles as the primary distance signal when available."""
71 assert suggestion_candidates_are_too_close(
72 SuggestionCandidate(
73 "Artist One - Midnight City",
74 "library://track/1",
75 title="Midnight City",
76 ),
77 SuggestionCandidate(
78 "Artist Two - Midnight City [Radio Edit]",
79 "library://track/2",
80 title="Midnight City [Radio Edit]",
81 ),
82 )
83 assert not suggestion_candidates_are_too_close(
84 SuggestionCandidate(
85 "Artist One - Midnight City",
86 "library://track/1",
87 title="Midnight City",
88 ),
89 SuggestionCandidate(
90 "Artist One - Reunion",
91 "library://track/2",
92 title="Reunion",
93 ),
94 )
95
96
97def test_build_suggestions_includes_one_correct_answer() -> None:
98 """Build suggestions with exactly one correct answer."""
99 suggestions = build_suggestions(
100 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
101 [
102 SuggestionCandidate("Justice - D.A.N.C.E.", "library://track/2"),
103 SuggestionCandidate("Phoenix - Lisztomania", "library://track/3"),
104 SuggestionCandidate("Air - Sexy Boy", "library://track/4"),
105 ],
106 4,
107 rng=random.Random(1),
108 )
109
110 assert len(suggestions) == 4
111 assert sum(item.is_correct for item in suggestions) == 1
112 assert {item.label for item in suggestions} == {
113 "Daft Punk - One More Time",
114 "Justice - D.A.N.C.E.",
115 "Phoenix - Lisztomania",
116 "Air - Sexy Boy",
117 }
118
119
120def test_build_suggestions_filters_duplicate_uri_and_label() -> None:
121 """Skip distractors that duplicate the correct answer or each other."""
122 suggestions = build_suggestions(
123 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
124 [
125 SuggestionCandidate("Daft Punk - One More Time", "library://track/other"),
126 SuggestionCandidate("Different label", "library://track/1"),
127 SuggestionCandidate("Justice - D.A.N.C.E.", "library://track/2"),
128 SuggestionCandidate("Justice D A N C E", "library://track/3"),
129 SuggestionCandidate("Phoenix - Lisztomania", "library://track/4"),
130 ],
131 3,
132 rng=random.Random(1),
133 )
134
135 assert {item.label for item in suggestions} == {
136 "Daft Punk - One More Time",
137 "Justice - D.A.N.C.E.",
138 "Phoenix - Lisztomania",
139 }
140
141
142def test_build_suggestions_filters_close_title_versions() -> None:
143 """Skip distractors that are only version variants of the answer."""
144 suggestions = build_suggestions(
145 SuggestionCandidate(
146 "Massive Attack - Teardrop",
147 "library://track/1",
148 title="Teardrop",
149 ),
150 [
151 SuggestionCandidate(
152 "Massive Attack - Teardrop [Radio Edit]",
153 "library://track/2",
154 title="Teardrop [Radio Edit]",
155 ),
156 SuggestionCandidate(
157 "Massive Attack - Teardrop (Remastered 2019)",
158 "library://track/3",
159 title="Teardrop (Remastered 2019)",
160 ),
161 SuggestionCandidate(
162 "Portishead - Glory Box",
163 "library://track/4",
164 title="Glory Box",
165 ),
166 SuggestionCandidate(
167 "Tricky - Hell Is Round The Corner",
168 "library://track/5",
169 title="Hell Is Round The Corner",
170 ),
171 ],
172 3,
173 rng=random.Random(1),
174 )
175
176 assert {item.label for item in suggestions} == {
177 "Massive Attack - Teardrop",
178 "Portishead - Glory Box",
179 "Tricky - Hell Is Round The Corner",
180 }
181
182
183def test_build_suggestions_filters_close_distractors() -> None:
184 """Skip candidates that are too close to already selected distractors."""
185 suggestions = build_suggestions(
186 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
187 [
188 SuggestionCandidate("Justice - D.A.N.C.E.", "library://track/2"),
189 SuggestionCandidate("Justice - D.A.N.C.E. Radio Edit", "library://track/3"),
190 SuggestionCandidate("Phoenix - Lisztomania", "library://track/4"),
191 ],
192 3,
193 rng=random.Random(1),
194 )
195
196 assert {item.label for item in suggestions} == {
197 "Daft Punk - One More Time",
198 "Justice - D.A.N.C.E.",
199 "Phoenix - Lisztomania",
200 }
201
202
203def test_build_suggestions_requires_enough_distractors() -> None:
204 """Fail clearly when there are not enough unique distractors."""
205 with pytest.raises(ValueError, match="Not enough distractors"):
206 build_suggestions(
207 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
208 [SuggestionCandidate("Daft Punk - One More Time", "library://track/2")],
209 3,
210 )
211
212
213def test_build_suggestions_requires_at_least_two_choices() -> None:
214 """Reject a suggestion count below two."""
215 with pytest.raises(ValueError, match="at least 2"):
216 build_suggestions(
217 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
218 [],
219 1,
220 )
221
222
223def test_build_suggestions_use_opaque_ids() -> None:
224 """Suggestion IDs are sent to guests pre-reveal: they must not name the answer."""
225 opaque_ids = ["id-a", "id-b", "id-c", "id-d"]
226 with patch(
227 "music_assistant.providers.music_quiz.suggestions.secrets.token_hex",
228 side_effect=opaque_ids,
229 ):
230 suggestions = build_suggestions(
231 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
232 [
233 SuggestionCandidate("Justice - D.A.N.C.E.", "library://track/2"),
234 SuggestionCandidate("Phoenix - Lisztomania", "library://track/3"),
235 SuggestionCandidate("Air - Sexy Boy", "library://track/4"),
236 ],
237 4,
238 rng=random.Random(1),
239 )
240
241 # every id comes from the opaque token source, never derived from the answer
242 assert {suggestion.suggestion_id for suggestion in suggestions} == set(opaque_ids)
243
244
245def test_build_suggestions_uses_local_secure_shuffle_by_default() -> None:
246 """Shuffle final options locally with the system random source."""
247 with patch("music_assistant.providers.music_quiz.suggestions.SYSTEM_RANDOM.shuffle") as shuffle:
248 build_suggestions(
249 SuggestionCandidate("Daft Punk - One More Time", "library://track/1"),
250 [
251 SuggestionCandidate("Justice - D.A.N.C.E.", "library://track/2"),
252 SuggestionCandidate("Phoenix - Lisztomania", "library://track/3"),
253 SuggestionCandidate("Air - Sexy Boy", "library://track/4"),
254 ],
255 4,
256 )
257
258 shuffle.assert_called_once()
259
260
261def test_filter_suggestion_candidates_preserves_valid_order() -> None:
262 """Filter duplicate and close candidates without reordering valid entries."""
263 correct = SuggestionCandidate("Daft Punk - One More Time", title="One More Time")
264 candidates = [
265 SuggestionCandidate("Daft Punk - One More Time (Remix)", title="One More Time (Remix)"),
266 SuggestionCandidate("Justice - Genesis", title="Genesis"),
267 SuggestionCandidate("Justice - Genesis!", title="Genesis!"),
268 SuggestionCandidate("Air - Sexy Boy", title="Sexy Boy"),
269 ]
270
271 assert filter_suggestion_candidates(correct, candidates) == [
272 candidates[1],
273 candidates[3],
274 ]
275
276
277def test_parse_ai_distractor_response_accepts_exact_schema() -> None:
278 """Parse a complete candidate permutation and exact synthetic kind sequence."""
279 response = json.dumps(
280 {
281 "ranked_ids": ["candidate_1", "candidate_0"],
282 "synthetic": [
283 {"kind": "same_artist_title", "label": "Daft Punk - Neon Horizon"},
284 {"kind": "context_track", "label": "Lunar Circuit - Chrome Reverie"},
285 ],
286 }
287 )
288
289 parsed = parse_ai_distractor_response(
290 response,
291 ["candidate_0", "candidate_1"],
292 ["same_artist_title", "context_track"],
293 )
294
295 assert parsed.ranked_ids == ("candidate_1", "candidate_0")
296 assert [(item.kind, item.label) for item in parsed.synthetic] == [
297 ("same_artist_title", "Daft Punk - Neon Horizon"),
298 ("context_track", "Lunar Circuit - Chrome Reverie"),
299 ]
300
301
302@pytest.mark.parametrize(
303 "fence",
304 ["```json", "```"],
305)
306def test_parse_ai_distractor_response_accepts_fenced_payload(fence: str) -> None:
307 """Accept an exact payload wrapped in a code fence with or without a language tag."""
308 payload = json.dumps(
309 {
310 "ranked_ids": ["candidate_0"],
311 "synthetic": [{"kind": "artist", "label": "Fake Artist"}],
312 }
313 )
314
315 parsed = parse_ai_distractor_response(
316 f"{fence}\n{payload}\n```\n",
317 ["candidate_0"],
318 ["artist"],
319 )
320
321 assert parsed.ranked_ids == ("candidate_0",)
322 assert [(item.kind, item.label) for item in parsed.synthetic] == [("artist", "Fake Artist")]
323
324
325@pytest.mark.parametrize(
326 "response",
327 [
328 "not json",
329 "```json\nnot json\n```",
330 "Here is the JSON you asked for:\n```json\n"
331 + json.dumps(
332 {
333 "ranked_ids": ["candidate_0"],
334 "synthetic": [{"kind": "artist", "label": "Fake Artist"}],
335 }
336 )
337 + "\n```",
338 json.dumps(
339 {
340 "ranked_ids": ["candidate_0"],
341 "synthetic": [{"kind": "artist", "label": "Fake Artist"}],
342 "extra": True,
343 }
344 ),
345 json.dumps(
346 {
347 "ranked_ids": [],
348 "synthetic": [{"kind": "artist", "label": "Fake Artist"}],
349 }
350 ),
351 json.dumps(
352 {
353 "ranked_ids": ["candidate_0"],
354 "synthetic": [{"kind": "wrong", "label": "Fake Artist"}],
355 }
356 ),
357 json.dumps(
358 {
359 "ranked_ids": ["candidate_0"],
360 "synthetic": [{"kind": "artist", "label": "Fake Artist", "extra": True}],
361 }
362 ),
363 json.dumps(
364 {
365 "ranked_ids": ["candidate_0"],
366 "synthetic": [{"kind": "artist", "label": 123}],
367 }
368 ),
369 json.dumps(
370 {
371 "ranked_ids": ["candidate_0"],
372 "synthetic": [{"kind": "artist", "label": " Fake Artist"}],
373 }
374 ),
375 json.dumps(
376 {
377 "ranked_ids": ["candidate_0"],
378 "synthetic": [{"kind": "artist", "label": "Fake\nArtist"}],
379 }
380 ),
381 json.dumps(
382 {
383 "ranked_ids": ["candidate_0"],
384 "synthetic": [{"kind": "artist", "label": "Fake\u0000Artist"}],
385 }
386 ),
387 json.dumps(
388 {
389 "ranked_ids": ["candidate_0"],
390 "synthetic": [{"kind": "artist", "label": "Fake\u2028Artist"}],
391 }
392 ),
393 ],
394)
395def test_parse_ai_distractor_response_rejects_malformed_output(response: str) -> None:
396 """Reject commentary, extra fields, wrong shapes, and unsafe labels."""
397 with pytest.raises((TypeError, ValueError)):
398 parse_ai_distractor_response(response, ["candidate_0"], ["artist"])
399
400
401def test_parse_ai_distractor_response_rejects_wrong_count_and_close_labels() -> None:
402 """Require the exact synthetic count with pairwise-distinct labels."""
403 missing = json.dumps({"ranked_ids": [], "synthetic": []})
404 close = json.dumps(
405 {
406 "ranked_ids": [],
407 "synthetic": [
408 {"kind": "artist", "label": "Fake Artist"},
409 {"kind": "artist", "label": "Fake Artist!"},
410 ],
411 }
412 )
413
414 with pytest.raises(ValueError, match="shape"):
415 parse_ai_distractor_response(missing, [], ["artist"])
416 with pytest.raises(ValueError, match="distinct"):
417 parse_ai_distractor_response(close, [], ["artist", "artist"])
418
419
420def test_parse_ai_distractor_response_enforces_size_line_and_label_limits() -> None:
421 """Reject responses and labels outside their explicit resource limits."""
422 oversized_response = "x" * (MAX_AI_RESPONSE_BYTES + 1)
423 too_many_lines = "\n".join("{}" for _ in range(MAX_AI_RESPONSE_LINES + 1))
424 oversized_label = json.dumps(
425 {
426 "ranked_ids": [],
427 "synthetic": [
428 {"kind": "artist", "label": "x" * (MAX_AI_LABEL_LENGTH + 1)},
429 ],
430 }
431 )
432
433 with pytest.raises(ValueError, match="size"):
434 parse_ai_distractor_response(oversized_response, [], [])
435 with pytest.raises(ValueError, match="line"):
436 parse_ai_distractor_response(too_many_lines, [], [])
437 with pytest.raises(ValueError, match="length"):
438 parse_ai_distractor_response(oversized_label, [], ["artist"])
439
440
441def test_parse_ai_distractor_response_limits_the_original_response() -> None:
442 """Enforce the size and line limits before a code fence is stripped."""
443 oversized_response = f"```json\n{'x' * MAX_AI_RESPONSE_BYTES}\n```"
444 too_many_lines = "```json\n" + "\n".join("{}" for _ in range(MAX_AI_RESPONSE_LINES)) + "\n```"
445
446 with pytest.raises(ValueError, match="size"):
447 parse_ai_distractor_response(oversized_response, [], [])
448 with pytest.raises(ValueError, match="line"):
449 parse_ai_distractor_response(too_many_lines, [], [])
450
451
452@pytest.mark.asyncio
453async def test_ai_distractor_request_rejects_oversized_prompt_before_provider_lookup() -> None:
454 """Do not discover or call an AI engine for an oversized prompt."""
455 mass = MagicMock()
456 provider = MagicMock(spec=PluginProvider)
457 provider.instance_id = "ai--1"
458 provider.ai_query = AsyncMock(return_value="")
459 provider.get_ai_engines = AsyncMock(
460 return_value=[AIEngine(id="engine", name="ai--1", provider=provider)]
461 )
462 mass.get_providers_supporting_feature.return_value = [provider]
463
464 response = await request_ai_distractors(mass, "x" * (MAX_AI_PROMPT_BYTES + 1), engine_uid=None)
465
466 assert response is None
467 mass.get_providers_supporting_feature.assert_not_called()
468 provider.ai_query.assert_not_awaited()
469
470
471@pytest.mark.asyncio
472async def test_ai_distractor_request_refuses_a_configured_engine_that_disappeared() -> None:
473 """A concrete engine selection is never silently replaced by another available engine."""
474 mass = MagicMock()
475 provider = MagicMock(spec=PluginProvider)
476 provider.instance_id = "ai--1"
477 provider.ai_query = AsyncMock(return_value="")
478 provider.get_ai_engines = AsyncMock(
479 return_value=[AIEngine(id="engine", name="ai--1", provider=provider)]
480 )
481 mass.get_providers_supporting_feature.return_value = [provider]
482
483 response = await request_ai_distractors(mass, "prompt", engine_uid="ai--1/gone")
484
485 assert response is None
486 provider.ai_query.assert_not_awaited()
487