various styling & structural changes to unify site themes; token-drop interaction changes across epic urls & views
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Disco DeDisco
2026-03-14 02:03:44 -04:00
parent d9feb80b2a
commit f76c6d0fe5
8 changed files with 618 additions and 90 deletions

View File

@@ -1,8 +1,9 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import User from apps.lyric.models import Token, User
from apps.epic.models import Room, RoomInvite from apps.epic.models import GateSlot, Room, RoomInvite
class RoomCreationViewTest(TestCase): class RoomCreationViewTest(TestCase):
@@ -76,6 +77,158 @@ class GateStatusViewTest(TestCase):
self.assertContains(response, "gate-modal") self.assertContains(response, "gate-modal")
class DropTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
def test_drop_token_reserves_lowest_empty_slot(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertEqual(slot.status, GateSlot.RESERVED)
self.assertEqual(slot.gamer, self.gamer)
def test_drop_token_skips_already_filled_slots(self):
other = User.objects.create(email="other@test.io")
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = other
slot1.status = GateSlot.FILLED
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.RESERVED)
self.assertEqual(slot2.gamer, self.gamer)
def test_drop_token_blocked_when_another_slot_reserved(self):
other = User.objects.create(email="other@test.io")
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = other
slot1.status = GateSlot.RESERVED
slot1.reserved_at = timezone.now()
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
# Slot 2 should remain EMPTY — lock held by other user
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.EMPTY)
def test_drop_token_blocked_when_user_already_has_filled_slot(self):
slot1 = self.room.gate_slots.get(slot_number=1)
slot1.gamer = self.gamer
slot1.status = GateSlot.FILLED
slot1.save()
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot2 = self.room.gate_slots.get(slot_number=2)
self.assertEqual(slot2.status, GateSlot.EMPTY)
def test_drop_token_sets_reserved_at(self):
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
slot = self.room.gate_slots.get(slot_number=1)
self.assertIsNotNone(slot.reserved_at)
def test_drop_token_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
class ConfirmTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
Token.objects.create(user=self.gamer, token_type=Token.FREE)
def test_confirm_marks_slot_filled(self):
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
def test_confirm_sets_gate_open_when_all_slots_filled(self):
# Fill slots 26 via ORM
for i in range(2, 7):
other = User.objects.create(email=f"g{i}@test.io")
s = self.room.gate_slots.get(slot_number=i)
s.gamer = other
s.status = GateSlot.FILLED
s.save()
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.OPEN)
def test_confirm_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_confirm_does_nothing_without_reserved_slot(self):
self.slot.status = GateSlot.EMPTY
self.slot.gamer = None
self.slot.save()
self.client.post(
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
class RejectTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
owner = User.objects.create(email="owner@test.io")
self.room = Room.objects.create(name="Test Room", owner=owner)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.gamer
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
def test_reject_clears_reserved_slot(self):
self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
self.assertIsNone(self.slot.reserved_at)
def test_reject_after_confirm_clears_filled_slot(self):
self.slot.status = GateSlot.FILLED
self.slot.save()
self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
def test_reject_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
class RoomActionsViewTest(TestCase): class RoomActionsViewTest(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create(email="owner@test.io") self.owner = User.objects.create(email="owner@test.io")

View File

@@ -7,10 +7,11 @@ app_name = 'epic'
urlpatterns = [ urlpatterns = [
path('rooms/create_room', views.create_room, name='create_room'), path('rooms/create_room', views.create_room, name='create_room'),
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'), path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/<int:slot_number>/drop_token', views.drop_token, name='drop_token'), path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/reject_token', views.reject_token, name='reject_token'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'), path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'), path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'), path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'), path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
] ]

View File

@@ -1,11 +1,56 @@
from datetime import timedelta
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone
from apps.epic.models import Room, RoomInvite, debit_token from apps.epic.models import GateSlot, Room, RoomInvite, debit_token
from apps.lyric.models import Token from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60)
def _expire_reserved_slots(room):
cutoff = timezone.now() - RESERVE_TIMEOUT
room.gate_slots.filter(
status=GateSlot.RESERVED,
reserved_at__lt=cutoff,
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
def _gate_context(room, user):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
user_reserved_slot = None
user_filled_slot = None
if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
can_drop = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
)
is_last_slot = (
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
)
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None
return {
"slots": slots,
"pending_slot": pending_slot,
"user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot,
"can_drop": can_drop,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
}
@login_required @login_required
def create_room(request): def create_room(request):
if request.method == "POST": if request.method == "POST":
@@ -15,24 +60,41 @@ def create_room(request):
return redirect("epic:gatekeeper", room_id=room.id) return redirect("epic:gatekeeper", room_id=room.id)
return redirect("/gameboard/") return redirect("/gameboard/")
def gatekeeper(request, room_id): def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
slots = room.gate_slots.order_by("slot_number") ctx = _gate_context(room, request.user)
user_has_slot = ( ctx["room"] = room
request.user.is_authenticated return render(request, "apps/gameboard/room.html", ctx)
and room.gate_slots.filter(gamer=request.user).exists()
)
return render(request, "apps/gameboard/room.html", {
"room": room,
"slots": slots,
"user_has_slot": user_has_slot,
})
@login_required @login_required
def drop_token(request, room_id, slot_number): def drop_token(request, room_id):
if request.method == "POST": if request.method == "POST":
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
slot = room.gate_slots.get(slot_number=slot_number) if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
if slot:
slot.gamer = request.user
slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now()
slot.save()
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def confirm_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token = ( token = (
request.user.tokens.filter(token_type=Token.COIN).first() request.user.tokens.filter(token_type=Token.COIN).first()
or request.user.tokens.filter(token_type=Token.FREE).first() or request.user.tokens.filter(token_type=Token.FREE).first()
@@ -42,6 +104,24 @@ def drop_token(request, room_id, slot_number):
debit_token(request.user, slot, token) debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def reject_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first()
if slot:
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.reserved_at = None
slot.filled_at = None
slot.save()
return redirect("epic:gatekeeper", room_id=room_id)
@login_required @login_required
def invite_gamer(request, room_id): def invite_gamer(request, room_id):
if request.method == "POST": if request.method == "POST":
@@ -56,6 +136,7 @@ def invite_gamer(request, room_id):
) )
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@login_required @login_required
def delete_room(request, room_id): def delete_room(request, room_id):
if request.method == "POST": if request.method == "POST":
@@ -64,6 +145,7 @@ def delete_room(request, room_id):
room.delete() room.delete()
return redirect("/gameboard/") return redirect("/gameboard/")
@login_required @login_required
def abandon_room(request, room_id): def abandon_room(request, room_id):
if request.method == "POST": if request.method == "POST":
@@ -77,17 +159,11 @@ def abandon_room(request, room_id):
).delete() ).delete()
return redirect("/gameboard/") return redirect("/gameboard/")
def gate_status(request, room_id): def gate_status(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN: if room.gate_status == Room.OPEN:
return HttpResponse("") return HttpResponse("")
slots = room.gate_slots.order_by("slot_number") ctx = _gate_context(room, request.user)
user_has_slot = ( ctx["room"] = room
request.user.is_authenticated return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
and slots.filter(gamer=request.user).exists()
)
return render(request, "apps/gameboard/_partials/_gatekeeper.html", {
"room": room,
"slots": slots,
"user_has_slot": user_has_slot,
})

View File

@@ -1,5 +1,7 @@
import time import time
from django.utils import timezone
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
@@ -39,14 +41,16 @@ class GatekeeperTest(FunctionalTest):
body = self.browser.find_element(By.TAG_NAME, "body") body = self.browser.find_element(By.TAG_NAME, "body")
self.assertIn("Test Room", body.text) self.assertIn("Test Room", body.text)
self.assertIn("GATHERING GAMERS", body.text) self.assertIn("GATHERING GAMERS", body.text)
# 5. Six token slots are visible # 5. Six token slot circles are visible, all empty
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertEqual(len(slots), 6) self.assertEqual(len(slots), 6)
# 6. Slot 1 has Drop Token btn; slots 26 show as empty for slot in slots:
slot_1 = slots[0]
slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn")
for slot in slots[1:]:
self.assertIn("empty", slot.get_attribute("class")) self.assertIn("empty", slot.get_attribute("class"))
# 6. Shared coin slot is present; no individual drop buttons
self.browser.find_element(By.CSS_SELECTOR, ".token-slot")
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0
)
def test_founder_drops_token_and_slot_fills(self): def test_founder_drops_token_and_slot_fills(self):
# 1. Set up: log in, create room, arrive at gatekeeper # 1. Set up: log in, create room, arrive at gatekeeper
@@ -60,20 +64,25 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url) lambda: self.assertIn("/gate/", self.browser.current_url)
) )
# 2. Founder clicks Drop Token on slot 1 # 2. Founder clicks Insert Token via the shared coin slot
drop_btn = self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn") lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
# 3. Slot 1 (lowest) now shows OK button; slot is reserved
ok_btn = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm"
) )
drop_btn.click() )
# 4. Founder clicks OK → slot fills
# 3. Slot 1 now filled; drop btn gone ok_btn.click()
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertIn("filled", slots[0].get_attribute("class")) self.assertIn("filled", slots[0].get_attribute("class"))
self.assertEqual( self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0 len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
) )
def test_room_appears_in_my_games_after_creation(self): def test_room_appears_in_my_games_after_creation(self):
@@ -96,9 +105,9 @@ class GatekeeperTest(FunctionalTest):
self.assertIn("Dragon's Den", my_games.text) self.assertIn("Dragon's Den", my_games.text)
def test_second_gamer_drops_token_into_open_slot(self): def test_second_gamer_drops_token_into_open_slot(self):
# 1. Founder creates room, fills slot 1 # 1. Founder creates room, confirms slot 1
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url +"/gameboard/") self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_new_game_name") lambda: self.browser.find_element(By.ID, "id_new_game_name")
) )
@@ -108,11 +117,16 @@ class GatekeeperTest(FunctionalTest):
lambda: self.assertIn("/gate/", self.browser.current_url) lambda: self.assertIn("/gate/", self.browser.current_url)
) )
room_url = self.browser.current_url room_url = self.browser.current_url
self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn").click() self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
# 2. Founder invites friend via email (duplicate invite logic from My Notes applet) # 2. Founder invites friend
invite_input = self.wait_for( invite_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_invite_email") lambda: self.browser.find_element(By.ID, "id_invite_email")
) )
@@ -125,14 +139,16 @@ class GatekeeperTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_applet_my_games") lambda: self.browser.find_element(By.ID, "id_applet_my_games")
) )
self.assertIn("Dragon's Den", my_games.text) self.assertIn("Dragon's Den", my_games.text)
# 3. Friend follows link to gatekeeper # 4. Friend follows link to gatekeeper
self.browser.find_element(By.LINK_TEXT, "Dragon's Den").click() self.browser.find_element(By.LINK_TEXT, "Dragon's Den").click()
# 4. Friend sees drop btn on open slot # 5. Friend drops token via coin slot and confirms
drop_btn = self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn") lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
) ).click()
drop_btn.click() self.wait_for(
# 5. Now two slots filled lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
# 6. Now two slots filled
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot.filled")), 2 len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot.filled")), 2
@@ -151,12 +167,17 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url) lambda: self.assertIn("/gate/", self.browser.current_url)
) )
room_url = self.browser.current_url # 2. Founder confirms slot 1 via coin slot
# 2. Fill all 6 slots directly via ORM (founder + 5 extras) self.wait_for(
self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn").click() lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
) )
# 3. Fill slots 26 directly via ORM
room = Room.objects.get(name="Dragon's Den") room = Room.objects.get(name="Dragon's Den")
for i, email in enumerate([ for i, email in enumerate([
"g2@test.io", "g3@test.io", "g4@test.io", "g5@test.io", "g6@test.io" "g2@test.io", "g3@test.io", "g4@test.io", "g5@test.io", "g6@test.io"
@@ -169,15 +190,12 @@ class GatekeeperTest(FunctionalTest):
room.refresh_from_db() room.refresh_from_db()
room.gate_status = Room.OPEN room.gate_status = Room.OPEN
room.save() room.save()
# 3. Gatekeeper disappears via htmx # 4. Gatekeeper disappears via htmx
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-modal")), 0 len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-modal")), 0
) )
) )
# Restore the following once room built
# body = self.browser.find_element(By.TAG_NAME, "body")
# self.assertIn("OPEN", body.text)
def test_owner_can_delete_room_via_gear_menu(self): def test_owner_can_delete_room_via_gear_menu(self):
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
@@ -237,3 +255,124 @@ class GatekeeperTest(FunctionalTest):
slot.refresh_from_db() slot.refresh_from_db()
self.assertEqual(slot.status, "EMPTY") self.assertEqual(slot.status, "EMPTY")
self.assertIsNone(slot.gamer) self.assertIsNone(slot.gamer)
class CoinSlotTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.create_pre_authenticated_session("founder@test.io")
self.founder = User.objects.get(email="founder@test.io")
self.room = Room.objects.create(name="Coin Room", owner=self.founder)
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
def test_coin_slot_active_for_eligible_gamer(self):
# Gamer with no slot arrives at gatekeeper — coin slot is active
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.active")
)
self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
def test_drop_token_reserves_lowest_empty_slot(self):
# Gamer drops token; slot 1 (lowest) becomes reserved with OK button
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm"
)
)
slot = self.room.gate_slots.get(slot_number=1)
slot.refresh_from_db()
self.assertEqual(slot.status, GateSlot.RESERVED)
self.assertEqual(slot.gamer, self.founder)
def test_confirm_fills_slot_and_removes_ok_button(self):
# Drop then confirm → slot 1 FILLED, OK button gone
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
)
slot = self.room.gate_slots.get(slot_number=1)
slot.refresh_from_db()
self.assertEqual(slot.status, GateSlot.FILLED)
def test_gamer_can_reject_pending_token(self):
# Drop then reject via Push to Reject → slot remains empty
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
# Push to Reject appears in coin slot
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-reject-btn")
).click()
# Slot 1 still empty; coin slot active again
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.active")
)
slot = self.room.gate_slots.get(slot_number=1)
slot.refresh_from_db()
self.assertEqual(slot.status, GateSlot.EMPTY)
def test_coin_slot_locked_while_another_token_is_pending(self):
# Pre-set slot 1 as RESERVED by a different user
other = User.objects.create(email="other@test.io")
slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = other
slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now()
slot.save()
# Current user (founder) sees coin slot locked
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.locked")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".token-insert-btn")), 0
)
def test_last_gamer_sees_pick_roles_button(self):
# Fill slots 15 via ORM; slot 6 empty
for i, email in enumerate([
"g1@test.io", "g2@test.io", "g3@test.io", "g4@test.io", "g5@test.io"
], start=1):
gamer = User.objects.create(email=email)
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
# Founder (no slot yet) drops token → gets slot 6
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn")
).click()
# Slot 6 shows PICK ROLES instead of OK
self.wait_for(
lambda: self.assertIn(
"PICK ROLES",
self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='6']"
).text,
)
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
)

View File

@@ -232,9 +232,13 @@ body {
.row .col-lg-6 h2 { .row .col-lg-6 h2 {
text-align: center; text-align: center;
text-align-last: center; text-align-last: center;
letter-spacing: 0.25em; letter-spacing: 0.33em;
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 2rem; font-size: 2rem;
&#id_dash_wallet {
letter-spacing: 0.25em;
}
} }
} }
} }

View File

@@ -37,7 +37,7 @@ $gate-line: 2px;
.gate-header { .gate-header {
text-align: center; text-align: center;
h1 { margin: 0; } h1 { margin: 0 0 0.5rem; }
.gate-status-wrap { .gate-status-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -58,6 +58,114 @@ $gate-line: 2px;
} }
} }
.token-slot {
position: relative;
display: flex;
flex-direction: row;
border: 2px solid rgba(var(--terUser), 0.7);
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.35);
min-width: 180px;
&.locked {
opacity: 0.3;
pointer-events: none;
}
&.pending,
&.claimed {
box-shadow:
0 0 0.6rem rgba(var(--terUser), 0.5),
0 0 1.4rem rgba(var(--terUser), 0.2),
;
.token-reject-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); }
&:hover {
border-color: rgba(var(--terUser), 1);
background: rgba(0, 0, 0, 0.55);
box-shadow:
0 0 0.8rem rgba(var(--terUser), 0.75),
0 0 2rem rgba(var(--terUser), 0.35),
;
}
}
.token-rails,
button.token-rails {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0.6rem 0.45rem;
gap: 0.2rem;
border-right: 1px solid rgba(var(--terUser), 0.35);
.rail {
display: block;
width: 2px;
background: rgba(var(--terUser), 0.55);
border-radius: 1px;
}
}
button.token-rails {
background: transparent;
border: none;
border-right: 1px solid rgba(var(--terUser), 0.35);
cursor: pointer;
border-radius: 0.3rem 0 0 0.3rem;
&:hover {
background: rgba(var(--terUser), 0.1);
.rail { background: rgba(var(--terUser), 1); }
}
}
.token-reject-overlay {
position: absolute;
inset: 0;
background: transparent;
border: none;
cursor: pointer;
border-radius: inherit;
}
.token-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.45rem 0.75rem;
gap: 0.15rem;
.token-denomination {
font-size: 1.5em;
font-weight: bold;
color: rgba(var(--terUser), 1);
line-height: 1;
}
.token-insert-label,
.token-insert-btn {
font-size: 0.6em;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
line-height: 1.3;
}
.token-reject-label,
.token-reject-btn {
font-size: 0.55em;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.5;
line-height: 1.3;
text-align: center;
}
}
}
.gate-slots { .gate-slots {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -76,10 +184,20 @@ $gate-line: 2px;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
&.filled { &.filled,
&.reserved {
background: rgba(var(--terUser), 0.2); background: rgba(var(--terUser), 0.2);
} }
&.filled:hover,
&.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
.slot-number { .slot-number {
font-size: 0.7em; font-size: 0.7em;
opacity: 0.5; opacity: 0.5;
@@ -90,6 +208,9 @@ $gate-line: 2px;
form { form {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex;
align-items: center;
justify-content: center;
} }
.drop-token-btn { .drop-token-btn {

View File

@@ -3,9 +3,9 @@
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2>New Game</h2> <h2>New Game</h2>
<form method="POST" action="{% url "epic:create_room" %}"> <form method="POST" action="{% url "epic:create_room" %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %} {% csrf_token %}
<input id="id_new_game_name" name="name" type="text" placeholder="Room name" /> <input id="id_new_game_name" name="name" type="text" placeholder="Room name" class="form-control form-control-lg" style="flex:1; min-width:0;" />
<button type="submit" id="id_create_game_btn" class="btn btn-confirm">OK</button> <button type="submit" id="id_create_game_btn" class="btn btn-confirm">OK</button>
</form> </form>
</section> </section>

View File

@@ -6,6 +6,7 @@
> >
<div class="gate-overlay"> <div class="gate-overlay">
<div class="gate-modal" role="dialog" aria-label="Gatekeeper"> <div class="gate-modal" role="dialog" aria-label="Gatekeeper">
<header class="gate-header"> <header class="gate-header">
<h1>{{ room.name }}</h1> <h1>{{ room.name }}</h1>
<div class="gate-status-wrap"> <div class="gate-status-wrap">
@@ -14,7 +15,74 @@
<span></span><span></span><span></span><span></span> <span></span><span></span><span></span><span></span>
</span> </span>
</div> </div>
<script> </header>
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% else %} locked{% endif %}">
{% if can_drop %}
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
{% csrf_token %}
<button type="submit" class="token-rails" aria-label="Insert token to play">
<span class="rail"></span>
<span class="rail"></span>
</button>
</form>
{% else %}
<div class="token-rails">
<span class="rail"></span>
<span class="rail"></span>
</div>
{% endif %}
<div class="token-panel">
<div class="token-denomination">1</div>
<span class="token-insert-label">INSERT TOKEN TO PLAY</span>
<span class="token-reject-label">PUSH TO REJECT</span>
</div>
{% if user_can_reject %}
<form method="POST" action="{% url 'epic:reject_token' room.id %}" style="display:contents">
{% csrf_token %}
<button type="submit" class="token-reject-overlay" aria-label="Push to reject"></button>
</form>
{% endif %}
</div>
<div class="gate-slots">
{% for slot in slots %}
<div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
data-slot="{{ slot.slot_number }}"
>
<span class="slot-number">{{ slot.slot_number }}</span>
{% if slot.gamer %}
<span class="slot-gamer">{{ slot.gamer.email }}</span>
{% else %}
<span class="slot-gamer">empty</span>
{% endif %}
{% if slot.status == 'RESERVED' and slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
{% if is_last_slot %}
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
{% else %}
<button type="submit" class="btn btn-confirm">OK</button>
{% endif %}
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" placeholder="friend@example.com">
<button type="submit" id="id_invite_btn" class="btn btn-primary btn-xl">Invite</button>
</form>
{% endif %}
</div>
</div>
</div>
<script>
(function () { (function () {
clearInterval(window._gateDots); clearInterval(window._gateDots);
var wrap = document.querySelector('.status-dots'); var wrap = document.querySelector('.status-dots');
@@ -27,38 +95,4 @@
n = (n + 1) % 5; n = (n + 1) % 5;
}, 400); }, 400);
}()); }());
</script> </script>
</header>
<div class="gate-slots">
{% for slot in slots %}
<div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% endif %}"
data-slot="{{ slot.slot_number }}"
>
<span class="slot-number">{{ slot.slot_number }}</span>
{% if slot.gamer %}
<span class="slot-gamer">{{ slot.gamer.email }}</span>
{% else %}
<span class="slot-gamer">empty</span>
{% endif %}
{% if slot.status == 'EMPTY' and request.user.is_authenticated and not user_has_slot %}
<form method="POST" action="{% url "epic:drop_token" room.id slot.slot_number %}">
{% csrf_token %}
<button type="submit" class="btn drop-token-btn btn-primary btn-xl">Drop Token</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" placeholder="friend@example.com">
<button type="submit" id="id_invite_btn" class="btn btn-primary btn-xl">Invite</button>
</form>
{% endif %}
</div>
</div>
</div>