major navbar overhaul: .btn-primary.btn-xl now reads CONT GAME and links to the user's most recently active game; log out functionality transferred to new BYE .btn-abandon abutting login spans; tooltips for each asserted via new FTs.test_navbar methods to appear w.in visible area
This commit is contained in:
@@ -1,4 +1,30 @@
|
|||||||
def user_palette(request):
|
def user_palette(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return {"user_palette": request.user.palette}
|
return {"user_palette": request.user.palette}
|
||||||
return {"user_palette": "palette-default"}
|
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}
|
||||||
@@ -102,6 +102,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'core.context_processors.user_palette',
|
'core.context_processors.user_palette',
|
||||||
|
'core.context_processors.navbar_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
109
src/core/tests/unit/test_context_processors.py
Normal file
109
src/core/tests/unit/test_context_processors.py
Normal file
@@ -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, {})
|
||||||
183
src/functional_tests/test_navbar.py
Normal file
183
src/functional_tests/test_navbar.py
Normal file
@@ -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)
|
||||||
|
)
|
||||||
@@ -41,7 +41,19 @@ body {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-right: 0.5rem;
|
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,
|
.navbar-text,
|
||||||
@@ -211,7 +223,15 @@ body {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0 0.25rem;
|
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 {
|
.navbar-brand h1 {
|
||||||
|
|||||||
@@ -5,20 +5,33 @@
|
|||||||
<h1>Welcome,<br>Earthman</h1>
|
<h1>Welcome,<br>Earthman</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if user.email %}
|
{% if user.email %}
|
||||||
<div class="navbar-text">
|
<div class="navbar-user">
|
||||||
<span class="navbar-label">
|
<div class="navbar-text">
|
||||||
Logged in as
|
<span class="navbar-label">
|
||||||
</span>
|
Logged in as
|
||||||
<span class="navbar-identity">
|
</span>
|
||||||
@{{ user|display_name }}
|
<span class="navbar-identity">
|
||||||
</span>
|
@{{ user|display_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{% url "logout" %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button id="id_logout" class="btn btn-abandon" type="submit" data-confirm="Log out?">
|
||||||
|
BYE
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="{% url "logout" %}">
|
{% if navbar_recent_room_url %}
|
||||||
{% csrf_token %}
|
<button
|
||||||
<button id="id_logout" class="btn btn-primary btn-xl" type="submit" data-confirm="Log out?">
|
id="id_cont_game"
|
||||||
Log Out
|
class="btn btn-primary btn-xl"
|
||||||
|
type="button"
|
||||||
|
data-confirm="Continue game?"
|
||||||
|
data-href="{{ navbar_recent_room_url }}"
|
||||||
|
>
|
||||||
|
CONT<br>GAME
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="POST" action="{% url "send_login_email" %}">
|
<form method="POST" action="{% url "send_login_email" %}">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|||||||
@@ -86,11 +86,13 @@
|
|||||||
portal.style.left = Math.round(cleft) + 'px';
|
portal.style.left = Math.round(cleft) + 'px';
|
||||||
var cardCenterY = rect.top + rect.height / 2;
|
var cardCenterY = rect.top + rect.height / 2;
|
||||||
if (cardCenterY < window.innerHeight / 2) {
|
if (cardCenterY < window.innerHeight / 2) {
|
||||||
portal.style.top = Math.round(rect.top) + 'px';
|
// button in upper half → show below
|
||||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
|
||||||
} else {
|
|
||||||
portal.style.top = Math.round(rect.bottom) + 'px';
|
portal.style.top = Math.round(rect.bottom) + 'px';
|
||||||
portal.style.transform = 'translate(-50%, 0.5rem)';
|
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||||
|
} else {
|
||||||
|
// button in lower half → show above
|
||||||
|
portal.style.top = Math.round(rect.top) + 'px';
|
||||||
|
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
var form = btn.closest('form');
|
var form = btn.closest('form');
|
||||||
show(btn, btn.dataset.confirm, function () {
|
show(btn, btn.dataset.confirm, function () {
|
||||||
if (form) form.submit();
|
if (form) form.submit();
|
||||||
|
else if (btn.dataset.href) window.location.href = btn.dataset.href;
|
||||||
});
|
});
|
||||||
}, true);
|
}, true);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user