/
/
/
1"""Tests for Music Quiz persisted models."""
2
3from __future__ import annotations
4
5import inspect
6import subprocess
7import sys
8from pathlib import Path
9
10import pytest
11from mashumaro.exceptions import ExtraKeysError, InvalidFieldValue
12
13from music_assistant.helpers.shared_playback import SharedPlaybackMode
14from music_assistant.providers.music_quiz.models import (
15 MultipleChoiceAnswer,
16 MultipleChoiceRoundState,
17 MultipleChoiceSuggestion,
18 MusicQuizAnswerType,
19 MusicQuizConfig,
20 MusicQuizPlayer,
21 MusicQuizRound,
22 TimelineAnswerResult,
23 TimelineBonusOption,
24 TimelineBonusResult,
25 TimelineBonusType,
26 TimelineCandidate,
27 TimelineChoiceBonusAnswer,
28 TimelineEntry,
29 TimelineFreeTextBonusDefinition,
30 TimelineMultipleChoiceBonusDefinition,
31 TimelinePlacementAnswer,
32 TimelinePlacementResult,
33 TimelineRoundState,
34)
35
36
37def test_config_defaults_and_round_trips_similar_music() -> None:
38 """Preserve default and explicit similar-music settings in persisted config."""
39 default_config = MusicQuizConfig.from_dict({})
40 enabled_config = MusicQuizConfig(include_similar_music=True)
41
42 assert default_config.include_similar_music is False
43 assert MusicQuizConfig().to_dict()["include_similar_music"] is False
44 assert MusicQuizConfig.from_dict(enabled_config.to_dict()) == enabled_config
45
46
47def test_config_round_trips_game_playback_selection() -> None:
48 """Preserve the effective playback selection with persisted game settings."""
49 config = MusicQuizConfig(
50 playback_mode=SharedPlaybackMode.VENUE,
51 venue_player_id="living_room",
52 venue_player_name="Living Room",
53 )
54
55 serialized = config.to_dict()
56
57 assert serialized["playback_mode"] == "venue"
58 assert serialized["venue_player_id"] == "living_room"
59 assert serialized["venue_player_name"] == "Living Room"
60 assert MusicQuizConfig.from_dict(serialized) == config
61 assert MusicQuizConfig.from_dict({}).playback_mode == SharedPlaybackMode.VENUE
62
63
64def test_round_answer_state_round_trips_with_discriminator() -> None:
65 """Round-trip strict multiple-choice state through the common round model."""
66 game_round = MusicQuizRound(
67 round_index=0,
68 answer_label="Daft Punk - One More Time",
69 answer_state=MultipleChoiceRoundState(
70 suggestions=[
71 MultipleChoiceSuggestion(
72 suggestion_id="correct",
73 label="Daft Punk - One More Time",
74 uri="library://track/1",
75 is_correct=True,
76 )
77 ],
78 answers={
79 "p1": MultipleChoiceAnswer(
80 player_id="p1",
81 suggestion_id="correct",
82 answered_at=12.0,
83 is_correct=True,
84 points=1000,
85 )
86 },
87 ),
88 track_uri="library://track/1",
89 image_url="https://example.test/artwork.jpg",
90 duration=180.0,
91 started_at=10.0,
92 ended_at=12.0,
93 auto_advance_at=42.0,
94 )
95
96 serialized = game_round.to_dict()
97 restored = MusicQuizRound.from_dict(serialized)
98
99 assert serialized == {
100 "round_index": 0,
101 "answer_label": "Daft Punk - One More Time",
102 "answer_state": {
103 "answer_type": "multiple_choice",
104 "suggestions": [
105 {
106 "suggestion_id": "correct",
107 "label": "Daft Punk - One More Time",
108 "uri": "library://track/1",
109 "is_correct": True,
110 }
111 ],
112 "answers": {
113 "p1": {
114 "player_id": "p1",
115 "suggestion_id": "correct",
116 "answered_at": 12.0,
117 "is_correct": True,
118 "points": 1000,
119 }
120 },
121 },
122 "track_uri": "library://track/1",
123 "question": None,
124 "image_url": "https://example.test/artwork.jpg",
125 "duration": 180.0,
126 "started_at": 10.0,
127 "audio_started_at": None,
128 "ended_at": 12.0,
129 "auto_advance_at": 42.0,
130 }
131 assert restored == game_round
132 assert isinstance(restored.answer_state, MultipleChoiceRoundState)
133 assert restored.answer_state.answer_type is MusicQuizAnswerType.MULTIPLE_CHOICE
134
135
136def test_player_presence_is_not_serialized() -> None:
137 """Keep internal presence timestamps out of serialized player state."""
138 player = MusicQuizPlayer(
139 player_id="private",
140 name="Alice",
141 joined_at=10.0,
142 active_from_round=0,
143 last_seen=20.0,
144 )
145
146 assert player.to_dict() == {
147 "player_id": "private",
148 "name": "Alice",
149 "joined_at": 10.0,
150 "active_from_round": 0,
151 "score": 0,
152 "ready": False,
153 }
154
155
156@pytest.mark.parametrize(
157 "answer_state",
158 [
159 {
160 "suggestions": [],
161 "answers": {},
162 },
163 {
164 "answer_type": "timeline",
165 "suggestions": [],
166 "answers": {},
167 },
168 {
169 "answer_type": "multiple_choice",
170 "suggestions": [],
171 "answers": {},
172 "extra": True,
173 },
174 {
175 "answer_type": "multiple_choice",
176 "suggestions": [
177 {
178 "suggestion_id": "correct",
179 "label": "Correct",
180 "uri": None,
181 "is_correct": True,
182 "extra": True,
183 }
184 ],
185 "answers": {},
186 },
187 {
188 "answer_type": "multiple_choice",
189 "suggestions": [],
190 "answers": {
191 "p1": {
192 "player_id": "p1",
193 "suggestion_id": "correct",
194 "answered_at": 12.0,
195 "is_correct": True,
196 "points": 1000,
197 "extra": True,
198 }
199 },
200 },
201 ],
202)
203def test_round_answer_state_rejects_invalid_nested_data(
204 answer_state: dict[str, object],
205) -> None:
206 """Reject missing, unknown, and extra nested answer-state data."""
207 with pytest.raises(InvalidFieldValue):
208 MusicQuizRound.from_dict(
209 {
210 "round_index": 0,
211 "answer_label": "Correct",
212 "answer_state": answer_state,
213 }
214 )
215
216
217def test_round_rejects_extra_common_data() -> None:
218 """Reject unknown fields on the common persisted round."""
219 with pytest.raises(ExtraKeysError):
220 MusicQuizRound.from_dict(
221 {
222 "round_index": 0,
223 "answer_label": "Correct",
224 "answer_state": {
225 "answer_type": "multiple_choice",
226 "suggestions": [],
227 "answers": {},
228 },
229 "extra": True,
230 }
231 )
232
233
234def test_timeline_round_state_round_trips_with_strict_nested_variants() -> None:
235 """Round-trip timeline state with typed bonus definitions, answers and results."""
236 state = TimelineRoundState(
237 placement_snapshot=[
238 TimelineEntry(
239 entry_id="anchor",
240 release_year=1990,
241 title="Anchor",
242 artist="Artist",
243 track_uri="library://track/anchor",
244 image_url=None,
245 is_anchor=True,
246 )
247 ],
248 candidate=TimelineCandidate(
249 entry=TimelineEntry(
250 entry_id="current",
251 release_year=2000,
252 title="Current",
253 artist="Artist",
254 track_uri="library://track/current",
255 image_url="https://img/current",
256 ),
257 artist_answers=["Artist", "Artist Alias"],
258 title_answers=["Current"],
259 ),
260 bonus_definitions=[
261 TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST),
262 TimelineMultipleChoiceBonusDefinition(
263 bonus_type=TimelineBonusType.TITLE,
264 options=[
265 TimelineBonusOption("correct", "Current", True),
266 TimelineBonusOption("wrong-a", "Wrong A"),
267 TimelineBonusOption("wrong-b", "Wrong B"),
268 TimelineBonusOption("wrong-c", "Wrong C"),
269 ],
270 ),
271 ],
272 placements={
273 "p1": TimelinePlacementAnswer(
274 previous_entry_id="anchor",
275 next_entry_id=None,
276 answered_at=12,
277 )
278 },
279 bonus_answers={
280 "p1": [
281 TimelineChoiceBonusAnswer(
282 bonus_type=TimelineBonusType.TITLE,
283 submitted_at=13,
284 option_id="correct",
285 )
286 ]
287 },
288 finished_at={"p1": 14},
289 results={
290 "p1": TimelineAnswerResult(
291 placement=TimelinePlacementResult("anchor", None, True, 1000),
292 bonuses=[TimelineBonusResult(TimelineBonusType.TITLE, True, 250)],
293 )
294 },
295 revealed=True,
296 )
297 game_round = MusicQuizRound(
298 round_index=0,
299 answer_label="Artist - Current",
300 answer_state=state,
301 track_uri="library://track/current",
302 )
303
304 restored = MusicQuizRound.from_dict(game_round.to_dict())
305
306 assert restored == game_round
307 assert isinstance(restored.answer_state, TimelineRoundState)
308 assert isinstance(restored.answer_state.candidate, TimelineCandidate)
309 assert isinstance(
310 restored.answer_state.bonus_definitions[0],
311 TimelineFreeTextBonusDefinition,
312 )
313 assert isinstance(
314 restored.answer_state.bonus_definitions[1],
315 TimelineMultipleChoiceBonusDefinition,
316 )
317 assert isinstance(restored.answer_state.bonus_answers["p1"][0], TimelineChoiceBonusAnswer)
318
319
320@pytest.mark.parametrize(
321 "field_path",
322 [
323 ("placement_snapshot", 0),
324 ("candidate", None),
325 ("candidate_entry", None),
326 ("bonus_definitions", 0),
327 ("bonus_answers", 0),
328 ("results", None),
329 ],
330)
331def test_timeline_round_state_rejects_extra_nested_data(
332 field_path: tuple[str, int | None],
333) -> None:
334 """Reject extra keys in every nested timeline model family."""
335 state = TimelineRoundState(
336 placement_snapshot=[
337 TimelineEntry(
338 "anchor",
339 1990,
340 "Anchor",
341 "Artist",
342 "library://track/anchor",
343 None,
344 True,
345 )
346 ],
347 candidate=TimelineCandidate(
348 entry=TimelineEntry(
349 "current",
350 2000,
351 "Current",
352 "Artist",
353 "library://track/current",
354 None,
355 ),
356 artist_answers=["Artist"],
357 title_answers=["Current"],
358 ),
359 bonus_definitions=[TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST)],
360 bonus_answers={
361 "p1": [
362 TimelineChoiceBonusAnswer(
363 bonus_type=TimelineBonusType.TITLE,
364 submitted_at=13,
365 option_id="correct",
366 )
367 ]
368 },
369 results={
370 "p1": TimelineAnswerResult(
371 placement=TimelinePlacementResult("anchor", None, True, 1000)
372 )
373 },
374 ).to_dict()
375 field_name, index = field_path
376 target: dict[str, object]
377 if field_name == "placement_snapshot":
378 target = state[field_name][index]
379 elif field_name == "candidate":
380 target = state[field_name]
381 elif field_name == "candidate_entry":
382 target = state["candidate"]["entry"]
383 elif field_name == "bonus_definitions":
384 target = state[field_name][index]
385 elif field_name == "bonus_answers":
386 target = state[field_name]["p1"][index]
387 else:
388 target = state[field_name]["p1"]["placement"]
389 target["extra"] = True
390
391 with pytest.raises(InvalidFieldValue):
392 MusicQuizRound.from_dict(
393 {
394 "round_index": 0,
395 "answer_label": "Artist - Current",
396 "answer_state": state,
397 }
398 )
399
400
401def test_timeline_round_state_rejects_extra_top_level_data() -> None:
402 """Reject unknown fields on the discriminated timeline round state."""
403 state = TimelineRoundState(
404 placement_snapshot=[
405 TimelineEntry(
406 "anchor",
407 1990,
408 "Anchor",
409 "Artist",
410 "library://track/anchor",
411 None,
412 True,
413 )
414 ],
415 candidate=TimelineCandidate(
416 entry=TimelineEntry(
417 "current",
418 2000,
419 "Current",
420 "Artist",
421 "library://track/current",
422 None,
423 ),
424 artist_answers=["Artist"],
425 title_answers=["Current"],
426 ),
427 ).to_dict()
428 state["extra"] = True
429
430 with pytest.raises(InvalidFieldValue):
431 MusicQuizRound.from_dict(
432 {
433 "round_index": 0,
434 "answer_label": "Artist - Current",
435 "answer_state": state,
436 }
437 )
438
439
440def test_round_deserializes_without_importing_answer_strategy() -> None:
441 """Deserialize the model module without answer-strategy registration."""
442 models_path = Path(inspect.getfile(MusicQuizRound))
443 script = f"""
444import importlib.util
445import sys
446
447module_name = "_music_quiz_models_clean_import"
448spec = importlib.util.spec_from_file_location(module_name, {str(models_path)!r})
449assert spec is not None and spec.loader is not None
450module = importlib.util.module_from_spec(spec)
451sys.modules[module_name] = module
452spec.loader.exec_module(module)
453assert "music_assistant.providers.music_quiz.answer_types.multiple_choice" not in sys.modules
454game_round = module.MusicQuizRound.from_dict(
455 {{
456 "round_index": 0,
457 "answer_label": "Correct",
458 "answer_state": {{
459 "answer_type": "multiple_choice",
460 "suggestions": [],
461 "answers": {{}},
462 }},
463 }}
464)
465assert isinstance(game_round.answer_state, module.MultipleChoiceRoundState)
466"""
467
468 subprocess.run( # noqa: S603
469 [sys.executable, "-c", script],
470 check=True,
471 capture_output=True,
472 text=True,
473 )
474