F3/UI: Persistér mixed svarrækkefølge for reconnect #34

Merged
integrator-bot merged 1 commits from feature/f3-persist-mixed-answer-order into main 2026-02-27 23:03:47 +01:00
6 changed files with 74 additions and 24 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-02-27 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0002_roundquestion_shown_at'),
]
operations = [
migrations.AddField(
model_name='roundquestion',
name='mixed_answers',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -87,6 +87,7 @@ class RoundQuestion(models.Model):
question = models.ForeignKey(Question, on_delete=models.PROTECT) question = models.ForeignKey(Question, on_delete=models.PROTECT)
correct_answer = models.CharField(max_length=255) correct_answer = models.CharField(max_length=255)
shown_at = models.DateTimeField(default=timezone.now) shown_at = models.DateTimeField(default=timezone.now)
mixed_answers = models.JSONField(default=list, blank=True)
class LieAnswer(models.Model): class LieAnswer(models.Model):

View File

@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.

View File

@@ -1,3 +1,2 @@
from django.shortcuts import render
# Create your views here. # Create your views here.

View File

@@ -308,7 +308,9 @@ class MixAnswersTests(TestCase):
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS) self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
self.session.refresh_from_db() self.session.refresh_from_db()
self.round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.GUESS) self.assertEqual(self.session.status, GameSession.Status.GUESS)
self.assertEqual(self.round_question.mixed_answers, answer_texts)
def test_mix_answers_requires_host(self): def test_mix_answers_requires_host(self):
self.client.login(username="other", password="secret123") self.client.login(username="other", password="secret123")
@@ -339,6 +341,30 @@ class MixAnswersTests(TestCase):
answer_texts = [entry["text"] for entry in response.json()["answers"]] answer_texts = [entry["text"] for entry in response.json()["answers"]]
self.assertEqual(set(answer_texts), {"København", "Aarhus"}) 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): class GuessSubmissionTests(TestCase):
def setUp(self): def setUp(self):

View File

@@ -147,6 +147,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"round_number": current_round_question.round_number, "round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt, "prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(), "shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
} }
return JsonResponse( 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: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can mix answers"}, status=403) return JsonResponse({"error": "Only host can mix answers"}, status=403)
if session.status != GameSession.Status.LIE: if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
@@ -380,28 +381,34 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404) 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(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.LIE: if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"]) 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( return JsonResponse(
{ {