Compare commits

..

2 Commits

11 changed files with 396 additions and 25 deletions

View File

@@ -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)
);
});

View File

@@ -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();

View File

@@ -2,3 +2,29 @@ def user_palette(request):
if request.user.is_authenticated:
return {"user_palette": request.user.palette}
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}

View File

@@ -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',
],
},
},

View 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, {})

View File

@@ -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()

View 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)
)

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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">

View File

@@ -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);
});