F3: persist mixed answer order for stable UI reconnect
All checks were successful
CI / test-and-quality (push) Successful in 1m33s
CI / test-and-quality (pull_request) Successful in 1m34s

This commit is contained in:
2026-02-27 22:58:40 +01:00
parent fa523d84f5
commit 8e4ce8c4da
6 changed files with 74 additions and 24 deletions

View File

@@ -308,7 +308,9 @@ class MixAnswersTests(TestCase):
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")
@@ -339,6 +341,30 @@ class MixAnswersTests(TestCase):
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):

View File

@@ -147,6 +147,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
return JsonResponse(
@@ -368,8 +369,8 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can mix answers"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
@@ -380,28 +381,34 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
lie_texts = list(round_question.lies.values_list("text", flat=True))
deduped_answers = []
seen = set()
for text in [round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
return JsonResponse({"error": "Not enough answers to mix"}, status=400)
random.shuffle(deduped_answers)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400)
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
deduped_answers = list(locked_round_question.mixed_answers or [])
if not deduped_answers:
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
seen = set()
for text in [locked_round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
return JsonResponse({"error": "Not enough answers to mix"}, status=400)
random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers
locked_round_question.save(update_fields=["mixed_answers"])
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
return JsonResponse(
{