new _room_gear.html to manage room actions for various gamers (e.g., founders & guests); new _room.scss for gatekeeper styling (still flimsy); added new .btn-abandon Bl-btn palette to _button-pad.scss; new FTs & epic view ITs assert functionality (100 percent coverage, fully passing test suite)

This commit is contained in:
Disco DeDisco
2026-03-14 00:10:40 -04:00
parent dddffd22d5
commit af3523c9bb
10 changed files with 297 additions and 1 deletions

View File

@@ -2,7 +2,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.lyric.models import User from apps.lyric.models import User
from apps.epic.models import Room from apps.epic.models import Room, RoomInvite
class RoomCreationViewTest(TestCase): class RoomCreationViewTest(TestCase):
@@ -74,3 +74,58 @@ class GateStatusViewTest(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "gate-modal") self.assertContains(response, "gate-modal")
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")
self.gamer = User.objects.create(email="gamer@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
self.slot = self.room.gate_slots.get(slot_number=2)
self.slot.gamer = self.gamer
self.slot.status = "FILLED"
self.slot.save()
RoomInvite.objects.create(
room=self.room, inviter=self.owner,
invitee_email=self.gamer.email
)
def test_owner_delete_removes_room(self):
self.client.force_login(self.owner)
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
self.assertFalse(Room.objects.filter(pk=self.room.pk).exists())
def test_non_owner_delete_does_not_remove_room(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
self.assertTrue(Room.objects.filter(pk=self.room.pk).exists())
def test_delete_redirects_to_gameboard(self):
self.client.force_login(self.owner)
response = self.client.post(
reverse("epic:delete_room", kwargs={"room_id": self.room.id})
)
self.assertRedirects(response, "/gameboard/")
def test_abandon_clears_slot(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, "EMPTY")
self.assertIsNone(self.slot.gamer)
def test_abandon_deletes_pending_invite(self):
self.client.force_login(self.gamer)
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
self.assertFalse(
RoomInvite.objects.filter(
room=self.room, invitee_email=self.gamer.email
).exists()
)
def test_abandon_redirects_to_gameboard(self):
self.client.force_login(self.gamer)
response = self.client.post(
reverse("epic:abandon_room", kwargs={"room_id": self.room.id})
)
self.assertRedirects(response, "/gameboard/")

View File

@@ -10,5 +10,7 @@ urlpatterns = [
path('room/<uuid:room_id>/gate/<int:slot_number>/drop_token', views.drop_token, name='drop_token'), path('room/<uuid:room_id>/gate/<int:slot_number>/drop_token', views.drop_token, name='drop_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>/abandon', views.abandon_room, name='abandon_room'),
] ]

View File

@@ -56,6 +56,27 @@ def invite_gamer(request, room_id):
) )
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def delete_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if request.user == room.owner:
room.delete()
return redirect("/gameboard/")
@login_required
def abandon_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
room.gate_slots.filter(gamer=request.user).update(
gamer=None, status="EMPTY", filled_at=None
)
room.invites.filter(
invitee_email=request.user.email,
status=RoomInvite.PENDING
).delete()
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:

View File

@@ -176,3 +176,45 @@ class GatekeeperTest(FunctionalTest):
# Restore the following once room built # Restore the following once room built
# body = self.browser.find_element(By.TAG_NAME, "body") # body = self.browser.find_element(By.TAG_NAME, "body")
# self.assertIn("OPEN", body.text) # self.assertIn("OPEN", body.text)
def test_owner_can_delete_room_via_gear_menu(self):
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_new_game_name"))
self.browser.find_element(By.ID, "id_new_game_name").send_keys("Doomed Room")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(lambda: self.assertIn("/gate/", self.browser.current_url))
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-danger")
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.current_url, self.live_server_url + "/gameboard/"
))
self.assertFalse(Room.objects.filter(name="Doomed Room").exists())
def test_gamer_can_abandon_room_via_gear_menu(self):
founder = User.objects.create(email="founder@test.io")
room = Room.objects.create(name="Dragon's Den", owner=founder)
slot = room.gate_slots.get(slot_number=2)
self.create_pre_authenticated_session("gamer@test.io")
gamer, _ = User.objects.get_or_create(email="gamer@test.io")
slot.gamer = gamer
slot.status = "FILLED"
slot.save()
self.browser.get(self.live_server_url + f"/gameboard/room/{room.id}/gate/")
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.current_url, self.live_server_url + "/gameboard/"
))
slot.refresh_from_db()
self.assertEqual(slot.status, "EMPTY")
self.assertIsNone(slot.gamer)

View File

@@ -75,6 +75,7 @@
#id_dash_applet_menu { @extend %applet-menu; } #id_dash_applet_menu { @extend %applet-menu; }
#id_game_applet_menu { @extend %applet-menu; } #id_game_applet_menu { @extend %applet-menu; }
#id_wallet_applet_menu { @extend %applet-menu; } #id_wallet_applet_menu { @extend %applet-menu; }
#id_room_menu { @extend %applet-menu; }
// ── Applets grid (shared across all boards) ──────────────── // ── Applets grid (shared across all boards) ────────────────
%applets-grid { %applets-grid {

View File

@@ -90,6 +90,41 @@
} }
} }
&.btn-abandon {
color: rgba(var(--priBl), 1);
border-color: rgba(var(--priBl), 1);
background-color: rgba(var(--terBl), 1);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terBl), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terBl), 0.12)
;
&:hover {
text-shadow:
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
0 0 1rem rgba(var(--priBl), 1)
;
box-shadow:
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priBl), 0.12)
;
}
&:active {
border: 0.18rem solid rgba(var(--priBl), 1);
text-shadow:
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
0 0 0.12rem rgba(var(--priBl), 1)
;
box-shadow:
-0.1rem -0.1rem 0.12rem rgba(var(--terBl), 0.25),
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--priBl), 0.12)
;
}
}
&.btn-cancel { &.btn-cancel {
color: rgba(var(--priOr), 1); color: rgba(var(--priOr), 1);
border-color: rgba(var(--priOr), 1); border-color: rgba(var(--priOr), 1);

View File

@@ -0,0 +1,119 @@
$gate-node: 64px;
$gate-gap: 36px;
$gate-line: 2px;
.room-page {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.room-page .gear-btn,
#id_room_menu {
z-index: 101;
}
.gate-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
}
.gate-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem;
border-radius: 1rem;
background-color: rgba(var(--priUser), 1);
.gate-header {
text-align: center;
h1 { margin: 0; }
.gate-status {
opacity: 0.5;
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.15em;
}
}
.gate-slots {
display: flex;
flex-direction: row;
align-items: center;
gap: $gate-gap;
.gate-slot {
position: relative;
width: $gate-node;
height: $gate-node;
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.filled {
background: rgba(var(--terUser), 0.2);
}
.slot-number {
font-size: 0.7em;
opacity: 0.5;
}
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
}
.drop-token-btn {
position: absolute;
inset: 0;
border-radius: 50%;
width: 100%;
height: 100%;
background: transparent;
border: none;
font-size: 0;
cursor: pointer;
&:hover {
background: rgba(var(--terUser), 0.15);
}
}
}
}
}
// Mobile: 2×3 grid, both rows left-to-right
@media (max-width: 550px) {
.gate-modal .gate-slots {
display: grid;
grid-template-columns: repeat(3, $gate-node);
grid-template-rows: repeat(2, $gate-node);
gap: $gate-gap;
.gate-slot {
&:nth-child(1) { grid-column: 1; grid-row: 1; }
&:nth-child(2) { grid-column: 2; grid-row: 1; }
&:nth-child(3) { grid-column: 3; grid-row: 1; }
&:nth-child(4) { grid-column: 1; grid-row: 2; }
&:nth-child(5) { grid-column: 2; grid-row: 2; }
&:nth-child(6) { grid-column: 3; grid-row: 2; }
}
}
}

View File

@@ -5,6 +5,7 @@
@import 'dashboard'; @import 'dashboard';
@import 'gameboard'; @import 'gameboard';
@import 'palette-picker'; @import 'palette-picker';
@import 'room';
@import 'wallet-tokens'; @import 'wallet-tokens';

View File

@@ -0,0 +1,16 @@
{% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %}
<div id="id_room_menu" style="display:none">
<a href="/gameboard/" class="btn btn-cancel">EXIT</a>
{% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:delete_room' room.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger">DEL</button>
</form>
{% else %}
<form method="POST" action="{% url 'epic:abandon_room' room.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-abandon">BYE</button>
</form>
{% endif %}
</div>

View File

@@ -1,5 +1,8 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span>room{% endblock header_text %}
{% block content %} {% block content %}
<div class="room-page"> <div class="room-page">
<div class="room-shell"> <div class="room-shell">
@@ -19,5 +22,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% include "apps/gameboard/_partials/_room_gear.html" %}
</div> </div>
{% endblock content %} {% endblock content %}