diff --git a/src/core/context_processors.py b/src/core/context_processors.py index 5b629a8..9def416 100644 --- a/src/core/context_processors.py +++ b/src/core/context_processors.py @@ -1,4 +1,30 @@ def user_palette(request): if request.user.is_authenticated: return {"user_palette": request.user.palette} - return {"user_palette": "palette-default"} \ No newline at end of file + return {"user_palette": "palette-default"} + + +def navbar_context(request): + if not request.user.is_authenticated: + return {} + from django.db.models import Max, Q + from django.urls import reverse + from apps.epic.models import Room + + recent_room = ( + Room.objects.filter( + Q(owner=request.user) | Q(gate_slots__gamer=request.user) + ) + .annotate(last_event=Max("events__timestamp")) + .filter(last_event__isnull=False) + .order_by("-last_event") + .distinct() + .first() + ) + if recent_room is None: + return {} + if recent_room.table_status: + url = reverse("epic:room", args=[recent_room.id]) + else: + url = reverse("epic:gatekeeper", args=[recent_room.id]) + return {"navbar_recent_room_url": url} \ No newline at end of file diff --git a/src/core/settings.py b/src/core/settings.py index 746c954..34f1865 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -102,6 +102,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'core.context_processors.user_palette', + 'core.context_processors.navbar_context', ], }, }, diff --git a/src/core/tests/unit/test_context_processors.py b/src/core/tests/unit/test_context_processors.py new file mode 100644 index 0000000..1749504 --- /dev/null +++ b/src/core/tests/unit/test_context_processors.py @@ -0,0 +1,109 @@ +from datetime import timedelta +from unittest.mock import MagicMock + +from django.test import TestCase, RequestFactory +from django.utils import timezone + +from apps.drama.models import GameEvent, record +from apps.epic.models import Room +from apps.lyric.models import User + +from core.context_processors import navbar_context + + +class NavbarContextProcessorTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def _anon_request(self): + req = self.factory.get("/") + req.user = MagicMock(is_authenticated=False) + return req + + def _auth_request(self, user): + req = self.factory.get("/") + req.user = user + return req + + def _room_with_event(self, owner, name="Test Room"): + room = Room.objects.create(name=name, owner=owner) + record( + room, GameEvent.SLOT_FILLED, actor=owner, + slot_number=1, token_type="coin", + token_display="Coin-on-a-String", renewal_days=7, + ) + return room + + # ------------------------------------------------------------------ # + # Anonymous user # + # ------------------------------------------------------------------ # + + def test_returns_empty_for_anonymous_user(self): + ctx = navbar_context(self._anon_request()) + self.assertEqual(ctx, {}) + + # ------------------------------------------------------------------ # + # Authenticated user — no rooms # + # ------------------------------------------------------------------ # + + def test_returns_empty_when_no_rooms_with_events(self): + user = User.objects.create(email="disco@test.io") + # Room exists but has no events + Room.objects.create(name="Empty Room", owner=user) + + ctx = navbar_context(self._auth_request(user)) + self.assertEqual(ctx, {}) + + # ------------------------------------------------------------------ # + # Room in gate phase (no table_status) → gatekeeper URL # + # ------------------------------------------------------------------ # + + def test_returns_gatekeeper_url_for_gate_phase_room(self): + user = User.objects.create(email="disco@test.io") + room = self._room_with_event(user) + + ctx = navbar_context(self._auth_request(user)) + self.assertIn("navbar_recent_room_url", ctx) + self.assertIn(str(room.id), ctx["navbar_recent_room_url"]) + self.assertIn("gate", ctx["navbar_recent_room_url"]) + + # ------------------------------------------------------------------ # + # Room in role-select (table_status set) → room view URL # + # ------------------------------------------------------------------ # + + def test_returns_room_url_for_table_status_room(self): + user = User.objects.create(email="disco@test.io") + room = self._room_with_event(user) + room.table_status = Room.ROLE_SELECT + room.save() + + ctx = navbar_context(self._auth_request(user)) + self.assertIn("navbar_recent_room_url", ctx) + self.assertIn(str(room.id), ctx["navbar_recent_room_url"]) + self.assertNotIn("gate", ctx["navbar_recent_room_url"]) + + # ------------------------------------------------------------------ # + # Most recently updated room is chosen # + # ------------------------------------------------------------------ # + + def test_returns_most_recently_updated_room(self): + user = User.objects.create(email="disco@test.io") + older_room = self._room_with_event(user, name="Older Room") + newer_room = self._room_with_event(user, name="Newer Room") + + ctx = navbar_context(self._auth_request(user)) + self.assertIn(str(newer_room.id), ctx["navbar_recent_room_url"]) + self.assertNotIn(str(older_room.id), ctx["navbar_recent_room_url"]) + + # ------------------------------------------------------------------ # + # User sees own rooms but not others' rooms they never joined # + # ------------------------------------------------------------------ # + + def test_ignores_rooms_user_has_no_connection_to(self): + owner = User.objects.create(email="owner@test.io") + other = User.objects.create(email="other@test.io") + # Create a room belonging only to `owner` + self._room_with_event(owner) + + ctx = navbar_context(self._auth_request(other)) + self.assertEqual(ctx, {}) diff --git a/src/functional_tests/test_navbar.py b/src/functional_tests/test_navbar.py new file mode 100644 index 0000000..08a88b0 --- /dev/null +++ b/src/functional_tests/test_navbar.py @@ -0,0 +1,183 @@ +from django.urls import reverse +from selenium.webdriver.common.by import By + +from apps.drama.models import GameEvent, record +from apps.epic.models import Room +from apps.lyric.models import User + +from .base import FunctionalTest + + +def _guard_rect(browser): + """Return the guard portal's bounding rect (reflects CSS transform).""" + return browser.execute_script( + "return document.getElementById('id_guard_portal').getBoundingClientRect().toJSON()" + ) + + +def _elem_rect(browser, element): + """Return an element's bounding rect.""" + return browser.execute_script( + "return arguments[0].getBoundingClientRect().toJSON()", element + ) + + +class NavbarByeTest(FunctionalTest): + """ + The BYE btn-abandon replaces LOG OUT in the identity group. + It should confirm before logging out and its tooltip must appear below + the button (not above, which would be off-screen in the navbar). + """ + + def setUp(self): + super().setUp() + self.create_pre_authenticated_session("disco@test.io") + + # ------------------------------------------------------------------ # + # T1 — BYE btn present; "Log Out" text gone # + # ------------------------------------------------------------------ # + + def test_bye_btn_replaces_log_out(self): + self.browser.get(self.live_server_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_logout")) + + logout_btn = self.browser.find_element(By.ID, "id_logout") + self.assertEqual(logout_btn.text, "BYE") + self.assertIn("btn-abandon", logout_btn.get_attribute("class")) + self.assertNotIn("btn-primary", logout_btn.get_attribute("class")) + + # Old "Log Out" text nowhere in navbar + navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar") + self.assertNotIn("Log Out", navbar.text) + + # ------------------------------------------------------------------ # + # T2 — BYE tooltip appears below btn # + # ------------------------------------------------------------------ # + + def test_bye_tooltip_appears_below_btn(self): + self.browser.get(self.live_server_url) + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_logout") + ) + btn_rect = _elem_rect(self.browser, btn) + + # Click BYE — guard should become active + self.browser.execute_script("arguments[0].click()", btn) + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_guard_portal.active" + ) + ) + + portal_rect = _guard_rect(self.browser) + self.assertGreaterEqual( + portal_rect["top"], + btn_rect["bottom"] - 2, # 2 px tolerance for sub-pixel rounding + "Guard portal should appear below the BYE btn, not above it", + ) + + # ------------------------------------------------------------------ # + # T3 — BYE btn logs out on confirm # + # ------------------------------------------------------------------ # + + def test_bye_btn_logs_out_on_confirm(self): + self.browser.get(self.live_server_url) + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_logout") + ) + self.browser.execute_script("arguments[0].click()", btn) + self.confirm_guard() + + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]") + ) + navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar") + self.assertNotIn("disco@test.io", navbar.text) + + # ------------------------------------------------------------------ # + # T4 — No CONT GAME btn when user has no rooms with events # + # ------------------------------------------------------------------ # + + def test_cont_game_btn_absent_without_recent_room(self): + self.browser.get(self.live_server_url) + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_logout") + ) + cont_game_btns = self.browser.find_elements(By.ID, "id_cont_game") + self.assertEqual( + len(cont_game_btns), 0, + "CONT GAME btn should not appear when user has no rooms with events", + ) + + +class NavbarContGameTest(FunctionalTest): + """ + When the authenticated user has at least one room with a game event the + CONT GAME btn-primary btn-xl appears in the navbar and navigates to that + room on confirmation. Its tooltip must also appear below the button. + """ + + def setUp(self): + super().setUp() + self.create_pre_authenticated_session("disco@test.io") + self.user = User.objects.get(email="disco@test.io") + self.room = Room.objects.create(name="Arena of Peril", owner=self.user) + record( + self.room, GameEvent.SLOT_FILLED, actor=self.user, + slot_number=1, token_type="coin", + token_display="Coin-on-a-String", renewal_days=7, + ) + + # ------------------------------------------------------------------ # + # T5 — CONT GAME btn present when recent room exists # + # ------------------------------------------------------------------ # + + def test_cont_game_btn_present(self): + self.browser.get(self.live_server_url) + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_cont_game") + ) + btn = self.browser.find_element(By.ID, "id_cont_game") + self.assertIn("btn-primary", btn.get_attribute("class")) + self.assertIn("btn-xl", btn.get_attribute("class")) + + # ------------------------------------------------------------------ # + # T6 — CONT GAME tooltip appears below btn # + # ------------------------------------------------------------------ # + + def test_cont_game_tooltip_appears_below_btn(self): + self.browser.get(self.live_server_url) + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_cont_game") + ) + btn_rect = _elem_rect(self.browser, btn) + + self.browser.execute_script("arguments[0].click()", btn) + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_guard_portal.active" + ) + ) + + portal_rect = _guard_rect(self.browser) + self.assertGreaterEqual( + portal_rect["top"], + btn_rect["bottom"] - 2, + "Guard portal should appear below the CONT GAME btn, not above it", + ) + + # ------------------------------------------------------------------ # + # T7 — CONT GAME navigates to the room on confirm # + # ------------------------------------------------------------------ # + + def test_cont_game_navigates_to_room_on_confirm(self): + self.browser.get(self.live_server_url) + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_cont_game") + ) + self.browser.execute_script("arguments[0].click()", btn) + self.confirm_guard() + + self.wait_for( + lambda: self.assertIn(str(self.room.id), self.browser.current_url) + ) diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index e89f23b..02f8363 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -41,7 +41,19 @@ body { gap: 1rem; margin-right: 0.5rem; - > form { flex-shrink: 0; margin-left: auto; } + .navbar-user { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + + .navbar-text { flex: none; } // prevent expansion; BYE abuts the spans + > form { flex-shrink: 0; order: -1; } // BYE left of spans + } + + > #id_cont_game { flex-shrink: 0; } } .navbar-text, @@ -211,7 +223,15 @@ body { gap: 1rem; padding: 0 0.25rem; - > form { flex-shrink: 0; order: -1; } // logout above brand + > #id_cont_game { flex-shrink: 0; order: -1; } // cont-game above brand + + .navbar-user { + flex-direction: column; + align-items: center; + gap: 0.25rem; + .navbar-text { margin: 0; } // cancel landscape margin:auto so BYE abuts + > form { order: 0; .btn { margin-top: 0; } } // abut spans + } } .navbar-brand h1 { diff --git a/src/templates/core/_partials/_navbar.html b/src/templates/core/_partials/_navbar.html index 84a523c..c07088f 100644 --- a/src/templates/core/_partials/_navbar.html +++ b/src/templates/core/_partials/_navbar.html @@ -5,20 +5,33 @@

Welcome,
Earthman

{% if user.email %} -