new model fields & migrations for apps.epic & apps.lyric; new FTs, ITs & UTs passing

; some styling changes effected primarily to _gatekeetper.html modal
This commit is contained in:
Disco DeDisco
2026-03-14 22:00:16 -04:00
parent 26b6d4e7db
commit 4baaa63430
13 changed files with 410 additions and 28 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0003_roominvite'),
]
operations = [
migrations.AlterField(
model_name='room',
name='gate_status',
field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20),
),
]

View File

@@ -93,13 +93,30 @@ def create_gate_slots(sender, instance, created, **kwargs):
GateSlot.objects.create(room=instance, slot_number=i)
def select_token(user):
if user.is_staff:
pass_token = user.tokens.filter(token_type=Token.PASS).first()
if pass_token:
return pass_token
coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first()
if coin:
return coin
free = user.tokens.filter(
token_type=Token.FREE,
expires_at__gt=timezone.now(),
).order_by("expires_at").first()
if free:
return free
return user.tokens.filter(token_type=Token.TITHE).first()
def debit_token(user, slot, token):
if token.token_type == Token.COIN:
token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
else:
elif token.token_type != Token.PASS:
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED

View File

@@ -2,9 +2,10 @@ from datetime import timedelta
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
class RoomCreationTest(TestCase):
@@ -77,6 +78,60 @@ class CoinTokenInUseTest(TestCase):
self.assertIn(self.room.name, html)
class SelectTokenTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.other_room = Room.objects.create(name="Other Room", owner=self.user)
self.coin = Token.objects.get(user=self.user, token_type=Token.COIN)
def test_returns_coin_when_available(self):
token = select_token(self.user)
self.assertEqual(token.token_type, Token.COIN)
def test_returns_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
token = select_token(self.user)
self.assertEqual(token.token_type, Token.FREE)
def test_free_token_selection_is_fefo(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
soon = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=2),
)
Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=6),
)
token = select_token(self.user)
self.assertEqual(token.pk, soon.pk)
def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
token = select_token(self.user)
self.assertEqual(token.pk, tithe.pk)
def test_returns_none_when_all_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
token = select_token(self.user)
self.assertIsNone(token)
def test_returns_pass_for_staff(self):
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")

View File

@@ -229,6 +229,81 @@ class RejectTokenViewTest(TestCase):
)
class DropTokenAvailabilityViewTest(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.other_room = Room.objects.create(name="Other Room", owner=owner)
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_drop_reserves_slot_when_tokens_available(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)
# token not debited yet — that happens at confirm
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
def test_drop_returns_402_when_all_tokens_depleted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
)
self.assertEqual(response.status_code, 402)
class ConfirmTokenPriorityViewTest(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.other_room = Room.objects.create(name="Other 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()
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_confirm_leases_coin_to_room(self):
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.room)
self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())
def test_confirm_uses_free_token_when_coin_in_use(self):
self.coin.current_room = self.other_room
self.coin.save()
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertEqual(
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.other_room)
def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
self.coin.current_room = self.other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_pass_not_consumed_and_coin_not_leased(self):
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")

View File

@@ -5,8 +5,7 @@ from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token
from apps.lyric.models import Token
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
RESERVE_TIMEOUT = timedelta(seconds=60)
@@ -29,12 +28,14 @@ def _gate_context(room, user):
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 = (
eligible = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
)
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
is_last_slot = (
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
@@ -46,6 +47,7 @@ def _gate_context(room, user):
"user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot,
"can_drop": can_drop,
"token_depleted": token_depleted,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
}
@@ -76,6 +78,8 @@ def drop_token(request, room_id):
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)
if select_token(request.user) is None:
return HttpResponse(status=402)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
@@ -95,11 +99,7 @@ def confirm_token(request, room_id):
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token = (
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.TITHE).first()
)
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-03-15 00:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0008_token_current_room_token_next_ready_at'),
]
operations = [
migrations.AlterField(
model_name='token',
name='token_type',
field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass')], max_length=8),
),
]

View File

@@ -71,10 +71,12 @@ class Token(models.Model):
COIN = "coin"
FREE = "Free"
TITHE = "tithe"
PASS = "pass"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
@@ -97,8 +99,8 @@ class Token(models.Model):
return ""
def tooltip_expiry(self):
if self.token_type == self.COIN:
if self.next_ready_at:
if self.token_type in (self.COIN, self.PASS):
if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry"
if self.expires_at:
@@ -143,3 +145,5 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
if instance.is_staff:
Token.objects.create(user=instance, token_type=Token.PASS)

View File

@@ -97,6 +97,30 @@ class TokenCreationTest(TestCase):
self.assertLessEqual(delta.days, 7)
self.assertGreater(delta.total_seconds(), 0)
def test_no_pass_token_for_regular_user(self):
self.assertFalse(
Token.objects.filter(user=self.user, token_type=Token.PASS).exists()
)
class SuperuserTokenCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
email="admin@test.io", password="secret"
)
def test_pass_token_created_for_superuser(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.PASS).exists()
)
def test_superuser_also_gets_coin_and_free_token(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.COIN).exists()
)
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.FREE).exists()
)
class WalletTooltipTest(TestCase):
def setUp(self):

View File

@@ -41,3 +41,17 @@ class FreeTokenTooltipTest(SimpleTestCase):
def test_tooltip_contains_expiry_date(self):
self.assertIn("2026-03-15", self.token.tooltip_text())
class PassTokenTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()
self.token.token_type = Token.PASS
self.token.expires_at = None
self.token.next_ready_at = None
def test_tooltip_contains_name(self):
self.assertIn("Backstage Pass", self.token.tooltip_text())
def test_tooltip_contains_no_expiry(self):
self.assertIn("no expiry", self.token.tooltip_text())

View File

@@ -1,13 +1,13 @@
import time
from datetime import timedelta
from django.utils import timezone
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.epic.models import Room, GateSlot
from apps.lyric.models import User
from apps.epic.models import Room, GateSlot, select_token
from apps.lyric.models import Token, User
class GatekeeperTest(FunctionalTest):
@@ -39,7 +39,7 @@ class GatekeeperTest(FunctionalTest):
)
# 4. Page shows room name, GATHERING status
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)
# 5. Six token slot circles are visible, all empty
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
@@ -82,7 +82,7 @@ class GatekeeperTest(FunctionalTest):
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertIn("filled", slots[0].get_attribute("class"))
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
)
def test_room_appears_in_my_games_after_creation(self):
@@ -308,7 +308,7 @@ class CoinSlotTest(FunctionalTest):
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
)
slot = self.room.gate_slots.get(slot_number=1)
slot.refresh_from_db()
@@ -374,5 +374,130 @@ class CoinSlotTest(FunctionalTest):
)
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0
)
class TokenPriorityTest(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("gamer@test.io")
self.gamer = User.objects.get(email="gamer@test.io")
self.room = Room.objects.create(name="Token Room", owner=self.gamer)
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
def test_coin_is_used_by_default(self):
# 1. COIN token created at signup, not yet leased to a room
self.assertEqual(self.coin.token_type, Token.COIN)
self.assertIsNone(self.coin.current_room)
# 2. Gamer drops token and confirms
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).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")
)
# 3. Coin is now leased to this room, page not refreshed
self.assertEqual(self.browser.current_url, self.gate_url)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, self.room)
def test_free_token_used_when_coin_in_use(self):
# 1. Coin already leased to another room
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
# 2. Gamer has one unexpired free token (signup gives one; delete it and add fresh)
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
Token.objects.create(
user=self.gamer,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
# 3. Gamer drops token → Free Token consumed
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).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(
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
)
# 4. Coin untouched, still leased to other room
self.assertEqual(self.browser.current_url, self.gate_url)
self.coin.refresh_from_db()
self.assertEqual(self.coin.current_room, other_room)
def test_tithe_token_used_when_free_tokens_exhausted(self):
# 1. Coin in use, no Free Tokens, one Tithe Token
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
# 2. Gamer drops token → tithe consumed
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).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")
)
# Tithe row deleted, page hasn't refreshed
self.assertEqual(self.browser.current_url, self.gate_url)
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_slot_blocked_when_no_tokens_available(self):
# Coin in use, no Free Tokens, no Tithe Tokens → depleted state
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
self.coin.current_room = other_room
self.coin.save()
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.depleted")
)
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "button.token-rails")), 0
)
def test_staff_backstage_pass_bypasses_token_cost(self):
# 1. Staff user has a PASS token
self.gamer.is_staff = True
self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
# 2. Drops token, confirms as normal
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).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")
)
# 3. Pass not consumed, coin not leased; no reload
self.assertEqual(self.browser.current_url, self.gate_url)
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()
self.assertIsNone(self.coin.current_room)

View File

@@ -30,14 +30,33 @@ $gate-line: 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem;
border: 0.1rem solid rgba(var(--terUser), 0.5);
border-radius: 1rem;
background-color: rgba(var(--priUser), 1);
.gate-header {
text-align: center;
h1 { margin: 0 0 0.5rem; }
h1 {
font-size: 2rem;
color: rgba(var(--secUser), 0.6);
margin-bottom: 1rem;
text-align: justify;
text-align-last: justify;
text-justify: inter-character;
text-transform: uppercase;
text-shadow:
1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
-0.125rem -0.125rem 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
;
span {
color: rgba(var(--quaUser), 0.6);
}
margin: 0 0 0.5rem;
}
.gate-status-wrap {
display: flex;
justify-content: center;
@@ -46,6 +65,7 @@ $gate-line: 2px;
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 1rem;
.status-dots {
display: inline-flex;

View File

@@ -17,7 +17,7 @@
</div>
</header>
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% else %} locked{% endif %}">
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% elif token_depleted %} depleted{% else %} locked{% endif %}">
{% if can_drop %}
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
{% csrf_token %}
@@ -45,7 +45,7 @@
{% endif %}
</div>
<div class="gate-slots">
<div class="gate-slots row">
{% for slot in slots %}
<div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
@@ -72,11 +72,14 @@
</div>
{% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}">
<div class="form-container">
<h3>Invite Friend</h3>
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" hx-preserve>
<button type="submit" id="id_invite_btn" class="btn btn-primary btn-xl">Invite</button>
<input type="email" name="invitee_email" id="id_invite_email" class="form-control form-control-lg" placeholder="friend@example.com" style="flex:1; min-width:0;" hx-preserve>
<button type="submit" id="id_invite_btn" class="btn btn-confirm">OK</button>
</form>
</div>
{% endif %}
</div>

View File

@@ -29,6 +29,15 @@
<p class="expiry">{{ token.tooltip_expiry }}</p>
</div>
</div>
{% empty %}
<div id="id_free_token_empty" class="token token--empty">
<i class="fa-solid fa-coins"></i>
<div class="token-tooltip">
<h4>Free Token</h4>
<p>0 owned</p>
<p class="expiry">find one around</p>
</div>
</div>
{% endfor %}
{% for token in tithe_tokens %}
<div id="id_tithe_token_{{ forloop.counter0 }}" class="token">