Merge pull request 'F3: Guessfase submit-endpoint med deadline-validering' (#8) from feature/f3-guess-submit into main
All checks were successful
CI / test-and-quality (push) Successful in 44s
All checks were successful
CI / test-and-quality (push) Successful in 44s
This commit was merged in pull request #8.
This commit is contained in:
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] Runde starter med kategori
|
||||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||||
- [x] System blander korrekt svar + løgne
|
- [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)
|
- [ ] Pointudregning (konfigurerbar pr. runde)
|
||||||
- [ ] Scoreboard + næste spørgsmål/runde
|
- [ ] Scoreboard + næste spørgsmål/runde
|
||||||
- [ ] Slutresultat
|
- [ ] Slutresultat
|
||||||
|
|||||||
106
lobby/tests.py
106
lobby/tests.py
@@ -8,6 +8,7 @@ from django.utils import timezone
|
|||||||
from fupogfakta.models import (
|
from fupogfakta.models import (
|
||||||
Category,
|
Category,
|
||||||
GameSession,
|
GameSession,
|
||||||
|
Guess,
|
||||||
LieAnswer,
|
LieAnswer,
|
||||||
Player,
|
Player,
|
||||||
Question,
|
Question,
|
||||||
@@ -337,3 +338,108 @@ class MixAnswersTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
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"})
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
views.mix_answers,
|
||||||
name="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 (
|
from fupogfakta.models import (
|
||||||
Category,
|
Category,
|
||||||
GameSession,
|
GameSession,
|
||||||
|
Guess,
|
||||||
LieAnswer,
|
LieAnswer,
|
||||||
Player,
|
Player,
|
||||||
Question,
|
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],
|
"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