/
/
/
1"""Tests for Music Quiz game state helpers."""
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 (
13 MusicQuizAlreadyAnsweredError,
14 MusicQuizNameTakenError,
15 MusicQuizWrongPhaseError,
16)
17from music_assistant.providers.music_quiz.game import (
18 active_players_for_round,
19 add_player,
20 all_active_players_complete,
21 are_active_players_ready,
22 finish_game,
23 reset_game,
24 reveal_round,
25 start_round,
26 submit_answer,
27)
28from music_assistant.providers.music_quiz.models import (
29 MultipleChoiceRoundState,
30 MultipleChoiceSuggestion,
31 MusicQuizAnswerType,
32 MusicQuizConfig,
33 MusicQuizGame,
34 MusicQuizPhase,
35 MusicQuizPlayer,
36 MusicQuizRound,
37)
38
39ANSWER_TYPE = MultipleChoiceAnswerType()
40
41
42def _player(player_id: str, name: str, active_from_round: int = 0) -> MusicQuizPlayer:
43 """Return a test player."""
44 return MusicQuizPlayer(
45 player_id=player_id,
46 name=name,
47 joined_at=1,
48 active_from_round=active_from_round,
49 )
50
51
52def _game() -> MusicQuizGame:
53 """Return a test game with one active round."""
54 return MusicQuizGame(
55 config=MusicQuizConfig(),
56 quiz_type="guess_the_song",
57 answer_type=MusicQuizAnswerType.MULTIPLE_CHOICE,
58 phase=MusicQuizPhase.ANSWERING,
59 current_round_index=0,
60 rounds=[
61 MusicQuizRound(
62 round_index=0,
63 track_uri="library://track/1",
64 answer_label="Daft Punk - One More Time",
65 answer_state=MultipleChoiceRoundState(
66 suggestions=[
67 MultipleChoiceSuggestion(
68 suggestion_id="correct",
69 label="Daft Punk - One More Time",
70 is_correct=True,
71 ),
72 MultipleChoiceSuggestion(
73 suggestion_id="wrong_1",
74 label="Justice - D.A.N.C.E.",
75 ),
76 ]
77 ),
78 )
79 ],
80 )
81
82
83def test_add_player_rejects_duplicate_name_case_insensitive() -> None:
84 """Reject duplicate player names case-insensitively."""
85 game = _game()
86 add_player(game, _player("p1", "Alice"))
87
88 with pytest.raises(MusicQuizNameTakenError, match="unique"):
89 add_player(game, _player("p2", "alice"))
90
91
92def test_add_player_rejects_duplicate_player_id() -> None:
93 """Never silently overwrite an existing player on an ID collision."""
94 game = _game()
95 add_player(game, _player("p1", "Alice"))
96
97 with pytest.raises(InvalidDataError, match="already exists"):
98 add_player(game, _player("p1", "Bob"))
99
100 assert game.players["p1"].name == "Alice"
101
102
103def test_start_round_requires_exactly_one_correct_suggestion() -> None:
104 """A round with zero or multiple correct suggestions is unplayable."""
105 game = _game()
106 game.phase = MusicQuizPhase.LOBBY
107 game.rounds.clear()
108 game.current_round_index = None
109
110 def _round(*correct_flags: bool) -> MusicQuizRound:
111 return MusicQuizRound(
112 round_index=0,
113 track_uri="library://track/1",
114 answer_label="Daft Punk - One More Time",
115 answer_state=MultipleChoiceRoundState(
116 suggestions=[
117 MultipleChoiceSuggestion(
118 suggestion_id=f"s{index}",
119 label=f"Suggestion {index}",
120 is_correct=flag,
121 )
122 for index, flag in enumerate(correct_flags)
123 ]
124 ),
125 )
126
127 with pytest.raises(InvalidDataError, match="exactly one correct"):
128 start_round(game, _round(True, True, False, False), 1, ANSWER_TYPE)
129 with pytest.raises(InvalidDataError, match="exactly one correct"):
130 start_round(game, _round(False, False, False, False), 1, ANSWER_TYPE)
131
132 start_round(game, _round(True, False, False, False), 1, ANSWER_TYPE)
133 assert game.phase == MusicQuizPhase.ANSWERING
134
135
136def test_finish_game_requires_reveal_phase() -> None:
137 """A game can only finish from the reveal phase."""
138 game = _game()
139
140 with pytest.raises(InvalidDataError, match="only be finished"):
141 finish_game(game)
142
143 game.phase = MusicQuizPhase.REVEAL
144 finish_game(game)
145 assert game.phase == MusicQuizPhase.FINISHED
146
147
148def test_ready_gate_includes_players_joining_next_round() -> None:
149 """During reveal the upcoming round belongs to late joiners too."""
150 game = _game()
151 add_player(game, _player("p1", "Alice"))
152 reveal_round(game, ANSWER_TYPE)
153 add_player(game, _player("p2", "Bob", active_from_round=1))
154
155 game.players["p1"].ready = True
156 assert are_active_players_ready(game) is False
157
158 game.players["p2"].ready = True
159 assert are_active_players_ready(game) is True
160
161
162def test_ready_gate_on_final_round_ignores_spectators_of_a_round_that_never_comes() -> None:
163 """A player joining during the final reveal must not block the game finish."""
164 game = _game()
165 game.config.round_count = 1
166 add_player(game, _player("p1", "Alice"))
167 reveal_round(game, ANSWER_TYPE)
168 # joins during the final reveal: their "first active round" will never play
169 add_player(game, _player("p2", "Bob", active_from_round=1))
170
171 game.players["p1"].ready = True
172 assert are_active_players_ready(game) is True
173
174
175def test_submit_answer_locks_first_answer() -> None:
176 """Reject a second answer from the same player."""
177 game = _game()
178 add_player(game, _player("p1", "Alice"))
179
180 submit_answer(
181 game,
182 "p1",
183 MultipleChoiceSubmission(suggestion_id="wrong_1"),
184 10,
185 ANSWER_TYPE,
186 )
187 answer = _answer_state(game.rounds[0]).answers["p1"]
188
189 assert answer.suggestion_id == "wrong_1"
190 assert answer.is_correct is False
191 with pytest.raises(MusicQuizAlreadyAnsweredError, match="already answered"):
192 submit_answer(
193 game,
194 "p1",
195 MultipleChoiceSubmission(suggestion_id="correct"),
196 11,
197 ANSWER_TYPE,
198 )
199
200
201def test_submit_answer_rejects_unknown_suggestion() -> None:
202 """Reject answers for suggestions that are not in the current round."""
203 game = _game()
204 add_player(game, _player("p1", "Alice"))
205
206 with pytest.raises(InvalidDataError, match="Unknown suggestion"):
207 submit_answer(
208 game,
209 "p1",
210 MultipleChoiceSubmission(suggestion_id="missing"),
211 10,
212 ANSWER_TYPE,
213 )
214
215
216def test_submit_answer_rejects_outside_answering_phase() -> None:
217 """Only accept answers during the answering phase."""
218 game = _game()
219 game.phase = MusicQuizPhase.REVEAL
220 add_player(game, _player("p1", "Alice"))
221
222 with pytest.raises(MusicQuizWrongPhaseError, match="answering phase"):
223 submit_answer(
224 game,
225 "p1",
226 MultipleChoiceSubmission(suggestion_id="correct"),
227 10,
228 ANSWER_TYPE,
229 )
230
231
232def test_all_active_players_complete_tracks_current_round() -> None:
233 """Report completion only when every active player locked an answer."""
234 game = _game()
235 add_player(game, _player("p1", "Alice"))
236 add_player(game, _player("p2", "Bob"))
237 add_player(game, _player("p3", "Late", active_from_round=1))
238
239 assert all_active_players_complete(game, ANSWER_TYPE) is False
240 submit_answer(
241 game,
242 "p1",
243 MultipleChoiceSubmission(suggestion_id="correct"),
244 10,
245 ANSWER_TYPE,
246 )
247 assert all_active_players_complete(game, ANSWER_TYPE) is False
248 # the late joiner is not active this round and must not block completion
249 submit_answer(
250 game,
251 "p2",
252 MultipleChoiceSubmission(suggestion_id="wrong_1"),
253 11,
254 ANSWER_TYPE,
255 )
256 assert all_active_players_complete(game, ANSWER_TYPE) is True
257
258
259def test_all_active_players_complete_when_only_future_players_remain() -> None:
260 """Allow a round to reveal when participants remain but none can answer it."""
261 game = _game()
262 add_player(game, _player("late", "Late", active_from_round=1))
263
264 assert all_active_players_complete(game, ANSWER_TYPE) is True
265
266 game.players.clear()
267 assert all_active_players_complete(game, ANSWER_TYPE) is False
268
269
270def test_reveal_round_applies_scores_to_correct_answers_in_order() -> None:
271 """Apply linear scores when a round is revealed."""
272 game = _game()
273 add_player(game, _player("p1", "Alice"))
274 add_player(game, _player("p2", "Bob"))
275 add_player(game, _player("p3", "Charlie"))
276 submit_answer(
277 game,
278 "p2",
279 MultipleChoiceSubmission(suggestion_id="correct"),
280 10,
281 ANSWER_TYPE,
282 )
283 submit_answer(
284 game,
285 "p1",
286 MultipleChoiceSubmission(suggestion_id="wrong_1"),
287 11,
288 ANSWER_TYPE,
289 )
290 submit_answer(
291 game,
292 "p3",
293 MultipleChoiceSubmission(suggestion_id="correct"),
294 12,
295 ANSWER_TYPE,
296 )
297
298 reveal_round(game, ANSWER_TYPE)
299
300 current_round = game.rounds[0]
301 answers = _answer_state(current_round).answers
302 assert game.phase == MusicQuizPhase.REVEAL
303 assert answers["p2"].points == 1000
304 assert answers["p3"].points == 500
305 assert answers["p1"].points == 0
306 assert current_round.ended_at == 12
307 assert game.players["p2"].score == 1000
308 assert game.players["p3"].score == 500
309 assert game.players["p1"].score == 0
310
311
312def test_active_players_for_round_excludes_late_joiners_until_next_round() -> None:
313 """Only include late joiners from their active round onward."""
314 game = _game()
315 add_player(game, _player("p1", "Alice", active_from_round=0))
316 add_player(game, _player("p2", "Bob", active_from_round=1))
317
318 assert [player.player_id for player in active_players_for_round(game, 0)] == ["p1"]
319 assert [player.player_id for player in active_players_for_round(game, 1)] == [
320 "p1",
321 "p2",
322 ]
323
324
325def test_reset_game_keeps_players_and_config_for_new_game() -> None:
326 """Reset transient state while preserving game settings and identity."""
327 game = _game()
328 add_player(game, _player("p1", "Alice", active_from_round=0))
329 add_player(game, _player("p2", "Bob", active_from_round=1))
330 game.players["p1"].score = 1000
331 game.players["p1"].ready = True
332 game.players["p2"].score = 500
333 game.players["p2"].ready = True
334 game.auto_start_at = 30
335
336 reset_game(game)
337
338 assert game.phase == MusicQuizPhase.LOBBY
339 assert game.to_dict()["auto_start_at"] is None
340 assert game.quiz_type == "guess_the_song"
341 assert game.answer_type == MusicQuizAnswerType.MULTIPLE_CHOICE
342 assert game.current_round_index is None
343 assert game.rounds == []
344 assert {player.name for player in game.players.values()} == {"Alice", "Bob"}
345 assert all(player.score == 0 for player in game.players.values())
346 assert all(player.ready is False for player in game.players.values())
347 assert all(player.active_from_round == 0 for player in game.players.values())
348
349
350def test_reveal_round_uses_start_time_when_no_answers_exist() -> None:
351 """Use the common round start time when an answer type has no timestamp."""
352 game = _game()
353 game.rounds[0].started_at = 5
354
355 reveal_round(game, ANSWER_TYPE)
356
357 assert game.rounds[0].ended_at == 5
358
359
360def _answer_state(game_round: MusicQuizRound) -> MultipleChoiceRoundState:
361 """Return multiple-choice state from a test round."""
362 assert isinstance(game_round.answer_state, MultipleChoiceRoundState)
363 return game_round.answer_state
364