feat(ui): add MVP host/player web screens
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
CI / test-and-quality (pull_request) Successful in 1m25s

This commit is contained in:
2026-02-27 22:26:55 +01:00
parent d6e7198fe8
commit cfffc9934c
7 changed files with 159 additions and 2 deletions

18
docs/UI_SMOKE.md Normal file
View File

@@ -0,0 +1,18 @@
# UI smoke (MVP)
## Forudsætning
- Host er logget ind i Django.
- Mindst én aktiv kategori med spørgsmål findes.
## Flow
1. Åbn host-siden på /lobby/ui/host og tryk Opret session.
2. Åbn player-siden i 3 faner/enheder på /lobby/ui/player.
3. Join alle spillere med sessionkode og nickname.
4. Host: vælg kategori, Start runde, Vis spørgsmål.
5. Spillere: brug round_question_id og submit løgn.
6. Host: Mix svar.
7. Spillere: submit gæt.
8. Host: Beregn score og Vis scoreboard.
9. Host: Næste runde eller Afslut spil.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title></head>
<body>
<h1>Host panel (MVP)</h1>
<p>Kræver login som host-bruger.</p>
<button onclick="createSession()">1) Opret session</button>
<input id="code" placeholder="Sessionkode">
<select id="category">{% for c in categories %}<option value="{{ c.slug }}">{{ c.name }}</option>{% endfor %}</select>
<button onclick="startRound()">2) Start runde</button>
<button onclick="showQuestion()">3) Vis spørgsmål</button>
<input id="roundQuestionId" placeholder="Round question id">
<button onclick="mixAnswers()">4) Mix svar</button>
<button onclick="calcScores()">5) Beregn score</button>
<button onclick="showScoreboard()">6) Scoreboard</button>
<button onclick="nextRound()">7) Næste runde</button>
<button onclick="finishGame()">8) Afslut spil</button>
<button onclick="sessionDetail()">Session-status</button>
<pre id="out">Klar.</pre>
<script>
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}return d;}
function createSession(){return api("/lobby/sessions/create","POST",{});}
function sessionDetail(){return api("/lobby/sessions/"+code(),"GET",null);}
function startRound(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});}
function showQuestion(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});}
function mixAnswers(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});}
function calcScores(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});}
function showScoreboard(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);}
function nextRound(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});}
function finishGame(){return api("/lobby/sessions/"+code()+"/finish","POST",{});}
</script>
</body></html>

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Player</title></head>
<body>
<h1>Player panel (MVP)</h1>
<input id="code" placeholder="Sessionkode">
<input id="nickname" placeholder="Nickname">
<button onclick="joinSession()">1) Join</button>
<input id="playerId" placeholder="Player id">
<input id="roundQuestionId" placeholder="Round question id">
<input id="lieText" placeholder="Din løgn">
<button onclick="submitLie()">2) Submit løgn</button>
<input id="guessText" placeholder="Dit gæt">
<button onclick="submitGuess()">3) Submit gæt</button>
<button onclick="sessionDetail()">Opdater session-status</button>
<pre id="out">Klar.</pre>
<script>
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}return d;}
function joinSession(){return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()});}
function sessionDetail(){return api("/lobby/sessions/"+code(),"GET",null);}
function submitLie(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),text:document.getElementById("lieText").value});}
function submitGuess(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),selected_text:document.getElementById("guessText").value});}
</script>
</body></html>

View File

@@ -664,3 +664,50 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Next round can only start from reveal phase") self.assertEqual(response.json()["error"], "Next round can only start from reveal phase")
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")
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")
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)

14
lobby/ui_views.py Normal file
View File

@@ -0,0 +1,14 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from fupogfakta.models import Category
@login_required
def host_screen(request):
categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories})
def player_screen(request):
return render(request, "lobby/player_screen.html")

View File

@@ -1,10 +1,12 @@
from django.urls import path from django.urls import path
from . import views from . import ui_views, views
app_name = "lobby" app_name = "lobby"
urlpatterns = [ urlpatterns = [
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"), path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"), path("sessions/join", views.join_session, name="join_session"),
path("sessions/<str:code>", views.session_detail, name="session_detail"), path("sessions/<str:code>", views.session_detail, name="session_detail"),
@@ -34,4 +36,3 @@ urlpatterns = [
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"), path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"), path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
] ]

View File

@@ -133,6 +133,22 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
) )
) )
current_round_question = (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
.select_related("question")
.order_by("-id")
.first()
)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
}
return JsonResponse( return JsonResponse(
{ {
"session": { "session": {
@@ -143,6 +159,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players), "players_count": len(players),
}, },
"players": players, "players": players,
"round_question": round_question_payload,
} }
) )