1958 lines
92 KiB
Python
1958 lines
92 KiB
Python
import json
|
|
import tempfile
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.management import call_command
|
|
from django.test import TestCase, override_settings
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from fupogfakta.models import (
|
|
Category,
|
|
GameSession,
|
|
Guess,
|
|
LieAnswer,
|
|
Player,
|
|
Question,
|
|
RoundConfig,
|
|
RoundQuestion,
|
|
ScoreEvent,
|
|
)
|
|
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class LobbyFlowTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host", password="secret123")
|
|
|
|
def test_create_session_requires_login(self):
|
|
response = self.client.post(reverse("lobby:create_session"))
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(GameSession.objects.count(), 0)
|
|
|
|
def test_host_can_create_session(self):
|
|
self.client.login(username="host", password="secret123")
|
|
|
|
response = self.client.post(reverse("lobby:create_session"))
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
body = response.json()
|
|
self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY)
|
|
self.assertEqual(len(body["session"]["code"]), 6)
|
|
|
|
session = GameSession.objects.get(code=body["session"]["code"])
|
|
self.assertEqual(session.host, self.host)
|
|
|
|
def test_player_can_join_with_code(self):
|
|
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "abcd23", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
body = response.json()
|
|
self.assertEqual(body["session"]["code"], "ABCD23")
|
|
self.assertEqual(body["player"]["nickname"], "Luna")
|
|
self.assertIn("session_token", body["player"])
|
|
self.assertTrue(body["player"]["session_token"])
|
|
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
|
|
|
def test_player_can_join_with_trimmed_code(self):
|
|
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": " abcd23 ", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
|
|
|
def test_join_rejects_code_empty_after_trim(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": " ", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error"], "Session code is required")
|
|
|
|
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
|
session = GameSession.objects.create(host=self.host, code="QWER12")
|
|
Player.objects.create(session=session, nickname="Luna")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "QWER12", "nickname": "lUna"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(response.json()["error"], "Nickname already taken")
|
|
|
|
def test_player_can_join_during_scoreboard_phase(self):
|
|
session = GameSession.objects.create(host=self.host, code="ZXCV97", status=GameSession.Status.SCOREBOARD)
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "ZXCV97", "nickname": "Kai"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
body = response.json()
|
|
self.assertEqual(body["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual(body["player"]["nickname"], "Kai")
|
|
self.assertTrue(Player.objects.filter(session=session, nickname="Kai").exists())
|
|
|
|
def test_join_rejects_non_joinable_session(self):
|
|
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "ZXCV98", "nickname": "Kai"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error"], "Session is not joinable")
|
|
|
|
def test_join_error_localizes_to_danish_with_accept_language_header(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": " ", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "session_code_required")
|
|
self.assertEqual(response.json()["locale"], "da")
|
|
self.assertEqual(response.json()["error"], "Sessionskode er påkrævet")
|
|
|
|
def test_join_error_falls_back_to_english_for_unsupported_locale(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": " ", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="fr",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "session_code_required")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Session code is required")
|
|
|
|
def test_join_error_payload_uses_stable_i18n_contract_keys(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": " ", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(sorted(response.json().keys()), ["error", "error_code", "locale"])
|
|
|
|
def test_session_detail_returns_players(self):
|
|
session = GameSession.objects.create(host=self.host, code="LMNO45")
|
|
Player.objects.create(session=session, nickname="Mia", score=7)
|
|
Player.objects.create(session=session, nickname="Bo", score=2)
|
|
|
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["players_count"], 2)
|
|
self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"])
|
|
|
|
|
|
class StartRoundTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host", password="secret123")
|
|
self.other_user = User.objects.create_user(username="other", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
|
|
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
|
Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvilket år faldt muren?",
|
|
correct_answer="1989",
|
|
is_active=True,
|
|
)
|
|
|
|
def test_host_can_start_round_with_selected_category(self):
|
|
self.client.login(username="host", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
body = response.json()
|
|
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
|
self.assertEqual(body["round"]["number"], 1)
|
|
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
|
self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?")
|
|
self.assertIn("lie_deadline_at", body["round_question"])
|
|
|
|
self.session.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
|
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
|
self.assertEqual(round_config.category, self.category)
|
|
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
|
|
|
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
|
self.client.login(username="host", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": " abcd23 "}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.session.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
|
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
|
self.assertEqual(round_config.category, self.category)
|
|
|
|
def test_start_round_requires_host(self):
|
|
self.client.login(username="other", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_start_round")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can start round")
|
|
|
|
def test_start_round_requires_existing_active_category_with_questions(self):
|
|
self.client.login(username="host", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": "ukendt"},
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": empty_category.slug},
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "category_has_no_questions")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Category has no active questions")
|
|
|
|
def test_start_round_rejects_non_lobby_session(self):
|
|
self.client.login(username="host", password="secret123")
|
|
self.session.status = GameSession.Status.GUESS
|
|
self.session.save(update_fields=["status"])
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error"], "Round can only be started from lobby")
|
|
|
|
def test_start_round_error_localizes_to_danish(self):
|
|
self.client.login(username="other", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_start_round")
|
|
self.assertEqual(response.json()["locale"], "da")
|
|
self.assertEqual(response.json()["error"], "Kun værten kan starte runden")
|
|
|
|
def test_start_round_error_falls_back_to_english_for_unsupported_locale(self):
|
|
self.client.login(username="other", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="fr",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_start_round")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can start round")
|
|
|
|
|
|
class LieSubmissionTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
|
self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvad er hovedstaden i Australien?",
|
|
correct_answer="Canberra",
|
|
is_active=True,
|
|
)
|
|
RoundConfig.objects.create(
|
|
session=self.session,
|
|
number=1,
|
|
category=self.category,
|
|
lie_seconds=45,
|
|
)
|
|
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
|
|
|
def test_host_can_show_question_and_get_lie_deadline(self):
|
|
self.client.login(username="host", password="secret123")
|
|
|
|
response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
payload = response.json()
|
|
self.assertEqual(payload["config"]["lie_seconds"], 45)
|
|
self.assertIn("lie_deadline_at", payload["round_question"])
|
|
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
|
|
|
def test_player_can_submit_lie_before_deadline(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists())
|
|
|
|
def test_submit_lie_rejects_after_time_window(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
round_question.shown_at = timezone.now() - timedelta(seconds=46)
|
|
round_question.save(update_fields=["shown_at"])
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "lie_submission_closed")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Lie submission window has closed")
|
|
|
|
def test_submit_lie_rejects_duplicate_submission(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth")
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(response.json()["error_code"], "lie_already_submitted")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
|
|
|
def test_submit_lie_requires_session_token(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "text": "Sydney"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "session_token_required")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "session_token is required")
|
|
|
|
def test_submit_lie_rejects_invalid_session_token(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Invalid player session token")
|
|
|
|
def test_submit_lie_uses_danish_locale_payload_from_accept_language(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_lie",
|
|
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.1",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
|
self.assertEqual(response.json()["locale"], "da")
|
|
self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token")
|
|
|
|
class MixAnswersTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host", password="secret123")
|
|
self.other_user = User.objects.create_user(username="other", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
|
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvilken by er Danmarks hovedstad?",
|
|
correct_answer="København",
|
|
is_active=True,
|
|
)
|
|
RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
|
self.round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer="København",
|
|
)
|
|
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
|
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
|
|
|
def test_host_can_mix_answers_and_transition_to_guess(self):
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
|
|
|
self.client.login(username="host", password="secret123")
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:mix_answers",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
answer_texts = [entry["text"] for entry in payload["answers"]]
|
|
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
|
|
|
|
self.session.refresh_from_db()
|
|
self.round_question.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.GUESS)
|
|
self.assertEqual(self.round_question.mixed_answers, answer_texts)
|
|
|
|
def test_mix_answers_requires_host(self):
|
|
self.client.login(username="other", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:mix_answers",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_mix_answers")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can mix answers")
|
|
|
|
def test_mix_answers_deduplicates_case_insensitive_lies(self):
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn")
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus")
|
|
|
|
self.client.login(username="host", password="secret123")
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:mix_answers",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
answer_texts = [entry["text"] for entry in response.json()["answers"]]
|
|
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
|
|
|
|
def test_mix_answers_is_idempotent_after_transition_to_guess(self):
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
|
|
|
self.client.login(username="host", password="secret123")
|
|
first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
|
second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
|
|
|
self.assertEqual(first.status_code, 200)
|
|
self.assertEqual(second.status_code, 200)
|
|
self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]])
|
|
|
|
def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self):
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
|
|
|
self.client.login(username="host", password="secret123")
|
|
mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
|
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(mix_response.status_code, 200)
|
|
self.assertEqual(detail_response.status_code, 200)
|
|
self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]])
|
|
|
|
|
|
class GuessSubmissionTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_guess", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS)
|
|
self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvilken planet kaldes den røde planet?",
|
|
correct_answer="Mars",
|
|
is_active=True,
|
|
)
|
|
self.round_config = RoundConfig.objects.create(
|
|
session=self.session,
|
|
number=1,
|
|
category=self.category,
|
|
lie_seconds=45,
|
|
guess_seconds=30,
|
|
)
|
|
self.round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer="Mars",
|
|
)
|
|
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
|
self.liar = Player.objects.create(session=self.session, nickname="Mads")
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter")
|
|
|
|
def test_player_can_submit_guess_in_guess_phase(self):
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
payload = response.json()
|
|
self.assertTrue(payload["guess"]["is_correct"])
|
|
self.assertIsNone(payload["guess"]["fooled_player_id"])
|
|
self.assertIn("guess_deadline_at", payload["window"])
|
|
|
|
def test_submit_guess_rejects_when_not_in_guess_phase(self):
|
|
self.session.status = GameSession.Status.LIE
|
|
self.session.save(update_fields=["status"])
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
|
|
|
|
def test_submit_guess_rejects_unknown_answer(self):
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "selected_answer_invalid")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
|
|
|
|
def test_submit_guess_rejects_duplicate_submission(self):
|
|
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
self.assertEqual(response.json()["error_code"], "guess_already_submitted")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
|
|
|
|
def test_submit_guess_rejects_after_deadline(self):
|
|
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
|
|
self.round_question.save(update_fields=["shown_at"])
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "guess_submission_closed")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Guess submission window has closed")
|
|
|
|
|
|
|
|
def test_submit_guess_requires_session_token(self):
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "selected_text": "Mars"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "session_token_required")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "session_token is required")
|
|
|
|
def test_submit_guess_rejects_invalid_session_token(self):
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:submit_guess",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
),
|
|
data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Invalid player session token")
|
|
|
|
|
|
class CanonicalRoundFlowTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_canonical", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="CN2871")
|
|
self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvem skrev Hamlet?",
|
|
correct_answer="Shakespeare",
|
|
is_active=True,
|
|
)
|
|
self.players = [
|
|
Player.objects.create(session=self.session, nickname="Luna"),
|
|
Player.objects.create(session=self.session, nickname="Mads"),
|
|
Player.objects.create(session=self.session, nickname="Nora"),
|
|
]
|
|
|
|
def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self):
|
|
self.client.login(username="host_canonical", password="secret123")
|
|
|
|
start_response = self.client.post(
|
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
|
data={"category_slug": self.category.slug},
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(start_response.status_code, 201)
|
|
round_question_id = start_response.json()["round_question"]["id"]
|
|
self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE)
|
|
|
|
lie_responses = []
|
|
for index, player in enumerate(self.players, start=1):
|
|
lie_responses.append(
|
|
self.client.post(
|
|
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
|
data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"},
|
|
content_type="application/json",
|
|
)
|
|
)
|
|
|
|
self.assertTrue(all(response.status_code == 201 for response in lie_responses))
|
|
self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS)
|
|
self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"])
|
|
self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2)
|
|
|
|
guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"]
|
|
guess_responses = []
|
|
for player, selected_text in zip(self.players, guess_targets, strict=True):
|
|
guess_responses.append(
|
|
self.client.post(
|
|
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
|
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
|
|
content_type="application/json",
|
|
)
|
|
)
|
|
|
|
self.assertTrue(all(response.status_code == 201 for response in guess_responses))
|
|
self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL)
|
|
self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"])
|
|
self.assertIsNotNone(guess_responses[-1].json()["reveal"])
|
|
|
|
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
self.assertEqual(detail_response.status_code, 200)
|
|
payload = detail_response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD)
|
|
self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
|
self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"])
|
|
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
|
|
|
|
@patch("lobby.views.sync_broadcast_phase_event")
|
|
@patch("lobby.views._resolve_scores")
|
|
@patch("lobby.views.GameSession.objects.get")
|
|
def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing(
|
|
self,
|
|
mock_session_get,
|
|
mock_resolve_scores,
|
|
mock_sync_broadcast,
|
|
):
|
|
round_config = RoundConfig.objects.create(
|
|
session=self.session,
|
|
number=1,
|
|
category=self.category,
|
|
points_correct=5,
|
|
points_bluff=2,
|
|
)
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer="Shakespeare",
|
|
)
|
|
LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe")
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=self.players[0],
|
|
selected_text="Shakespeare",
|
|
is_correct=True,
|
|
)
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=self.players[1],
|
|
selected_text="Marlowe",
|
|
is_correct=False,
|
|
fooled_player=self.players[0],
|
|
)
|
|
self.players[0].score = round_config.points_correct + round_config.points_bluff
|
|
self.players[0].save(update_fields=["score"])
|
|
ScoreEvent.objects.create(
|
|
session=self.session,
|
|
player=self.players[0],
|
|
delta=round_config.points_correct,
|
|
reason="guess_correct",
|
|
meta={"round_question_id": round_question.id, "guess_id": 1},
|
|
)
|
|
ScoreEvent.objects.create(
|
|
session=self.session,
|
|
player=self.players[0],
|
|
delta=round_config.points_bluff,
|
|
reason="bluff_success",
|
|
meta={"round_question_id": round_question.id, "fooled_count": 1},
|
|
)
|
|
self.session.status = GameSession.Status.REVEAL
|
|
self.session.save(update_fields=["status"])
|
|
|
|
stale_session = GameSession(
|
|
pk=self.session.pk,
|
|
host=self.host,
|
|
code=self.session.code,
|
|
status=GameSession.Status.GUESS,
|
|
current_round=self.session.current_round,
|
|
)
|
|
mock_session_get.return_value = stale_session
|
|
|
|
response = self.client.post(
|
|
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}),
|
|
data={
|
|
"player_id": self.players[2].id,
|
|
"session_token": self.players[2].session_token,
|
|
"selected_text": "Shakespeare",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL)
|
|
self.assertTrue(response.json()["phase_transition"]["auto_advanced"])
|
|
self.assertIsNotNone(response.json()["reveal"])
|
|
mock_resolve_scores.assert_not_called()
|
|
mock_sync_broadcast.assert_not_called()
|
|
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2)
|
|
|
|
|
|
class ScoreCalculationTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_score", password="secret123")
|
|
self.other_user = User.objects.create_user(username="other_score", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS)
|
|
self.category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvilken sport spiller man i Wimbledon?",
|
|
correct_answer="Tennis",
|
|
is_active=True,
|
|
)
|
|
RoundConfig.objects.create(
|
|
session=self.session,
|
|
number=1,
|
|
category=self.category,
|
|
points_correct=5,
|
|
points_bluff=2,
|
|
)
|
|
self.round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer="Tennis",
|
|
)
|
|
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
|
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
|
self.player_three = Player.objects.create(session=self.session, nickname="Nora")
|
|
|
|
def test_host_can_calculate_scores_and_transition_to_reveal(self):
|
|
LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel")
|
|
|
|
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
|
Guess.objects.create(
|
|
round_question=self.round_question,
|
|
player=self.player_two,
|
|
selected_text="Padel",
|
|
is_correct=False,
|
|
fooled_player=self.player_three,
|
|
)
|
|
Guess.objects.create(
|
|
round_question=self.round_question,
|
|
player=self.player_three,
|
|
selected_text="Padel",
|
|
is_correct=False,
|
|
fooled_player=self.player_three,
|
|
)
|
|
|
|
self.client.login(username="host_score", password="secret123")
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:calculate_scores",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
|
|
self.assertEqual(payload["events_created"], 2)
|
|
self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id)
|
|
self.assertEqual(payload["reveal"]["correct_answer"], "Tennis")
|
|
self.assertEqual(
|
|
payload["reveal"]["lies"],
|
|
[
|
|
{
|
|
"player_id": self.player_three.id,
|
|
"nickname": "Nora",
|
|
"text": "Padel",
|
|
"created_at": payload["reveal"]["lies"][0]["created_at"],
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
payload["reveal"]["guesses"],
|
|
[
|
|
{
|
|
"player_id": self.player_one.id,
|
|
"nickname": "Luna",
|
|
"selected_text": "Tennis",
|
|
"is_correct": True,
|
|
"created_at": payload["reveal"]["guesses"][0]["created_at"],
|
|
"fooled_player_id": None,
|
|
},
|
|
{
|
|
"player_id": self.player_two.id,
|
|
"nickname": "Mads",
|
|
"selected_text": "Padel",
|
|
"is_correct": False,
|
|
"created_at": payload["reveal"]["guesses"][1]["created_at"],
|
|
"fooled_player_id": self.player_three.id,
|
|
"fooled_player_nickname": "Nora",
|
|
},
|
|
{
|
|
"player_id": self.player_three.id,
|
|
"nickname": "Nora",
|
|
"selected_text": "Padel",
|
|
"is_correct": False,
|
|
"created_at": payload["reveal"]["guesses"][2]["created_at"],
|
|
"fooled_player_id": self.player_three.id,
|
|
"fooled_player_nickname": "Nora",
|
|
},
|
|
],
|
|
)
|
|
|
|
self.player_one.refresh_from_db()
|
|
self.player_three.refresh_from_db()
|
|
self.session.refresh_from_db()
|
|
|
|
self.assertEqual(self.player_one.score, 5)
|
|
self.assertEqual(self.player_three.score, 4)
|
|
self.assertEqual(self.session.status, GameSession.Status.REVEAL)
|
|
|
|
def test_calculate_scores_requires_host(self):
|
|
self.client.login(username="other_score", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:calculate_scores",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_calculate_scores")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can calculate scores")
|
|
|
|
def test_calculate_scores_rejects_duplicate_calculation(self):
|
|
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
|
|
|
self.client.login(username="host_score", password="secret123")
|
|
first = self.client.post(
|
|
reverse(
|
|
"lobby:calculate_scores",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
second = self.client.post(
|
|
reverse(
|
|
"lobby:calculate_scores",
|
|
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(first.status_code, 200)
|
|
self.assertEqual(second.status_code, 409)
|
|
self.assertEqual(second.json()["error_code"], "scores_already_calculated")
|
|
self.assertEqual(second.json()["locale"], "en")
|
|
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
|
|
|
|
|
|
class RevealRoundFlowTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_reveal", password="secret123")
|
|
self.other_user = User.objects.create_user(username="other_reveal", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
|
|
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
|
|
self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3)
|
|
self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvad er Danmarks hovedstad?",
|
|
correct_answer="København",
|
|
is_active=True,
|
|
)
|
|
self.next_question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvad er Sveriges hovedstad?",
|
|
correct_answer="Stockholm",
|
|
is_active=True,
|
|
)
|
|
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
|
self.round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
ScoreEvent.objects.create(
|
|
session=self.session,
|
|
player=self.player_one,
|
|
delta=5,
|
|
reason="guess_correct",
|
|
meta={"round_question_id": self.round_question.id},
|
|
)
|
|
|
|
@patch("lobby.views.sync_broadcast_phase_event")
|
|
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
|
|
response = self.client.get(
|
|
reverse(
|
|
"lobby:reveal_scoreboard",
|
|
kwargs={"code": self.session.code},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
|
mock_sync_broadcast_phase_event.assert_called_once_with(
|
|
self.session.code,
|
|
"phase.scoreboard",
|
|
{
|
|
"leaderboard": [
|
|
{"id": self.player_one.id, "nickname": "Luna", "score": 9},
|
|
{"id": self.player_two.id, "nickname": "Mads", "score": 3},
|
|
],
|
|
"current_round": 1,
|
|
},
|
|
)
|
|
|
|
self.session.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD)
|
|
|
|
def test_reveal_scoreboard_requires_host(self):
|
|
self.client.login(username="other_reveal", password="secret123")
|
|
|
|
response = self.client.get(
|
|
reverse(
|
|
"lobby:reveal_scoreboard",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="fr",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
|
|
|
def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self):
|
|
self.session.status = GameSession.Status.SCOREBOARD
|
|
self.session.save(update_fields=["status"])
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
|
|
response = self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
|
|
|
@patch("lobby.views.sync_broadcast_phase_event")
|
|
def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:finish_game",
|
|
kwargs={"code": self.session.code},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED)
|
|
self.assertEqual(payload["winner"]["nickname"], "Luna")
|
|
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
|
|
|
self.session.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
|
|
|
def test_finish_game_requires_host(self):
|
|
self.client.login(username="other_reveal", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:finish_game",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="da",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
|
|
self.assertEqual(response.json()["locale"], "da")
|
|
self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet")
|
|
|
|
def test_finish_game_rejects_wrong_phase(self):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
self.session.status = GameSession.Status.GUESS
|
|
self.session.save(update_fields=["status"])
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:finish_game",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="fr",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
|
|
|
|
@patch("lobby.views.sync_broadcast_phase_event")
|
|
def test_host_can_start_next_round_from_scoreboard(self, mock_sync_broadcast_phase_event):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
|
mock_sync_broadcast_phase_event.reset_mock()
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:start_next_round",
|
|
kwargs={"code": self.session.code},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.LIE)
|
|
self.assertEqual(payload["session"]["current_round"], 2)
|
|
self.assertEqual(payload["round"]["category"]["slug"], self.category.slug)
|
|
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
|
|
self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds)
|
|
|
|
self.session.refresh_from_db()
|
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
|
self.assertEqual(self.session.current_round, 2)
|
|
self.assertTrue(
|
|
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
|
|
)
|
|
self.assertTrue(
|
|
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
|
|
)
|
|
mock_sync_broadcast_phase_event.assert_called_once()
|
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
|
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
|
|
|
def test_start_next_round_requires_host(self):
|
|
self.session.status = GameSession.Status.SCOREBOARD
|
|
self.session.save(update_fields=["status"])
|
|
self.client.login(username="other_reveal", password="secret123")
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:start_next_round",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="fr",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json(), {
|
|
"error": "Only host can start next round",
|
|
"error_code": "host_only_start_next_round",
|
|
"locale": "en",
|
|
})
|
|
|
|
@patch("lobby.views.sync_broadcast_phase_event")
|
|
def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
|
|
first_response = self.client.get(
|
|
reverse(
|
|
"lobby:reveal_scoreboard",
|
|
kwargs={"code": self.session.code},
|
|
)
|
|
)
|
|
second_response = self.client.get(
|
|
reverse(
|
|
"lobby:reveal_scoreboard",
|
|
kwargs={"code": self.session.code},
|
|
)
|
|
)
|
|
|
|
self.assertEqual(first_response.status_code, 200)
|
|
self.assertEqual(second_response.status_code, 200)
|
|
self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"])
|
|
self.assertEqual(mock_sync_broadcast_phase_event.call_count, 1)
|
|
|
|
def test_start_next_round_rejects_wrong_phase(self):
|
|
self.client.login(username="host_reveal", password="secret123")
|
|
self.session.status = GameSession.Status.GUESS
|
|
self.session.save(update_fields=["status"])
|
|
|
|
response = self.client.post(
|
|
reverse(
|
|
"lobby:start_next_round",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="da",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["error_code"], "next_round_invalid_phase")
|
|
self.assertEqual(response.json()["locale"], "da")
|
|
self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen")
|
|
|
|
def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self):
|
|
self.client.login(username="other_reveal", password="secret123")
|
|
|
|
response = self.client.get(
|
|
reverse(
|
|
"lobby:reveal_scoreboard",
|
|
kwargs={"code": self.session.code},
|
|
),
|
|
HTTP_ACCEPT_LANGUAGE="fr-FR,fr;q=0.9",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
|
self.assertEqual(response.json()["locale"], "en")
|
|
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
|
|
|
class UiScreenTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_ui", password="secret123")
|
|
|
|
def test_host_screen_requires_login(self):
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_host_screen_renders_for_logged_in_user(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Host panel")
|
|
self.assertContains(response, "id=\"createSessionBtn\"")
|
|
self.assertContains(response, "id=\"createSessionHint\"")
|
|
self.assertContains(response, "saveHostContext")
|
|
self.assertContains(response, "restoreHostContext")
|
|
self.assertContains(response, "id=\"roundQuestionStatus\"")
|
|
self.assertContains(response, "id=\"hostActionHint\"")
|
|
self.assertContains(response, "id=\"categoryGuardHint\"")
|
|
|
|
self.assertContains(response, "id=\"phaseStatus\"")
|
|
self.assertContains(response, "updateHostActionState")
|
|
self.assertContains(response, "phaseLabel")
|
|
self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.")
|
|
self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.")
|
|
self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.")
|
|
self.assertContains(response, "Kræver 3-5 spillere i lobbyen.")
|
|
self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.")
|
|
self.assertContains(response, "Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.")
|
|
self.assertContains(response, "Round question-id styres server-side i canonical flow og er read-only i fase:")
|
|
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"")
|
|
self.assertContains(response, "hostActionInFlight")
|
|
self.assertContains(response, "withHostActionLock")
|
|
self.assertContains(response, "updateCreateSessionState")
|
|
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight")
|
|
self.assertContains(response, "Opret session er låst mens en host-handling kører.")
|
|
self.assertContains(response, "Opret session er låst mens session-opdatering kører.")
|
|
self.assertContains(response, "Handling kører… afvent svar før næste klik.")
|
|
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
|
self.assertContains(response, "Sidst opdateret:")
|
|
self.assertContains(response, "Session-data kan være forældet")
|
|
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
|
self.assertContains(response, "id=\"sessionDetailHint\"")
|
|
self.assertContains(response, "updateSessionDetailState")
|
|
self.assertContains(response, "sessionDetailInFlight")
|
|
self.assertContains(response, "session_detail_in_flight")
|
|
self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight")
|
|
self.assertContains(response, "id=\"autoRefreshToggleBtn\"")
|
|
self.assertContains(response, "id=\"autoRefreshHint\"")
|
|
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()")
|
|
self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.")
|
|
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
|
self.assertContains(response, "Opdaterer session-status…")
|
|
self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.")
|
|
self.assertContains(response, "Angiv sessionkode for at opdatere session-status.")
|
|
self.assertContains(response, "markSessionRefresh")
|
|
self.assertContains(response, "updateLastRefreshStatus")
|
|
self.assertContains(response, "isSessionDetailRead")
|
|
self.assertContains(response, "Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase:")
|
|
self.assertContains(response, "Host-actions er klar: vælg næste runde eller afslut spillet.")
|
|
self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}')
|
|
self.assertContains(response, "Host-actions er låst mens session-opdatering kører.")
|
|
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
|
|
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
|
|
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
|
self.assertContains(response, "hostShellRouteFromPath")
|
|
self.assertContains(response, "syncHostShellRoute")
|
|
self.assertContains(response, "Deep-link route guard: omdirigeret")
|
|
self.assertContains(response, "id=\"hostShellErrorBoundary\"")
|
|
self.assertContains(response, "recoverHostShell('retry')")
|
|
self.assertContains(response, "recoverHostShell('reload')")
|
|
self.assertContains(response, "setHostShellFatalError")
|
|
self.assertContains(response, "clearHostShellFatalError")
|
|
self.assertContains(response, "updateHostShellErrorBoundary")
|
|
self.assertContains(response, "host_shell_runtime_error")
|
|
self.assertContains(response, "window.addEventListener(\"unhandledrejection\"")
|
|
|
|
def test_host_screen_deeplink_requires_login(self):
|
|
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
def test_host_screen_deeplink_renders_for_logged_in_user(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Host panel")
|
|
|
|
def test_player_screen_is_public(self):
|
|
response = self.client.get(reverse("lobby:player_screen"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "Player panel")
|
|
self.assertContains(response, "id=\"sessionToken\"")
|
|
self.assertContains(response, "session_token")
|
|
self.assertContains(response, "id=\"answerOptions\"")
|
|
self.assertContains(response, "renderAnswerOptions")
|
|
self.assertContains(response, "renderAnswerOptions(null)")
|
|
self.assertContains(response, "availableAnswers")
|
|
self.assertContains(response, "guessStorageKey")
|
|
self.assertContains(response, "persistGuessState")
|
|
self.assertContains(response, "savePlayerContext")
|
|
self.assertContains(response, "restorePlayerContext")
|
|
self.assertContains(response, "id=\"lieSubmitBtn\"")
|
|
self.assertContains(response, "id=\"lieStatus\"")
|
|
self.assertContains(response, "id=\"phaseStatus\"")
|
|
self.assertContains(response, "currentSessionStatus")
|
|
self.assertContains(response, "updatePhaseStatus")
|
|
self.assertContains(response, "Løgn-input er låst i fase")
|
|
self.assertContains(response, "Gæt er låst i fase")
|
|
self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.")
|
|
self.assertContains(response, "persistLieState")
|
|
self.assertContains(response, "updateLieSubmitState")
|
|
self.assertContains(response, "hasSubmitContext")
|
|
self.assertContains(response, "hasRoundQuestionContext")
|
|
self.assertContains(response, "canAttemptJoin")
|
|
self.assertContains(response, "missing_join_input")
|
|
self.assertContains(response, "Udfyld kode og nickname for at join.")
|
|
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
|
|
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
|
|
self.assertContains(response, "id=\"connectionBanner\"")
|
|
self.assertContains(response, "id=\"connectionRetryBtn\"")
|
|
self.assertContains(response, "retryConnection")
|
|
self.assertContains(response, "setConnectionLost")
|
|
self.assertContains(response, "connection_lost")
|
|
self.assertContains(response, "id=\"contextLockHint\"")
|
|
self.assertContains(response, "updateContextLockState")
|
|
self.assertContains(response, "Spillerkontekst er låst efter join.")
|
|
self.assertContains(response, "already_joined_client")
|
|
self.assertContains(response, "missing_submit_context")
|
|
self.assertContains(response, "invalid_client_guess")
|
|
self.assertContains(response, "lieSubmitInFlight")
|
|
self.assertContains(response, "guessSubmitInFlight")
|
|
self.assertContains(response, "Sender løgn…")
|
|
self.assertContains(response, "Sender gæt…")
|
|
self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.")
|
|
self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.")
|
|
self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase")
|
|
self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight")
|
|
self.assertContains(response, "lie_submit_in_flight")
|
|
self.assertContains(response, "guess_submit_in_flight")
|
|
self.assertContains(response, "guess_already_submitted_client")
|
|
self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"")
|
|
self.assertContains(response, "id=\"playerAutoRefreshHint\"")
|
|
self.assertContains(response, "id=\"playerLastRefreshStatus\"")
|
|
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
|
self.assertContains(response, "id=\"sessionRefreshHint\"")
|
|
self.assertContains(response, "id=\"roundContextHint\"")
|
|
self.assertContains(response, "resetRoundContextForManualChange")
|
|
self.assertContains(response, "Runde-kontekst afventer session-opdatering.")
|
|
self.assertContains(response, "togglePlayerAutoRefresh")
|
|
self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()")
|
|
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
|
self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.")
|
|
self.assertContains(response, "Auto-refresh kræver sessionkode.")
|
|
self.assertContains(response, "markPlayerSessionRefresh")
|
|
self.assertContains(response, "updatePlayerLastRefreshStatus")
|
|
self.assertContains(response, "updateSessionDetailState")
|
|
self.assertContains(response, "session_detail_in_flight")
|
|
self.assertContains(response, "Opdaterer session-status…")
|
|
self.assertContains(response, "Session-opdatering er låst mens join kører.")
|
|
self.assertContains(response, "Session-opdatering er låst mens submit kører.")
|
|
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
|
self.assertContains(response, "Sidst opdateret:")
|
|
self.assertContains(response, "Session-data kan være forældet")
|
|
self.assertContains(response, "id=\"playerShellErrorBoundary\"")
|
|
self.assertContains(response, "recoverPlayerShell('retry')")
|
|
self.assertContains(response, "recoverPlayerShell('reload')")
|
|
self.assertContains(response, "setPlayerShellFatalError")
|
|
self.assertContains(response, "clearPlayerShellFatalError")
|
|
self.assertContains(response, "updatePlayerShellErrorBoundary")
|
|
self.assertContains(response, "player_shell_runtime_error")
|
|
self.assertContains(response, "installSecondaryDeviceAudioGuard")
|
|
self.assertContains(response, "silencePlayerMediaElements")
|
|
self.assertContains(response, "querySelectorAll(\"audio,video\")")
|
|
self.assertNotContains(response, "<audio")
|
|
self.assertContains(response, "window.addEventListener(\"error\"")
|
|
|
|
@override_settings(USE_SPA_UI=False)
|
|
def test_legacy_templates_are_used_when_spa_flag_is_off(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
host_response = self.client.get(reverse("lobby:host_screen"))
|
|
player_response = self.client.get(reverse("lobby:player_screen"))
|
|
|
|
self.assertContains(host_response, "Host panel")
|
|
self.assertContains(player_response, "Player panel")
|
|
self.assertNotContains(host_response, "<app-root")
|
|
self.assertNotContains(player_response, "<app-root")
|
|
|
|
@override_settings(USE_SPA_UI=True)
|
|
def test_host_screen_can_render_angular_shell_when_feature_flag_enabled(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "<app-root")
|
|
self.assertContains(response, "data-wpp-shell-route=\"/host\"")
|
|
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
|
|
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=dev")
|
|
self.assertContains(response, "/static/frontend/angular/browser/styles.css?v=dev")
|
|
|
|
@override_settings(USE_SPA_UI=True)
|
|
def test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get(
|
|
reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess/round-1"})
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "<app-root")
|
|
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
|
|
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
|
|
|
|
def test_host_screen_template_registers_scoreboard_shell_route(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(
|
|
response,
|
|
'var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};',
|
|
)
|
|
|
|
def test_host_screen_template_gates_next_round_and_finish_on_scoreboard_phase(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}')
|
|
self.assertContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}')
|
|
self.assertNotContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}')
|
|
self.assertNotContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}')
|
|
|
|
@override_settings(USE_SPA_UI=True)
|
|
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get("/lobby/ui/host//guess///round-1//")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "<app-root")
|
|
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
|
|
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
|
|
|
|
@override_settings(USE_SPA_UI=True)
|
|
def test_player_screen_can_render_angular_shell_when_feature_flag_enabled(self):
|
|
response = self.client.get(reverse("lobby:player_screen"))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "<app-root")
|
|
self.assertContains(response, "data-wpp-shell-route=\"/player\"")
|
|
self.assertContains(response, "data-wpp-shell-kind=\"player\"")
|
|
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=dev")
|
|
|
|
@override_settings(USE_SPA_UI=True, WPP_SPA_ASSET_VERSION="release-2026-03-01")
|
|
def test_spa_shell_uses_configured_asset_version_for_cache_busting(self):
|
|
self.client.login(username="host_ui", password="secret123")
|
|
|
|
response = self.client.get(reverse("lobby:host_screen"))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "/static/frontend/angular/browser/styles.css?v=release-2026-03-01")
|
|
self.assertContains(response, "/static/frontend/angular/browser/main.js?v=release-2026-03-01")
|
|
|
|
|
|
class SessionDetailRoundQuestionTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_detail", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="ABCDE1", status=GameSession.Status.LIE)
|
|
self.category = Category.objects.create(name="Historie", slug="historie-2", is_active=True)
|
|
self.question = Question.objects.create(
|
|
category=self.category,
|
|
prompt="Hvem opfandt pæren?",
|
|
correct_answer="Edison",
|
|
is_active=True,
|
|
)
|
|
|
|
def test_session_detail_includes_current_round_question_when_available(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
|
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
|
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
|
|
|
def test_session_detail_includes_canonical_reveal_payload_in_reveal_phase(self):
|
|
self.session.status = GameSession.Status.REVEAL
|
|
self.session.save(update_fields=["status"])
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
liar = Player.objects.create(session=self.session, nickname="Løgnhals")
|
|
guesser = Player.objects.create(session=self.session, nickname="Detektiv")
|
|
correct_player = Player.objects.create(session=self.session, nickname="Sandhed")
|
|
LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=guesser,
|
|
selected_text="Tesla",
|
|
is_correct=False,
|
|
fooled_player=liar,
|
|
)
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=correct_player,
|
|
selected_text="Edison",
|
|
is_correct=True,
|
|
)
|
|
|
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["reveal"]["round_question_id"], round_question.id)
|
|
self.assertEqual(payload["reveal"]["correct_answer"], "Edison")
|
|
self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id)
|
|
self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals")
|
|
self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla")
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id)
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla")
|
|
self.assertFalse(payload["reveal"]["guesses"][0]["is_correct"])
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id)
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals")
|
|
self.assertEqual(payload["reveal"]["guesses"][1]["player_id"], correct_player.id)
|
|
self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison")
|
|
self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"])
|
|
self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"])
|
|
self.assertNotIn("fooled_player_nickname", payload["reveal"]["guesses"][1])
|
|
|
|
def test_session_detail_includes_canonical_reveal_payload_in_scoreboard_phase(self):
|
|
self.session.status = GameSession.Status.SCOREBOARD
|
|
self.session.save(update_fields=["status"])
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
liar = Player.objects.create(session=self.session, nickname="Løgnhals")
|
|
guesser = Player.objects.create(session=self.session, nickname="Detektiv")
|
|
correct_player = Player.objects.create(session=self.session, nickname="Sandhed")
|
|
LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=guesser,
|
|
selected_text="Tesla",
|
|
is_correct=False,
|
|
fooled_player=liar,
|
|
)
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=correct_player,
|
|
selected_text="Edison",
|
|
is_correct=True,
|
|
)
|
|
|
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
payload = response.json()
|
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
|
self.assertEqual(payload["reveal"]["round_question_id"], round_question.id)
|
|
self.assertEqual(payload["reveal"]["correct_answer"], "Edison")
|
|
self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id)
|
|
self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals")
|
|
self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla")
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id)
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla")
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id)
|
|
self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals")
|
|
self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"])
|
|
self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison")
|
|
self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"])
|
|
self.assertIsNone(payload["reveal"]["guesses"][1].get("fooled_player_nickname"))
|
|
|
|
def test_session_detail_preserves_canonical_reveal_payload_across_reveal_and_scoreboard(self):
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=self.question,
|
|
correct_answer=self.question.correct_answer,
|
|
)
|
|
liar = Player.objects.create(session=self.session, nickname="Løgnhals")
|
|
guesser = Player.objects.create(session=self.session, nickname="Detektiv")
|
|
LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
|
|
Guess.objects.create(
|
|
round_question=round_question,
|
|
player=guesser,
|
|
selected_text="Tesla",
|
|
is_correct=False,
|
|
fooled_player=liar,
|
|
)
|
|
|
|
self.session.status = GameSession.Status.REVEAL
|
|
self.session.save(update_fields=["status"])
|
|
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
|
|
self.session.status = GameSession.Status.SCOREBOARD
|
|
self.session.save(update_fields=["status"])
|
|
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
|
|
self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"])
|
|
self.assertTrue(reveal_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
|
self.assertTrue(scoreboard_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
|
self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"])
|
|
self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"])
|
|
|
|
|
|
class SessionDetailPhaseViewModelTests(TestCase):
|
|
def setUp(self):
|
|
self.host = User.objects.create_user(username="host_phase", password="secret123")
|
|
self.session = GameSession.objects.create(host=self.host, code="PHASE1", status=GameSession.Status.LOBBY)
|
|
|
|
def test_session_detail_includes_shared_phase_view_model_contract(self):
|
|
Player.objects.create(session=self.session, nickname="P1")
|
|
Player.objects.create(session=self.session, nickname="P2")
|
|
Player.objects.create(session=self.session, nickname="P3")
|
|
|
|
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
phase = response.json()["phase_view_model"]
|
|
self.assertEqual(phase["status"], GameSession.Status.LOBBY)
|
|
self.assertEqual(phase["round_number"], 1)
|
|
self.assertEqual(phase["players_count"], 3)
|
|
self.assertEqual(phase["constraints"]["min_players_to_start"], 3)
|
|
self.assertEqual(phase["constraints"]["max_players_mvp"], 5)
|
|
self.assertTrue(phase["constraints"]["min_players_reached"])
|
|
self.assertTrue(phase["constraints"]["max_players_allowed"])
|
|
self.assertTrue(phase["host"]["can_start_round"])
|
|
self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY)
|
|
self.assertFalse(phase["host"]["can_show_question"])
|
|
self.assertFalse(phase["readiness"]["question_ready"])
|
|
self.assertFalse(phase["readiness"]["scoreboard_ready"])
|
|
self.assertTrue(phase["player"]["can_join"])
|
|
self.assertFalse(phase["player"]["can_submit_lie"])
|
|
self.assertFalse(phase["player"]["can_submit_guess"])
|
|
|
|
def test_phase_view_model_flags_change_with_round_phase(self):
|
|
category = Category.objects.create(name="Kultur", slug="kultur", is_active=True)
|
|
question = Question.objects.create(
|
|
category=category,
|
|
prompt="Hvilket land kommer sushi fra?",
|
|
correct_answer="Japan",
|
|
is_active=True,
|
|
)
|
|
round_question = RoundQuestion.objects.create(
|
|
session=self.session,
|
|
round_number=1,
|
|
question=question,
|
|
correct_answer="Japan",
|
|
)
|
|
|
|
self.session.status = GameSession.Status.LIE
|
|
self.session.save(update_fields=["status"])
|
|
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
lie_phase = lie_payload["phase_view_model"]
|
|
self.assertFalse(lie_phase["host"]["can_show_question"])
|
|
self.assertFalse(lie_phase["host"]["can_mix_answers"])
|
|
self.assertTrue(lie_phase["readiness"]["question_ready"])
|
|
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
|
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
|
|
|
self.session.status = GameSession.Status.GUESS
|
|
self.session.save(update_fields=["status"])
|
|
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
guess_phase = guess_payload["phase_view_model"]
|
|
self.assertFalse(guess_phase["host"]["can_mix_answers"])
|
|
self.assertFalse(guess_phase["host"]["can_calculate_scores"])
|
|
self.assertFalse(guess_phase["readiness"]["scoreboard_ready"])
|
|
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
|
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
|
|
|
round_question.delete()
|
|
self.session.status = GameSession.Status.REVEAL
|
|
self.session.save(update_fields=["status"])
|
|
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
reveal_phase = reveal_payload["phase_view_model"]
|
|
self.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"])
|
|
self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"])
|
|
self.assertFalse(reveal_phase["host"]["can_start_next_round"])
|
|
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
|
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
|
|
|
self.session.status = GameSession.Status.SCOREBOARD
|
|
self.session.save(update_fields=["status"])
|
|
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
scoreboard_phase = scoreboard_payload["phase_view_model"]
|
|
self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"])
|
|
self.assertTrue(scoreboard_phase["readiness"]["scoreboard_ready"])
|
|
self.assertTrue(scoreboard_phase["host"]["can_start_next_round"])
|
|
self.assertTrue(scoreboard_phase["host"]["can_finish_game"])
|
|
self.assertFalse(scoreboard_phase["player"]["can_view_final_result"])
|
|
|
|
self.session.status = GameSession.Status.FINISHED
|
|
self.session.save(update_fields=["status"])
|
|
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
|
finished_phase = finished_payload["phase_view_model"]
|
|
self.assertFalse(finished_phase["player"]["can_join"])
|
|
self.assertTrue(finished_phase["player"]["can_view_final_result"])
|
|
|
|
class SmokeStagingCommandTests(TestCase):
|
|
def test_smoke_staging_command_runs_full_flow(self):
|
|
call_command("smoke_staging")
|
|
|
|
session = GameSession.objects.latest("created_at")
|
|
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
|
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
|
|
|
def test_smoke_staging_writes_artifact_when_requested(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
artifact_path = Path(tmp_dir) / "smoke.json"
|
|
call_command("smoke_staging", artifact=str(artifact_path))
|
|
|
|
self.assertTrue(artifact_path.exists())
|
|
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
|
self.assertTrue(payload["ok"])
|
|
self.assertEqual(payload["command"], "smoke_staging")
|
|
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
|
self.assertIn("generated_at", payload)
|
|
self.assertIn("session_code", payload)
|
|
self.assertEqual(
|
|
payload["steps"],
|
|
[
|
|
"create_session",
|
|
"join_players",
|
|
"start_round",
|
|
"submit_lies",
|
|
"auto_guess_transition",
|
|
"submit_guesses",
|
|
"auto_reveal_to_scoreboard",
|
|
"finish_game",
|
|
],
|
|
)
|
|
|
|
|
|
class I18nResolverTests(TestCase):
|
|
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.8",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "da")
|
|
|
|
def test_resolve_locale_accepts_underscore_language_tags(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da_DK",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "da")
|
|
|
|
def test_resolve_locale_uses_next_supported_language_when_primary_is_unsupported(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="fr-FR, da;q=0.9, en;q=0.8",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "da")
|
|
|
|
def test_resolve_locale_skips_q0_languages_and_falls_back_to_next_supported(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da;q=0, en;q=0.8",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "en")
|
|
|
|
def test_resolve_locale_prefers_highest_quality_supported_language(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
HTTP_ACCEPT_LANGUAGE="da;q=0.8, en;q=0.9",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "en")
|
|
|
|
def test_resolve_locale_defaults_to_en_when_header_missing(self):
|
|
response = self.client.post(
|
|
reverse("lobby:join_session"),
|
|
data={"code": "", "nickname": "Luna"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(resolve_locale(response.wsgi_request), "en")
|
|
|
|
def test_missing_backend_key_returns_key_deterministically(self):
|
|
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")
|
|
|
|
def test_missing_backend_key_is_logged_with_context(self):
|
|
with self.assertLogs("lobby.i18n", level="WARNING") as logs:
|
|
result = resolve_error_message(key="missing_key", locale="da")
|
|
|
|
self.assertEqual(result, "missing_key")
|
|
self.assertTrue(any("i18n key missing in shared catalog" in entry for entry in logs.output))
|
|
|
|
def test_missing_backend_error_code_is_logged_with_context(self):
|
|
from lobby.i18n import resolve_error_key
|
|
|
|
with self.assertLogs("lobby.i18n", level="WARNING") as logs:
|
|
result = resolve_error_key("missing_code")
|
|
|
|
self.assertEqual(result, "missing_code")
|
|
self.assertTrue(any("i18n error code missing in shared catalog" in entry for entry in logs.output))
|
|
|
|
def test_missing_locale_translation_falls_back_to_default_locale(self):
|
|
with patch(
|
|
"lobby.i18n.lobby_i18n_error_messages",
|
|
return_value={"session_code_required": {"en": "Session code is required"}},
|
|
):
|
|
self.assertEqual(
|
|
resolve_error_message(key="session_code_required", locale="da"),
|
|
"Session code is required",
|
|
)
|
|
|
|
def test_shared_catalog_uses_en_default_and_da_en_matrix(self):
|
|
default_locale, supported_locales = i18n_locale_config()
|
|
catalog = lobby_i18n_catalog()
|
|
|
|
self.assertEqual(default_locale, "en")
|
|
self.assertIn("en", supported_locales)
|
|
self.assertIn("da", supported_locales)
|
|
|
|
for key, translations in catalog["backend"]["errors"].items():
|
|
self.assertTrue(translations.get("en"), f"backend key {key} missing en")
|
|
self.assertTrue(translations.get("da"), f"backend key {key} missing da")
|
|
|
|
for key, translations in catalog["frontend"]["errors"].items():
|
|
self.assertTrue(translations.get("en"), f"frontend key {key} missing en")
|
|
self.assertTrue(translations.get("da"), f"frontend key {key} missing da")
|
|
|
|
def test_backend_error_codes_map_via_shared_backend_frontend_key_map(self):
|
|
catalog = lobby_i18n_catalog()
|
|
backend_errors = catalog["backend"]["errors"]
|
|
frontend_errors = catalog["frontend"]["errors"]
|
|
shared_map = catalog["contract"]["backend_to_frontend_error_keys"]
|
|
|
|
for code, backend_key in catalog["backend"]["error_codes"].items():
|
|
frontend_key = shared_map.get(code)
|
|
self.assertIn(backend_key, backend_errors)
|
|
self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}")
|
|
self.assertIn(frontend_key, frontend_errors)
|