from datetime import timedelta from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent from fupogfakta.payloads import ( build_lie_started_payload, build_phase_view_model, build_reveal_payload, build_round_question_payload, ) from fupogfakta.services import ( finish_game, get_current_round_question, prepare_mixed_answers, promote_reveal_to_scoreboard, resolve_scores, select_round_question, start_next_round, ) User = get_user_model() class FupOgFaktaExtractionSliceTests(TestCase): def setUp(self): self.host = User.objects.create_user(username="host", password="secret123") self.session = GameSession.objects.create(host=self.host, code="ABCD23") self.category = Category.objects.create(name="Historie", slug="historie", is_active=True) self.question_one = Question.objects.create( category=self.category, prompt="Hvornår faldt muren?", correct_answer="1989", is_active=True, ) self.question_two = Question.objects.create( category=self.category, prompt="Hvornår kom euroen?", correct_answer="1999", is_active=True, ) self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category) self.alice = Player.objects.create(session=self.session, nickname="Alice") self.bob = Player.objects.create(session=self.session, nickname="Bob") self.clara = Player.objects.create(session=self.session, nickname="Clara") def test_select_round_question_skips_already_used_questions_for_session(self): RoundQuestion.objects.create( session=self.session, round_number=99, question=self.question_one, correct_answer=self.question_one.correct_answer, ) round_question = select_round_question(self.session, self.round_config) self.assertEqual(round_question.question, self.question_two) self.assertEqual(get_current_round_question(self.session), round_question) def test_prepare_mixed_answers_dedupes_blank_and_case_variants(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question_one, correct_answer="1989", ) LieAnswer.objects.create(round_question=round_question, player=self.alice, text=" 1989 ") LieAnswer.objects.create(round_question=round_question, player=self.bob, text="Nitten niogfirs") LieAnswer.objects.create(round_question=round_question, player=self.clara, text=" ") with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None): answers = prepare_mixed_answers(round_question) self.assertEqual(answers, ["1989", "Nitten niogfirs"]) round_question.refresh_from_db() self.assertEqual(round_question.mixed_answers, answers) def test_start_next_round_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) result = start_next_round(self.session) self.session.refresh_from_db() self.assertTrue(result.should_broadcast) self.assertEqual(result.session.status, GameSession.Status.LIE) self.assertEqual(result.session.current_round, 2) self.assertEqual(result.round_config.number, 2) self.assertTrue(result.round_config.started_from_scoreboard) self.assertEqual(result.round_question.round_number, 2) def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self): self.session.status = GameSession.Status.LIE self.session.current_round = 2 self.session.save(update_fields=["status", "current_round"]) RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False) RoundQuestion.objects.create( session=self.session, round_number=2, question=self.question_two, correct_answer=self.question_two.correct_answer, ) with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"): start_next_round(self.session) def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) stale_shown_at = timezone.now() - timedelta(minutes=10) stale_round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.question_two, correct_answer=self.question_two.correct_answer, shown_at=stale_shown_at, mixed_answers=["Stale truth", "Stale lie"], ) LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie") Guess.objects.create( round_question=stale_round_question, player=self.bob, selected_text="Stale truth", is_correct=True, ) before_transition = timezone.now() result = start_next_round(self.session) after_transition = timezone.now() stale_round_question.refresh_from_db() self.assertEqual(result.round_question.id, stale_round_question.id) self.assertGreaterEqual(stale_round_question.shown_at, before_transition) self.assertLessEqual(stale_round_question.shown_at, after_transition) self.assertNotEqual(stale_round_question.shown_at, stale_shown_at) self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat()) expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds) self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat()) self.assertGreater(expected_deadline, before_transition) self.assertEqual(stale_round_question.mixed_answers, []) self.assertEqual(stale_round_question.lies.count(), 0) self.assertEqual(stale_round_question.guesses.count(), 0) def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True) stale_round_config = RoundConfig.objects.create( session=self.session, number=2, category=stale_category, lie_seconds=12, guess_seconds=18, points_correct=9, points_bluff=7, started_from_scoreboard=False, ) stale_round_question = RoundQuestion.objects.create( session=self.session, round_number=2, question=self.question_two, correct_answer=self.question_two.correct_answer, shown_at=timezone.now() - timedelta(minutes=10), mixed_answers=["Stale truth"], ) result = start_next_round(self.session) stale_round_config.refresh_from_db() stale_round_question.refresh_from_db() self.assertEqual(result.round_config.id, stale_round_config.id) self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1) self.assertEqual(stale_round_config.category_id, self.round_config.category_id) self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds) self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds) self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct) self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff) self.assertTrue(stale_round_config.started_from_scoreboard) self.assertEqual(result.round_question.id, stale_round_question.id) self.assertEqual(stale_round_question.mixed_answers, []) def test_finish_game_moves_scoreboard_transition_into_service(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) result = finish_game(self.session) self.session.refresh_from_db() self.assertTrue(result.should_broadcast) self.assertEqual(result.session.status, GameSession.Status.FINISHED) self.assertEqual(self.session.status, GameSession.Status.FINISHED) def test_promote_reveal_to_scoreboard_moves_transition_into_service(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question_one, correct_answer=self.question_one.correct_answer, ) self.session.status = GameSession.Status.REVEAL self.session.save(update_fields=["status"]) LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil") Guess.objects.create( round_question=round_question, player=self.bob, selected_text="Elbil", is_correct=False, fooled_player=self.alice, ) ScoreEvent.objects.create( session=self.session, player=self.alice, delta=5, reason="bluff_success", meta={"round_question_id": round_question.id}, ) self.alice.score = 5 self.alice.save(update_fields=["score"]) result = promote_reveal_to_scoreboard(self.session) self.session.refresh_from_db() self.assertTrue(result.should_broadcast) self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD) self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname) def test_resolve_scores_applies_correct_and_bluff_points(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question_one, correct_answer="1989", ) Guess.objects.create( round_question=round_question, player=self.alice, selected_text="1989", is_correct=True, ) Guess.objects.create( round_question=round_question, player=self.bob, selected_text="Berlin", is_correct=False, fooled_player=self.clara, ) Guess.objects.create( round_question=round_question, player=self.clara, selected_text="Berlin", is_correct=False, fooled_player=self.clara, ) score_events, leaderboard = resolve_scores(self.session, round_question, self.round_config) self.assertEqual(len(score_events), 2) self.alice.refresh_from_db() self.clara.refresh_from_db() self.assertEqual(self.alice.score, self.round_config.points_correct) self.assertEqual(self.clara.score, self.round_config.points_bluff * 2) self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2) self.assertEqual([entry["nickname"] for entry in leaderboard], ["Alice", "Clara", "Bob"]) def test_payload_builders_expose_fupogfakta_round_contract(self): round_question = RoundQuestion.objects.create( session=self.session, round_number=1, question=self.question_one, correct_answer="1989", ) lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991") Guess.objects.create( round_question=round_question, player=self.alice, selected_text="1991", is_correct=False, fooled_player=self.bob, ) round_question_payload = build_round_question_payload(round_question) lie_payload = build_lie_started_payload(self.session, self.round_config, round_question) reveal_payload = build_reveal_payload(round_question) phase_view_model = build_phase_view_model( self.session, players_count=3, has_round_question=True, ) self.assertEqual(round_question_payload["prompt"], self.question_one.prompt) self.assertEqual(round_question_payload["answers"], []) self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name}) self.assertEqual(lie_payload["round_question_id"], round_question.id) self.assertEqual(reveal_payload["correct_answer"], "1989") self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id) self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname) self.assertTrue(phase_view_model["host"]["can_start_round"]) self.assertFalse(phase_view_model["host"]["can_finish_game"])