F3: Guessfase submit-endpoint med deadline-validering #8
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-and-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install ruff
|
||||
|
||||
- name: Lint
|
||||
run: ruff check lobby
|
||||
|
||||
- name: Tests
|
||||
run: python manage.py test lobby -v 1
|
||||
2
TODO.md
2
TODO.md
@@ -57,7 +57,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [x] Runde starter med kategori
|
||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||
- [x] System blander korrekt svar + løgne
|
||||
- [ ] Guessfase: alle gætter inden Z sek
|
||||
- [x] Guessfase: alle gætter inden Z sek
|
||||
- [ ] Pointudregning (konfigurerbar pr. runde)
|
||||
- [ ] Scoreboard + næste spørgsmål/runde
|
||||
- [ ] Slutresultat
|
||||
|
||||
106
lobby/tests.py
106
lobby/tests.py
@@ -8,6 +8,7 @@ from django.utils import timezone
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
@@ -337,3 +338,108 @@ class MixAnswersTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
answer_texts = [entry["text"] for entry in response.json()["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
|
||||
|
||||
|
||||
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, "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, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
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, "selected_text": "Venus"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
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, "selected_text": "Jupiter"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
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, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Guess submission window has closed")
|
||||
|
||||
@@ -20,4 +20,10 @@ urlpatterns = [
|
||||
views.mix_answers,
|
||||
name="mix_answers",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
|
||||
views.submit_guess,
|
||||
name="submit_guess",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.views.decorators.http import require_GET, require_POST
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
@@ -398,3 +399,100 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
"answers": [{"text": text} for text in deduped_answers],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
session_code = code.strip().upper()
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
selected_text = str(payload.get("selected_text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return JsonResponse({"error": "player_id is required"}, status=400)
|
||||
|
||||
if not selected_text or len(selected_text) > 255:
|
||||
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status != GameSession.Status.GUESS:
|
||||
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "Round question not found"}, status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config missing"}, status=400)
|
||||
|
||||
guess_deadline_at = round_question.shown_at + timedelta(
|
||||
seconds=round_config.lie_seconds + round_config.guess_seconds
|
||||
)
|
||||
if timezone.now() > guess_deadline_at:
|
||||
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
|
||||
|
||||
allowed_answers = {
|
||||
round_question.correct_answer.strip().casefold(),
|
||||
*(
|
||||
text.strip().casefold()
|
||||
for text in round_question.lies.values_list("text", flat=True)
|
||||
if text.strip()
|
||||
),
|
||||
}
|
||||
|
||||
selected_normalized = selected_text.casefold()
|
||||
if selected_normalized not in allowed_answers:
|
||||
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
|
||||
|
||||
correct_normalized = round_question.correct_answer.strip().casefold()
|
||||
fooled_player_id = None
|
||||
if selected_normalized != correct_normalized:
|
||||
fooled_player_id = (
|
||||
round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first()
|
||||
)
|
||||
|
||||
try:
|
||||
guess = Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=player,
|
||||
selected_text=selected_text,
|
||||
is_correct=selected_normalized == correct_normalized,
|
||||
fooled_player_id=fooled_player_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"guess": {
|
||||
"id": guess.id,
|
||||
"player_id": player.id,
|
||||
"round_question_id": round_question.id,
|
||||
"selected_text": guess.selected_text,
|
||||
"is_correct": guess.is_correct,
|
||||
"fooled_player_id": guess.fooled_player_id,
|
||||
"created_at": guess.created_at.isoformat(),
|
||||
},
|
||||
"window": {
|
||||
"guess_deadline_at": guess_deadline_at.isoformat(),
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user