From af3523c9bb7fdf0d4957e6828c1394ba39af2367 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 14 Mar 2026 00:10:40 -0400 Subject: [PATCH] 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) --- src/apps/epic/tests/integrated/test_views.py | 57 ++++++++- src/apps/epic/urls.py | 2 + src/apps/epic/views.py | 21 ++++ src/functional_tests/test_gatekeeper.py | 42 +++++++ src/static_src/scss/_applets.scss | 1 + src/static_src/scss/_button-pad.scss | 35 ++++++ src/static_src/scss/_room.scss | 119 ++++++++++++++++++ src/static_src/scss/core.scss | 1 + .../apps/gameboard/_partials/_room_gear.html | 16 +++ src/templates/apps/gameboard/room.html | 4 + 10 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/static_src/scss/_room.scss create mode 100644 src/templates/apps/gameboard/_partials/_room_gear.html diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 48ebd18..5d8deea 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.urls import reverse from apps.lyric.models import User -from apps.epic.models import Room +from apps.epic.models import Room, RoomInvite class RoomCreationViewTest(TestCase): @@ -74,3 +74,58 @@ class GateStatusViewTest(TestCase): ) self.assertEqual(response.status_code, 200) 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/") diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 39ad946..5694b5b 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -10,5 +10,7 @@ urlpatterns = [ path('room//gate//drop_token', views.drop_token, name='drop_token'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), + path('room//delete', views.delete_room, name='delete_room'), + path('room//abandon', views.abandon_room, name='abandon_room'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index c29af63..d10f01f 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -56,6 +56,27 @@ def invite_gamer(request, 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): room = Room.objects.get(id=room_id) if room.gate_status == Room.OPEN: diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index b9b28fc..da503e3 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -176,3 +176,45 @@ class GatekeeperTest(FunctionalTest): # 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): + 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) diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index ee3acd5..56a08e6 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -75,6 +75,7 @@ #id_dash_applet_menu { @extend %applet-menu; } #id_game_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 { diff --git a/src/static_src/scss/_button-pad.scss b/src/static_src/scss/_button-pad.scss index b3548e1..f55b9b4 100644 --- a/src/static_src/scss/_button-pad.scss +++ b/src/static_src/scss/_button-pad.scss @@ -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 { color: rgba(var(--priOr), 1); border-color: rgba(var(--priOr), 1); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss new file mode 100644 index 0000000..b271825 --- /dev/null +++ b/src/static_src/scss/_room.scss @@ -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; } + } + } +} diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index 86bbd73..5cbbfc6 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -5,6 +5,7 @@ @import 'dashboard'; @import 'gameboard'; @import 'palette-picker'; +@import 'room'; @import 'wallet-tokens'; diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html new file mode 100644 index 0000000..79fd10d --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -0,0 +1,16 @@ +{% include "apps/applets/_partials/_gear.html" with menu_id="id_room_menu" %} + + diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 739ee6d..05c6072 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -1,5 +1,8 @@ {% extends "core/base.html" %} +{% block title_text %}Gameboard{% endblock title_text %} +{% block header_text %}Gameroom{% endblock header_text %} + {% block content %}
@@ -19,5 +22,6 @@
{% endif %}
+ {% include "apps/gameboard/_partials/_room_gear.html" %} {% endblock content %} \ No newline at end of file