/
/
/
1"""Tests for the Music Quiz multiple-choice answer strategy."""
2
3from __future__ import annotations
4
5import pytest
6from music_assistant_models.errors import InvalidDataError
7
8from music_assistant.providers.music_quiz.answer_types.multiple_choice import (
9 MultipleChoiceAnswerType,
10 MultipleChoiceSubmission,
11)
12from music_assistant.providers.music_quiz.errors import MusicQuizInvalidAnswerError
13from music_assistant.providers.music_quiz.models import (
14 MultipleChoiceAnswer,
15 MultipleChoiceRoundState,
16 MultipleChoiceSuggestion,
17 MusicQuizAnswerType,
18 QuizRoundAnswerState,
19)
20
21ANSWER_TYPE = MultipleChoiceAnswerType()
22
23
24def _state() -> MultipleChoiceRoundState:
25 """Return multiple-choice round state."""
26 return MultipleChoiceRoundState(
27 suggestions=[
28 MultipleChoiceSuggestion(
29 suggestion_id="correct",
30 label="Daft Punk - One More Time",
31 is_correct=True,
32 ),
33 MultipleChoiceSuggestion(
34 suggestion_id="wrong",
35 label="Justice - D.A.N.C.E.",
36 ),
37 ],
38 )
39
40
41def test_parse_submission_returns_typed_request() -> None:
42 """Parse the strict wire payload into a typed multiple-choice submission."""
43 submission = ANSWER_TYPE.parse_submission(
44 {
45 "answer_type": "multiple_choice",
46 "suggestion_id": "correct",
47 }
48 )
49
50 assert submission == MultipleChoiceSubmission(suggestion_id="correct")
51
52
53@pytest.mark.parametrize(
54 "payload",
55 [
56 {"suggestion_id": "correct"},
57 {"answer_type": "multiple_choice"},
58 {"answer_type": "timeline", "suggestion_id": "correct"},
59 {"answer_type": 1, "suggestion_id": "correct"},
60 {"answer_type": "multiple_choice", "suggestion_id": 1},
61 {"answer_type": "multiple_choice", "suggestion_id": ""},
62 {
63 "answer_type": "multiple_choice",
64 "suggestion_id": "correct",
65 "extra": True,
66 },
67 ],
68)
69def test_parse_submission_rejects_malformed_payload(payload: dict[str, object]) -> None:
70 """Reject missing, mismatched, incorrectly typed and extra fields."""
71 with pytest.raises(MusicQuizInvalidAnswerError):
72 ANSWER_TYPE.parse_submission(payload)
73
74
75def test_round_serialization_redacts_answer_until_reveal() -> None:
76 """Expose suggestion choices without revealing which one is correct."""
77 state = _state()
78
79 hidden = ANSWER_TYPE.serialize_round(state, revealed=False)
80 revealed = ANSWER_TYPE.serialize_round(state, revealed=True)
81
82 assert hidden == {
83 "suggestions": [
84 {"suggestion_id": "correct", "label": "Daft Punk - One More Time"},
85 {"suggestion_id": "wrong", "label": "Justice - D.A.N.C.E."},
86 ]
87 }
88 assert "correct_suggestion_id" not in hidden
89 assert revealed["correct_suggestion_id"] == "correct"
90 assert "answer_label" not in revealed
91
92
93def test_player_serialization_separates_public_and_personal_state() -> None:
94 """Keep a locked answer private and its correctness hidden before reveal."""
95 state = _state()
96 state.answers["p1"] = MultipleChoiceAnswer(
97 player_id="p1",
98 suggestion_id="correct",
99 answered_at=12,
100 is_correct=True,
101 points=1000,
102 )
103
104 assert ANSWER_TYPE.serialize_public_player(state, "p1", revealed=False) == {"answered": True}
105 assert ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=False) == {
106 "answer": {
107 "suggestion_id": "correct",
108 "answered_at": 12,
109 }
110 }
111 assert ANSWER_TYPE.serialize_public_player(state, "p1", revealed=True) == {
112 "answered": True,
113 "last_answer": {
114 "suggestion_id": "correct",
115 "correct": True,
116 "points": 1000,
117 },
118 }
119 assert ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=True) == {
120 "answer": {
121 "suggestion_id": "correct",
122 "answered_at": 12,
123 "correct": True,
124 "points": 1000,
125 }
126 }
127
128
129def test_remove_player_discards_multiple_choice_answer() -> None:
130 """Remove all multiple-choice state owned by a departed player."""
131 state = _state()
132 state.answers["p1"] = MultipleChoiceAnswer(
133 player_id="p1",
134 suggestion_id="correct",
135 answered_at=12,
136 is_correct=True,
137 )
138
139 ANSWER_TYPE.remove_player(state, "p1")
140 ANSWER_TYPE.remove_player(state, "missing")
141
142 assert state.answers == {}
143
144
145def test_host_serialization_preserves_full_flat_answer_state() -> None:
146 """Serialize full multiple-choice state for the host round payload."""
147 state = _state()
148 state.answers["p1"] = MultipleChoiceAnswer(
149 player_id="p1",
150 suggestion_id="correct",
151 answered_at=12,
152 is_correct=True,
153 points=1000,
154 )
155
156 assert ANSWER_TYPE.serialize_host_round(state) == {
157 "suggestions": [
158 {
159 "suggestion_id": "correct",
160 "label": "Daft Punk - One More Time",
161 "uri": None,
162 "is_correct": True,
163 },
164 {
165 "suggestion_id": "wrong",
166 "label": "Justice - D.A.N.C.E.",
167 "uri": None,
168 "is_correct": False,
169 },
170 ],
171 "answers": {
172 "p1": {
173 "player_id": "p1",
174 "suggestion_id": "correct",
175 "answered_at": 12,
176 "is_correct": True,
177 "points": 1000,
178 }
179 },
180 }
181
182
183def test_strategy_rejects_matching_discriminator_on_wrong_state_class() -> None:
184 """Reject state whose discriminator matches but concrete model does not."""
185 state = QuizRoundAnswerState(answer_type=MusicQuizAnswerType.MULTIPLE_CHOICE)
186
187 with pytest.raises(InvalidDataError, match="does not match"):
188 ANSWER_TYPE.serialize_round(state, revealed=False)
189