Compare commits
2 Commits
d4518a0671
...
40c747a837
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c747a837 | ||
|
|
40a55721ab |
@@ -185,7 +185,8 @@ var RoleSelect = (function () {
|
||||
function () { // dismiss (NVM / outside click)
|
||||
card.classList.remove("guard-active");
|
||||
card.classList.remove("flipped");
|
||||
}
|
||||
},
|
||||
{ invertY: true } // modal grid: tooltip flies away from centre (upper→above, lower→below)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -139,8 +139,18 @@ function initGameKitTooltips() {
|
||||
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||
portal.style.top = Math.round(tokenRect.top) + 'px';
|
||||
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||
|
||||
// Show above when token is in lower viewport half; below when in upper half
|
||||
// (avoids clipping when game-kit tokens sit near the top in landscape mode).
|
||||
const tokenCenterY = tokenRect.top + tokenRect.height / 2;
|
||||
const showBelow = tokenCenterY < window.innerHeight / 2;
|
||||
if (showBelow) {
|
||||
portal.style.top = Math.round(tokenRect.bottom) + 'px';
|
||||
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||
} else {
|
||||
portal.style.top = Math.round(tokenRect.top) + 'px';
|
||||
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||
}
|
||||
|
||||
if (isEquippable) {
|
||||
const mainRect = portal.getBoundingClientRect();
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
def user_palette(request):
|
||||
if request.user.is_authenticated:
|
||||
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.messages.context_processors.messages',
|
||||
'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, {})
|
||||
@@ -248,7 +248,7 @@ class GatekeeperTest(FunctionalTest):
|
||||
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_room_menu .btn-abandon")
|
||||
).click()
|
||||
self.confirm_guard()
|
||||
|
||||
|
||||
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;
|
||||
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,
|
||||
@@ -210,8 +222,17 @@ body {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
|
||||
|
||||
> 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 {
|
||||
@@ -226,6 +247,7 @@ body {
|
||||
.navbar-brand {
|
||||
order: 1; // brand at bottom
|
||||
width: 100%;
|
||||
margin-left: 0; // reset portrait margin-left: 1rem
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
|
||||
.position-strip {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 130;
|
||||
|
||||
@@ -5,20 +5,33 @@
|
||||
<h1>Welcome,<br>Earthman</h1>
|
||||
</a>
|
||||
{% if user.email %}
|
||||
<div class="navbar-text">
|
||||
<span class="navbar-label">
|
||||
Logged in as
|
||||
</span>
|
||||
<span class="navbar-identity">
|
||||
@{{ user|display_name }}
|
||||
</span>
|
||||
<div class="navbar-user">
|
||||
<div class="navbar-text">
|
||||
<span class="navbar-label">
|
||||
Logged in as
|
||||
</span>
|
||||
<span class="navbar-identity">
|
||||
@{{ 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>
|
||||
<form method="POST" action="{% url "logout" %}">
|
||||
{% csrf_token %}
|
||||
<button id="id_logout" class="btn btn-primary btn-xl" type="submit" data-confirm="Log out?">
|
||||
Log Out
|
||||
{% if navbar_recent_room_url %}
|
||||
<button
|
||||
id="id_cont_game"
|
||||
class="btn btn-primary btn-xl"
|
||||
type="button"
|
||||
data-confirm="Continue game?"
|
||||
data-href="{{ navbar_recent_room_url }}"
|
||||
>
|
||||
CONT<br>GAME
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form method="POST" action="{% url "send_login_email" %}">
|
||||
<div class="input-group">
|
||||
|
||||
@@ -73,8 +73,9 @@
|
||||
var _cb = null;
|
||||
var _onDismiss = null;
|
||||
|
||||
function show(anchor, message, callback, onDismiss) {
|
||||
function show(anchor, message, callback, onDismiss, options) {
|
||||
if (!portal) return;
|
||||
options = options || {};
|
||||
_cb = callback;
|
||||
_onDismiss = onDismiss || null;
|
||||
portal.querySelector('.guard-message').innerHTML = message;
|
||||
@@ -85,12 +86,16 @@
|
||||
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8));
|
||||
portal.style.left = Math.round(cleft) + 'px';
|
||||
var cardCenterY = rect.top + rect.height / 2;
|
||||
if (cardCenterY < window.innerHeight / 2) {
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
} else {
|
||||
// Default: upper half → below (avoids viewport top edge for navbar/fixed buttons).
|
||||
// invertY: upper half → above (for modal grids where tooltip should fly away from centre).
|
||||
var showBelow = (cardCenterY < window.innerHeight / 2);
|
||||
if (options.invertY) showBelow = !showBelow;
|
||||
if (showBelow) {
|
||||
portal.style.top = Math.round(rect.bottom) + 'px';
|
||||
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||
} else {
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +145,7 @@
|
||||
var form = btn.closest('form');
|
||||
show(btn, btn.dataset.confirm, function () {
|
||||
if (form) form.submit();
|
||||
else if (btn.dataset.href) window.location.href = btn.dataset.href;
|
||||
});
|
||||
}, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user