/
/
/
1"""Tests for the Music Quiz timeline answer strategy."""
2
3from __future__ import annotations
4
5from collections.abc import Iterable
6from typing import Any, cast
7
8import pytest
9from music_assistant_models.errors import InvalidDataError
10
11from music_assistant.providers.music_quiz.answer_types.timeline import (
12 MAX_BONUS_TEXT_LENGTH,
13 TimelineAnswerType,
14 TimelineBonusChoiceSubmission,
15 TimelineBonusTextSubmission,
16 TimelineFinishSubmission,
17 TimelinePlacementSubmission,
18)
19from music_assistant.providers.music_quiz.errors import (
20 MusicQuizAlreadyAnsweredError,
21 MusicQuizInvalidAnswerError,
22 MusicQuizUnknownSuggestionError,
23)
24from music_assistant.providers.music_quiz.models import (
25 MusicQuizAnswerType,
26 MusicQuizConfig,
27 MusicQuizGame,
28 MusicQuizPhase,
29 MusicQuizPlayer,
30 MusicQuizRound,
31 QuizRoundAnswerState,
32 TimelineBonusDefinition,
33 TimelineBonusMode,
34 TimelineBonusOption,
35 TimelineBonusType,
36 TimelineCandidate,
37 TimelineEntry,
38 TimelineFreeTextBonusDefinition,
39 TimelineMultipleChoiceBonusDefinition,
40 TimelineRoundState,
41)
42
43ANSWER_TYPE = TimelineAnswerType()
44
45
46def _entry(entry_id: str, year: int, *, anchor: bool = False) -> TimelineEntry:
47 """Return a deterministic timeline entry."""
48 return TimelineEntry(
49 entry_id=entry_id,
50 release_year=year,
51 title=f"Title {entry_id}",
52 artist=f"Artist {entry_id}",
53 track_uri=f"library://track/{entry_id}",
54 image_url=f"https://img/{entry_id}",
55 is_anchor=anchor,
56 )
57
58
59def _state(
60 current_year: int = 2005,
61 *,
62 bonus_definitions: list[TimelineBonusDefinition] | None = None,
63 artist_answers: list[str] | None = None,
64 title_answers: list[str] | None = None,
65) -> TimelineRoundState:
66 """Return timeline state with an equal-year run."""
67 current = _entry("current-secret", current_year)
68 current.title = "Current Secret Title"
69 current.artist = "Current Secret Artist"
70 return TimelineRoundState(
71 placement_snapshot=[
72 _entry("anchor", 1990, anchor=True),
73 _entry("same-a", 2000),
74 _entry("same-b", 2000),
75 _entry("newer", 2010),
76 ],
77 candidate=TimelineCandidate(
78 entry=current,
79 artist_answers=artist_answers or [current.artist],
80 title_answers=title_answers or [current.title],
81 ),
82 bonus_definitions=list(bonus_definitions or []),
83 )
84
85
86def _game(
87 state: TimelineRoundState,
88 player_ids: Iterable[str] = ("p1",),
89 *,
90 artist_mode: TimelineBonusMode = TimelineBonusMode.OFF,
91 title_mode: TimelineBonusMode = TimelineBonusMode.OFF,
92) -> MusicQuizGame:
93 """Return an active timeline game."""
94 players = {
95 player_id: MusicQuizPlayer(
96 player_id=player_id,
97 name=f"Player {player_id}",
98 joined_at=float(index),
99 active_from_round=0,
100 )
101 for index, player_id in enumerate(player_ids)
102 }
103 return MusicQuizGame(
104 config=MusicQuizConfig(
105 round_count=1,
106 source_uris=["library://playlist/1"],
107 artist_bonus_mode=artist_mode,
108 title_bonus_mode=title_mode,
109 ),
110 quiz_type="music_timeline",
111 answer_type=MusicQuizAnswerType.TIMELINE,
112 phase=MusicQuizPhase.ANSWERING,
113 players=players,
114 rounds=[
115 MusicQuizRound(
116 round_index=0,
117 answer_label="Current Secret Artist - Current Secret Title",
118 answer_state=state,
119 track_uri=state.candidate.entry.track_uri,
120 )
121 ],
122 current_round_index=0,
123 )
124
125
126def _choice_definition(
127 bonus_type: TimelineBonusType = TimelineBonusType.TITLE,
128) -> TimelineMultipleChoiceBonusDefinition:
129 """Return a four-option timeline bonus definition."""
130 return TimelineMultipleChoiceBonusDefinition(
131 bonus_type=bonus_type,
132 options=[
133 TimelineBonusOption("correct-option", "Correct", True),
134 TimelineBonusOption("wrong-a", "Wrong A"),
135 TimelineBonusOption("wrong-b", "Wrong B"),
136 TimelineBonusOption("wrong-c", "Wrong C"),
137 ],
138 )
139
140
141def _correct_placement() -> TimelinePlacementSubmission:
142 """Return the correct boundary for the default current entry."""
143 return TimelinePlacementSubmission("same-b", "newer")
144
145
146def test_parse_submission_returns_strict_typed_actions() -> None:
147 """Parse every timeline wire variant into its concrete typed action."""
148 assert ANSWER_TYPE.parse_submission(
149 {
150 "answer_type": "timeline",
151 "action": "place",
152 "previous_entry_id": None,
153 "next_entry_id": "anchor",
154 }
155 ) == TimelinePlacementSubmission(None, "anchor")
156 assert ANSWER_TYPE.parse_submission(
157 {
158 "answer_type": "timeline",
159 "action": "bonus_text",
160 "bonus_type": "artist",
161 "value": " Beyoncé ",
162 }
163 ) == TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Beyoncé")
164 assert ANSWER_TYPE.parse_submission(
165 {
166 "answer_type": "timeline",
167 "action": "bonus_choice",
168 "bonus_type": "title",
169 "option_id": "opaque",
170 }
171 ) == TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "opaque")
172 assert (
173 ANSWER_TYPE.parse_submission({"answer_type": "timeline", "action": "finish"})
174 == TimelineFinishSubmission()
175 )
176
177
178@pytest.mark.parametrize(
179 "payload",
180 [
181 {"action": "finish"},
182 {"answer_type": "multiple_choice", "action": "finish"},
183 {"answer_type": "timeline"},
184 {"answer_type": "timeline", "action": "unknown"},
185 {"answer_type": "timeline", "action": "finish", "extra": True},
186 {
187 "answer_type": "timeline",
188 "action": "place",
189 "previous_entry_id": None,
190 },
191 {
192 "answer_type": "timeline",
193 "action": "place",
194 "previous_entry_id": 1,
195 "next_entry_id": "anchor",
196 },
197 {
198 "answer_type": "timeline",
199 "action": "bonus_text",
200 "bonus_type": "artist",
201 "value": " ",
202 },
203 {
204 "answer_type": "timeline",
205 "action": "bonus_text",
206 "bonus_type": "artist",
207 "value": "x" * (MAX_BONUS_TEXT_LENGTH + 1),
208 },
209 {
210 "answer_type": "timeline",
211 "action": "bonus_text",
212 "bonus_type": "year",
213 "value": "2000",
214 },
215 {
216 "answer_type": "timeline",
217 "action": "bonus_choice",
218 "bonus_type": "title",
219 "option_id": "",
220 },
221 {
222 "answer_type": "timeline",
223 "action": "bonus_choice",
224 "bonus_type": "title",
225 "option_id": "opaque",
226 "extra": True,
227 },
228 ],
229)
230def test_parse_submission_rejects_malformed_variants(payload: dict[str, object]) -> None:
231 """Reject wrong discriminators, key sets, types and bounded text violations."""
232 with pytest.raises(MusicQuizInvalidAnswerError):
233 ANSWER_TYPE.parse_submission(payload)
234
235
236def test_validate_round_rejects_invalid_timeline_and_bonus_state() -> None:
237 """Reject unsorted snapshots, duplicate IDs and mismatched bonus definitions."""
238 state = _state()
239 game = _game(state)
240 ANSWER_TYPE.validate_round(game, state)
241
242 state.placement_snapshot.reverse()
243 with pytest.raises(InvalidDataError, match="ordered"):
244 ANSWER_TYPE.validate_round(game, state)
245
246 state = _state()
247 state.candidate.entry.entry_id = "anchor"
248 with pytest.raises(InvalidDataError, match="unique"):
249 ANSWER_TYPE.validate_round(_game(state), state)
250
251 state = _state(
252 bonus_definitions=[
253 TimelineFreeTextBonusDefinition(
254 bonus_type=TimelineBonusType.ARTIST,
255 )
256 ]
257 )
258 with pytest.raises(InvalidDataError, match="definition"):
259 ANSWER_TYPE.validate_round(_game(state), state)
260
261 choice_definition = _choice_definition()
262 choice_definition.options[1].label = "Correct!"
263 state = _state(bonus_definitions=[choice_definition])
264 with pytest.raises(InvalidDataError, match="labels"):
265 ANSWER_TYPE.validate_round(
266 _game(state, title_mode=TimelineBonusMode.MULTIPLE_CHOICE),
267 state,
268 )
269
270 state = _state(
271 bonus_definitions=[
272 _choice_definition(),
273 TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST),
274 ]
275 )
276 with pytest.raises(InvalidDataError, match="configured order"):
277 ANSWER_TYPE.validate_round(
278 _game(
279 state,
280 artist_mode=TimelineBonusMode.FREE_TEXT,
281 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
282 ),
283 state,
284 )
285
286
287def test_all_adjacent_boundaries_are_valid_but_stale_boundaries_do_not_lock() -> None:
288 """Accept every adjacent snapshot boundary and reject stale or non-adjacent IDs."""
289 state = _state()
290 valid_boundaries = [
291 (None, "anchor"),
292 ("anchor", "same-a"),
293 ("same-a", "same-b"),
294 ("same-b", "newer"),
295 ("newer", None),
296 ]
297 game = _game(state, [f"p{index}" for index in range(len(valid_boundaries) + 5)])
298 for index, (previous_id, next_id) in enumerate(valid_boundaries):
299 ANSWER_TYPE.submit(
300 game,
301 state,
302 game.players[f"p{index}"],
303 TimelinePlacementSubmission(previous_id, next_id),
304 float(index),
305 )
306
307 for player_id, submission in (
308 ("p5", TimelinePlacementSubmission("missing", "anchor")),
309 ("p6", TimelinePlacementSubmission("anchor", "newer")),
310 ("p7", TimelinePlacementSubmission(None, None)),
311 ("p8", TimelinePlacementSubmission(None, "same-a")),
312 ("p9", TimelinePlacementSubmission("same-b", None)),
313 ):
314 with pytest.raises(MusicQuizInvalidAnswerError):
315 ANSWER_TYPE.submit(game, state, game.players[player_id], submission, 10)
316 assert player_id not in state.placements
317
318
319@pytest.mark.parametrize(
320 ("previous_id", "next_id"),
321 [
322 ("anchor", "same-a"),
323 ("same-a", "same-b"),
324 ("same-b", "newer"),
325 ],
326)
327def test_equal_year_accepts_every_boundary_around_the_equal_year_run(
328 previous_id: str,
329 next_id: str,
330) -> None:
331 """Treat every adjacent insertion point around equal-year entries as correct."""
332 state = _state(current_year=2000)
333 game = _game(state)
334 ANSWER_TYPE.submit(
335 game,
336 state,
337 game.players["p1"],
338 TimelinePlacementSubmission(previous_id, next_id),
339 1,
340 )
341
342 ANSWER_TYPE.reveal(game, state)
343
344 assert state.results["p1"].placement.is_correct is True
345 assert state.results["p1"].placement.points == 1000
346
347
348@pytest.mark.parametrize(
349 ("current_year", "previous_id", "next_id"),
350 [
351 (1980, None, "anchor"),
352 (2020, "newer", None),
353 ],
354)
355def test_chronological_boundary_ends_score_correctly(
356 current_year: int,
357 previous_id: str | None,
358 next_id: str | None,
359) -> None:
360 """Score songs older or newer than the entire snapshot at its boundary ends."""
361 state = _state(current_year=current_year)
362 game = _game(state)
363 ANSWER_TYPE.submit(
364 game,
365 state,
366 game.players["p1"],
367 TimelinePlacementSubmission(previous_id, next_id),
368 1,
369 )
370
371 ANSWER_TYPE.reveal(game, state)
372
373 assert state.results["p1"].placement.is_correct is True
374
375
376def test_reveal_always_inserts_entry_in_deterministic_order() -> None:
377 """Insert the revealed song by year and entry ID even when its placement is wrong."""
378 state = _state(current_year=2000)
379 state.candidate.entry.entry_id = "same-aa"
380 game = _game(state)
381 ANSWER_TYPE.submit(
382 game,
383 state,
384 game.players["p1"],
385 TimelinePlacementSubmission("newer", None),
386 1,
387 )
388
389 ANSWER_TYPE.reveal(game, state)
390 revealed = cast("dict[str, Any]", ANSWER_TYPE.serialize_round(state, revealed=True))
391 revealed_again = cast("dict[str, Any]", ANSWER_TYPE.serialize_round(state, revealed=True))
392
393 assert state.results["p1"].placement.is_correct is False
394 assert state.results["p1"].placement.points == 0
395 assert [entry["entry_id"] for entry in revealed["timeline"]] == [
396 "anchor",
397 "same-a",
398 "same-aa",
399 "same-b",
400 "newer",
401 ]
402 assert revealed["revealed_entry"]["entry_id"] == "same-aa"
403 assert revealed_again["timeline"] == revealed["timeline"]
404 assert sum(entry["entry_id"] == "same-aa" for entry in revealed["timeline"]) == 1
405 assert "same-aa" not in {entry.entry_id for entry in state.placement_snapshot}
406
407
408@pytest.mark.parametrize(
409 "placement",
410 [_correct_placement(), TimelinePlacementSubmission(None, "anchor")],
411 ids=["correct", "incorrect"],
412)
413def test_placement_locks_and_completes_immediately_without_bonuses(
414 placement: TimelinePlacementSubmission,
415) -> None:
416 """Lock any valid placement and complete immediately when bonuses are off."""
417 state = _state()
418 game = _game(state)
419 player = game.players["p1"]
420
421 ANSWER_TYPE.submit(game, state, player, placement, 12)
422
423 assert state.placements["p1"].answered_at == 12
424 assert ANSWER_TYPE.is_player_complete(state, "p1") is True
425 with pytest.raises(MusicQuizInvalidAnswerError, match="after finishing"):
426 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 13)
427
428
429def test_incorrect_placement_accepts_serializes_and_scores_bonuses() -> None:
430 """Offer and independently score every configured bonus after a wrong placement."""
431 definitions = [
432 TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST),
433 _choice_definition(),
434 ]
435 state = _state(bonus_definitions=definitions)
436 game = _game(
437 state,
438 artist_mode=TimelineBonusMode.FREE_TEXT,
439 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
440 )
441 player = game.players["p1"]
442 wrong_placement = TimelinePlacementSubmission(None, "anchor")
443 ANSWER_TYPE.submit(game, state, player, wrong_placement, 1)
444
445 personal = cast(
446 "dict[str, Any]",
447 ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=False),
448 )
449 assert ANSWER_TYPE.is_player_complete(state, "p1") is False
450 assert personal["answer"] == {
451 "previous_entry_id": None,
452 "next_entry_id": "anchor",
453 "answered_at": 1,
454 "bonuses": [],
455 "finished": False,
456 }
457
458 ANSWER_TYPE.submit(
459 game,
460 state,
461 player,
462 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Wrong Artist"),
463 2,
464 )
465 ANSWER_TYPE.submit(
466 game,
467 state,
468 player,
469 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "correct-option"),
470 3,
471 )
472
473 assert ANSWER_TYPE.serialize_public_player(state, "p1", revealed=False) == {
474 "answered": True,
475 "placed": True,
476 "artist_bonus_answered": True,
477 "title_bonus_answered": True,
478 }
479 ended_at = ANSWER_TYPE.reveal(game, state)
480
481 assert ended_at == 3
482 result = state.results["p1"]
483 assert result.placement.is_correct is False
484 assert result.placement.points == 0
485 assert [(bonus.bonus_type, bonus.is_correct, bonus.points) for bonus in result.bonuses] == [
486 (TimelineBonusType.ARTIST, False, 0),
487 (TimelineBonusType.TITLE, True, 250),
488 ]
489 assert player.score == 250
490 public = cast(
491 "dict[str, Any]",
492 ANSWER_TYPE.serialize_public_player(state, "p1", revealed=True),
493 )
494 assert public["last_answer"] == {
495 "placement": {
496 "previous_entry_id": None,
497 "next_entry_id": "anchor",
498 "correct": False,
499 "points": 0,
500 },
501 "artist": {"correct": False, "points": 0},
502 "title": {"correct": True, "points": 250},
503 }
504 personal = cast(
505 "dict[str, Any]",
506 ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=True),
507 )
508 assert personal["answer"]["correct"] is False
509 assert personal["answer"]["points"] == 0
510 assert personal["answer"]["bonus_results"] == [
511 {"bonus_type": "artist", "correct": False, "points": 0},
512 {"bonus_type": "title", "correct": True, "points": 250},
513 ]
514
515
516def test_final_configured_bonus_completes_at_submission_timestamp() -> None:
517 """Complete only when the final configured bonus is submitted."""
518 definitions = [
519 TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST),
520 _choice_definition(),
521 ]
522 state = _state(bonus_definitions=definitions)
523 game = _game(
524 state,
525 artist_mode=TimelineBonusMode.FREE_TEXT,
526 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
527 )
528 player = game.players["p1"]
529
530 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 1)
531 ANSWER_TYPE.submit(
532 game,
533 state,
534 player,
535 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Artist"),
536 2,
537 )
538 assert ANSWER_TYPE.is_player_complete(state, "p1") is False
539
540 ANSWER_TYPE.submit(
541 game,
542 state,
543 player,
544 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "correct-option"),
545 3,
546 )
547
548 assert state.finished_at["p1"] == 3
549
550
551def test_remove_player_clears_all_timeline_answer_state() -> None:
552 """Remove placement, bonus, finish and result state for an expired player."""
553 state = _state()
554 game = _game(state)
555 ANSWER_TYPE.submit(game, state, game.players["p1"], _correct_placement(), 1)
556 state.bonus_answers["p1"] = []
557 ANSWER_TYPE.reveal(game, state)
558
559 ANSWER_TYPE.remove_player(state, "p1")
560 ANSWER_TYPE.remove_player(state, "missing")
561
562 assert state.placements == {}
563 assert state.bonus_answers == {}
564 assert state.finished_at == {}
565 assert state.results == {}
566
567
568def test_removed_player_neither_blocks_completion_nor_scores() -> None:
569 """Exclude an expired player's locked placement, bonus and finish from reveal."""
570 state = _state(
571 bonus_definitions=[TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST)],
572 artist_answers=["Massive Attack"],
573 )
574 game = _game(
575 state,
576 ("departed", "active"),
577 artist_mode=TimelineBonusMode.FREE_TEXT,
578 )
579 for index, player in enumerate(game.players.values(), start=1):
580 ANSWER_TYPE.submit(game, state, player, _correct_placement(), index)
581 ANSWER_TYPE.submit(
582 game,
583 state,
584 player,
585 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Massive Attack"),
586 index + 2,
587 )
588 departed = game.players.pop("departed")
589
590 ANSWER_TYPE.remove_player(state, departed.player_id)
591
592 assert ANSWER_TYPE.is_round_complete(state, game.players.values()) is True
593 ANSWER_TYPE.reveal(game, state)
594 assert departed.score == 0
595 assert game.players["active"].score == 1250
596 assert "departed" not in state.placements
597 assert "departed" not in state.bonus_answers
598 assert "departed" not in state.finished_at
599 assert "departed" not in state.results
600
601
602def test_bonus_and_finish_rules_reject_invalid_action_order_and_modes() -> None:
603 """Enforce placement-first, mode matching, uniqueness, finish and post-finish locks."""
604 definitions = [
605 TimelineFreeTextBonusDefinition(
606 bonus_type=TimelineBonusType.ARTIST,
607 ),
608 _choice_definition(),
609 ]
610 state = _state(bonus_definitions=definitions, artist_answers=["Beyoncé"])
611 game = _game(
612 state,
613 artist_mode=TimelineBonusMode.FREE_TEXT,
614 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
615 )
616 player = game.players["p1"]
617
618 with pytest.raises(MusicQuizInvalidAnswerError, match="Place"):
619 ANSWER_TYPE.submit(
620 game,
621 state,
622 player,
623 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Beyoncé"),
624 1,
625 )
626 with pytest.raises(MusicQuizInvalidAnswerError, match="Place"):
627 ANSWER_TYPE.submit(game, state, player, TimelineFinishSubmission(), 1)
628
629 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 2)
630 assert ANSWER_TYPE.is_player_complete(state, "p1") is False
631 with pytest.raises(MusicQuizAlreadyAnsweredError):
632 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 3)
633 with pytest.raises(MusicQuizInvalidAnswerError, match="does not accept"):
634 ANSWER_TYPE.submit(
635 game,
636 state,
637 player,
638 TimelineBonusChoiceSubmission(TimelineBonusType.ARTIST, "correct-option"),
639 3,
640 )
641 with pytest.raises(MusicQuizInvalidAnswerError, match="artist bonus first"):
642 ANSWER_TYPE.submit(
643 game,
644 state,
645 player,
646 TimelineBonusTextSubmission(TimelineBonusType.TITLE, "Correct"),
647 3,
648 )
649 ANSWER_TYPE.submit(
650 game,
651 state,
652 player,
653 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Beyoncé"),
654 4,
655 )
656 with pytest.raises(MusicQuizUnknownSuggestionError):
657 ANSWER_TYPE.submit(
658 game,
659 state,
660 player,
661 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "unknown"),
662 5,
663 )
664 with pytest.raises(MusicQuizInvalidAnswerError, match="already answered"):
665 ANSWER_TYPE.submit(
666 game,
667 state,
668 player,
669 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Beyoncé"),
670 5,
671 )
672 ANSWER_TYPE.submit(
673 game,
674 state,
675 player,
676 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "correct-option"),
677 6,
678 )
679 assert ANSWER_TYPE.is_player_complete(state, "p1") is True
680 assert state.finished_at["p1"] == 6
681 for submission in (
682 TimelineFinishSubmission(),
683 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Beyoncé"),
684 _correct_placement(),
685 ):
686 with pytest.raises(MusicQuizInvalidAnswerError, match="after finishing"):
687 ANSWER_TYPE.submit(game, state, player, submission, 8)
688
689
690def test_explicit_finish_skips_remaining_bonuses() -> None:
691 """Keep finish as an explicit way to skip configured bonuses."""
692 definitions = [
693 TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST),
694 _choice_definition(),
695 ]
696 state = _state(bonus_definitions=definitions)
697 game = _game(
698 state,
699 artist_mode=TimelineBonusMode.FREE_TEXT,
700 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
701 )
702 player = game.players["p1"]
703
704 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 1)
705 ANSWER_TYPE.submit(
706 game,
707 state,
708 player,
709 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Artist"),
710 2,
711 )
712 ANSWER_TYPE.submit(game, state, player, TimelineFinishSubmission(), 3)
713
714 assert state.finished_at["p1"] == 3
715 assert len(state.bonus_answers["p1"]) == 1
716
717
718def test_artist_choice_and_title_text_modes_score_independently() -> None:
719 """Accept either action mode independently for artist and title bonuses."""
720 cases = [
721 (
722 _choice_definition(TimelineBonusType.ARTIST),
723 TimelineBonusChoiceSubmission(TimelineBonusType.ARTIST, "correct-option"),
724 TimelineBonusMode.MULTIPLE_CHOICE,
725 TimelineBonusMode.OFF,
726 ),
727 (
728 TimelineFreeTextBonusDefinition(
729 bonus_type=TimelineBonusType.TITLE,
730 ),
731 TimelineBonusTextSubmission(TimelineBonusType.TITLE, "Around the World"),
732 TimelineBonusMode.OFF,
733 TimelineBonusMode.FREE_TEXT,
734 ),
735 ]
736 for definition, submission, artist_mode, title_mode in cases:
737 state = _state(
738 bonus_definitions=[definition],
739 title_answers=(
740 ["Around the World (Radio Edit)"]
741 if definition.bonus_type == TimelineBonusType.TITLE
742 else None
743 ),
744 )
745 game = _game(
746 state,
747 artist_mode=artist_mode,
748 title_mode=title_mode,
749 )
750 player = game.players["p1"]
751 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 1)
752 assert ANSWER_TYPE.is_player_complete(state, "p1") is False
753 ANSWER_TYPE.submit(game, state, player, submission, 2)
754 assert state.finished_at["p1"] == 2
755
756 ANSWER_TYPE.reveal(game, state)
757
758 assert state.results["p1"].bonuses[0].points == 250
759
760
761def test_reveal_scores_speed_ranked_placements_and_fixed_bonuses() -> None:
762 """Rank only correct placements by speed and score bonuses independently."""
763 definitions = [
764 TimelineFreeTextBonusDefinition(
765 bonus_type=TimelineBonusType.ARTIST,
766 ),
767 _choice_definition(),
768 ]
769 state = _state(
770 bonus_definitions=definitions,
771 artist_answers=["Beyoncé feat. Jay-Z"],
772 )
773 game = _game(
774 state,
775 ("p1", "p2", "p3"),
776 artist_mode=TimelineBonusMode.FREE_TEXT,
777 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
778 )
779 for player_id, placement, answered_at in (
780 ("p1", _correct_placement(), 3),
781 ("p2", TimelinePlacementSubmission(None, "anchor"), 1),
782 ("p3", _correct_placement(), 2),
783 ):
784 ANSWER_TYPE.submit(game, state, game.players[player_id], placement, answered_at)
785 ANSWER_TYPE.submit(
786 game,
787 state,
788 game.players["p1"],
789 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "beyonce"),
790 4,
791 )
792 ANSWER_TYPE.submit(
793 game,
794 state,
795 game.players["p1"],
796 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "correct-option"),
797 5,
798 )
799 ANSWER_TYPE.submit(
800 game,
801 state,
802 game.players["p2"],
803 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "wrong"),
804 2,
805 )
806 ANSWER_TYPE.submit(
807 game,
808 state,
809 game.players["p2"],
810 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "correct-option"),
811 3,
812 )
813 ANSWER_TYPE.submit(game, state, game.players["p3"], TimelineFinishSubmission(), 6)
814
815 ended_at = ANSWER_TYPE.reveal(game, state)
816
817 assert ended_at == 6
818 assert state.results["p3"].placement.points == 1000
819 assert state.results["p1"].placement.points == 500
820 assert state.results["p2"].placement.points == 0
821 assert [result.points for result in state.results["p1"].bonuses] == [250, 250]
822 assert [result.points for result in state.results["p2"].bonuses] == [0, 250]
823 assert game.players["p1"].score == 1000
824 assert game.players["p2"].score == 250
825 assert game.players["p3"].score == 1000
826
827
828def test_deadline_reveal_scores_unfinished_submissions() -> None:
829 """Score a placement and bonus at reveal even when finish was not submitted."""
830 state = _state(
831 bonus_definitions=[
832 TimelineFreeTextBonusDefinition(
833 bonus_type=TimelineBonusType.ARTIST,
834 ),
835 _choice_definition(),
836 ],
837 artist_answers=["Massive Attack"],
838 )
839 game = _game(
840 state,
841 artist_mode=TimelineBonusMode.FREE_TEXT,
842 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
843 )
844 player = game.players["p1"]
845 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 1)
846 ANSWER_TYPE.submit(
847 game,
848 state,
849 player,
850 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "Massive Attack"),
851 2,
852 )
853
854 assert ANSWER_TYPE.is_player_complete(state, "p1") is False
855 ANSWER_TYPE.reveal(game, state)
856
857 assert state.results["p1"].placement.points == 1000
858 assert state.results["p1"].bonuses[0].points == 250
859 assert game.players["p1"].score == 1250
860 with pytest.raises(MusicQuizInvalidAnswerError, match="revealing"):
861 ANSWER_TYPE.submit(game, state, player, TimelineFinishSubmission(), 3)
862
863
864@pytest.mark.parametrize(
865 ("submitted_artist", "expected_points"),
866 [
867 ("Daft Punk feat. Pharrell Williams", 250),
868 ("Daft Punk", 250),
869 ("Pharrell Williams", 250),
870 ("Punk, Daft", 250),
871 ("Random Artist", 0),
872 ],
873)
874def test_artist_free_text_accepts_persisted_contributors_and_aliases(
875 submitted_artist: str,
876 expected_points: int,
877) -> None:
878 """Accept only artist truths justified by the persisted candidate metadata."""
879 state = _state(
880 bonus_definitions=[TimelineFreeTextBonusDefinition(bonus_type=TimelineBonusType.ARTIST)],
881 artist_answers=[
882 "Daft Punk feat. Pharrell Williams",
883 "Daft Punk",
884 "Pharrell Williams",
885 "Punk, Daft",
886 ],
887 )
888 state.candidate.entry.artist = "Daft Punk feat. Pharrell Williams"
889 game = _game(state, artist_mode=TimelineBonusMode.FREE_TEXT)
890 player = game.players["p1"]
891 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 1)
892 ANSWER_TYPE.submit(
893 game,
894 state,
895 player,
896 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, submitted_artist),
897 2,
898 )
899
900 ANSWER_TYPE.reveal(game, state)
901
902 assert state.results["p1"].bonuses[0].points == expected_points
903 assert state.candidate.entry.artist == "Daft Punk feat. Pharrell Williams"
904
905
906def test_reveal_rejects_progress_without_a_locked_placement() -> None:
907 """Reject corrupted finish or bonus progress that has no placement."""
908 state = _state()
909 state.finished_at["p1"] = 1
910
911 with pytest.raises(InvalidDataError, match="locked placement"):
912 ANSWER_TYPE.reveal(_game(state), state)
913
914
915def test_serialization_redacts_answer_material_and_restores_personal_progress() -> None:
916 """Keep current content and correctness secret while restoring only the player's answer."""
917 definitions = [
918 TimelineFreeTextBonusDefinition(
919 bonus_type=TimelineBonusType.ARTIST,
920 ),
921 _choice_definition(),
922 ]
923 state = _state(
924 bonus_definitions=definitions,
925 artist_answers=["Current Secret Artist", "Hidden Artist Alias"],
926 )
927 game = _game(
928 state,
929 artist_mode=TimelineBonusMode.FREE_TEXT,
930 title_mode=TimelineBonusMode.MULTIPLE_CHOICE,
931 )
932 player = game.players["p1"]
933 ANSWER_TYPE.submit(game, state, player, _correct_placement(), 10)
934 ANSWER_TYPE.submit(
935 game,
936 state,
937 player,
938 TimelineBonusTextSubmission(TimelineBonusType.ARTIST, "my artist answer"),
939 11,
940 )
941 ANSWER_TYPE.submit(
942 game,
943 state,
944 player,
945 TimelineBonusChoiceSubmission(TimelineBonusType.TITLE, "wrong-a"),
946 12,
947 )
948
949 public_round = cast("dict[str, Any]", ANSWER_TYPE.serialize_round(state, revealed=False))
950 serialized_public = str(public_round)
951 assert set(public_round) == {"timeline", "bonus_definitions"}
952 assert "Current Secret" not in serialized_public
953 assert "current-secret" not in serialized_public
954 assert "Hidden Artist Alias" not in serialized_public
955 assert "candidate" not in serialized_public
956 assert "is_correct" not in serialized_public
957 assert public_round["bonus_definitions"][0] == {
958 "bonus_type": "artist",
959 "mode": "free_text",
960 }
961 for option in public_round["bonus_definitions"][1]["options"]:
962 assert set(option) == {"option_id", "label"}
963
964 assert ANSWER_TYPE.serialize_public_player(state, "p1", revealed=False) == {
965 "answered": True,
966 "placed": True,
967 "artist_bonus_answered": True,
968 "title_bonus_answered": True,
969 }
970 assert ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=False) == {
971 "answer": {
972 "previous_entry_id": "same-b",
973 "next_entry_id": "newer",
974 "answered_at": 10,
975 "bonuses": [
976 {
977 "action": "bonus_text",
978 "bonus_type": "artist",
979 "value": "my artist answer",
980 },
981 {
982 "action": "bonus_choice",
983 "bonus_type": "title",
984 "option_id": "wrong-a",
985 },
986 ],
987 "finished": True,
988 }
989 }
990 assert "p1" not in str(ANSWER_TYPE.serialize_public_player(state, "p1", revealed=False))
991
992 ANSWER_TYPE.reveal(game, state)
993 revealed_round = cast("dict[str, Any]", ANSWER_TYPE.serialize_round(state, revealed=True))
994 public_player = cast(
995 "dict[str, Any]",
996 ANSWER_TYPE.serialize_public_player(state, "p1", revealed=True),
997 )
998 personal_player = cast(
999 "dict[str, Any]",
1000 ANSWER_TYPE.serialize_personal_player(state, "p1", revealed=True),
1001 )
1002 assert revealed_round["revealed_entry"]["release_year"] == 2005
1003 assert public_player["last_answer"]["placement"] == {
1004 "previous_entry_id": "same-b",
1005 "next_entry_id": "newer",
1006 "correct": True,
1007 "points": 1000,
1008 }
1009 assert personal_player["answer"]["correct"] is True
1010 assert personal_player["answer"]["points"] == 1000
1011 assert personal_player["answer"]["bonus_results"] == [
1012 {"bonus_type": "artist", "correct": False, "points": 0},
1013 {"bonus_type": "title", "correct": False, "points": 0},
1014 ]
1015
1016
1017def test_host_serialization_is_flat_and_complete() -> None:
1018 """Expose full typed timeline answer data in the host round fragment."""
1019 state = _state()
1020 game = _game(state)
1021 ANSWER_TYPE.submit(game, state, game.players["p1"], _correct_placement(), 1)
1022 ANSWER_TYPE.reveal(game, state)
1023
1024 host = cast("dict[str, Any]", ANSWER_TYPE.serialize_host_round(state))
1025
1026 assert set(host) == {
1027 "placement_snapshot",
1028 "candidate",
1029 "bonus_definitions",
1030 "placements",
1031 "bonus_answers",
1032 "finished_at",
1033 "results",
1034 "revealed",
1035 }
1036 assert host["candidate"]["entry"]["entry_id"] == "current-secret"
1037 assert host["placements"]["p1"]["answered_at"] == 1
1038 assert host["results"]["p1"]["placement"]["is_correct"] is True
1039
1040
1041def test_strategy_rejects_matching_discriminator_on_wrong_state_class() -> None:
1042 """Reject state whose discriminator matches but concrete model does not."""
1043 state = QuizRoundAnswerState(answer_type=MusicQuizAnswerType.TIMELINE)
1044
1045 with pytest.raises(InvalidDataError, match="does not match"):
1046 ANSWER_TYPE.serialize_round(state, revealed=False)
1047