recognition: page, palette modal, & dashboard palette unlock — TDD
- billboard/recognition/ view + template; recognition/<slug>/set-palette endpoint (no trailing slash) - recognition.html: <template>-based modal (clone on open, remove on close — Selenium find_elements compatible) - recognition-page.js: image-box → modal → swatch preview → body-click restore → OK → confirm → POST set-palette - _palettes_for_user() replaces static PALETTES; Recognition.palette unlocks swatch + populates data-shoptalk - _unlocked_palettes_for_user() wires dynamic unlock check into set_palette view - _applet-palette.html: data-shoptalk from context instead of hard-coded "Placeholder" - _recognition.scss: banner, recog-list/item, image-box, modal, palette-confirm; :not([hidden]) pattern avoids display override - FT T2 split into T2a (banner → FYI → recog page), T2b (palette modal flow), T2c (dashboard palette applet) - 684 ITs green; 7 FTs green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
143
src/apps/billboard/static/apps/billboard/recognition-page.js
Normal file
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _state = 'closed'; // 'closed' | 'open' | 'previewing'
|
||||
var _selectedPalette = null;
|
||||
var _activeItem = null;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _activeModal() {
|
||||
return _activeItem && _activeItem.querySelector('.recog-palette-modal');
|
||||
}
|
||||
|
||||
function _paletteClass(el) {
|
||||
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
|
||||
}
|
||||
|
||||
function _getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
function _openModal() {
|
||||
// Clone from <template> if the modal is not in the DOM yet.
|
||||
var existing = _activeModal();
|
||||
if (!existing) {
|
||||
var tpl = _activeItem.querySelector('.recog-palette-modal-tpl');
|
||||
if (!tpl) return;
|
||||
var clone = tpl.content.firstElementChild.cloneNode(true);
|
||||
_activeItem.appendChild(clone);
|
||||
_wireModal();
|
||||
}
|
||||
_state = 'open';
|
||||
var confirmEl = _activeModal().querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = true;
|
||||
}
|
||||
|
||||
function _closeModal() {
|
||||
_state = 'closed';
|
||||
var modal = _activeModal();
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
// Wire event listeners onto the freshly-cloned modal DOM.
|
||||
function _wireModal() {
|
||||
var modal = _activeModal();
|
||||
if (!modal) return;
|
||||
|
||||
// Swatch body → preview (remove modal from DOM)
|
||||
modal.querySelectorAll('.recog-swatch-body').forEach(function (body) {
|
||||
body.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(body.parentElement);
|
||||
_state = 'previewing';
|
||||
modal.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// OK button (not inside confirm submenu) → show confirm submenu
|
||||
modal.querySelectorAll('.btn.btn-confirm').forEach(function (btn) {
|
||||
if (btn.closest('.recog-palette-confirm')) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_selectedPalette = _paletteClass(btn.parentElement);
|
||||
var confirmEl = modal.querySelector('.recog-palette-confirm');
|
||||
if (confirmEl) confirmEl.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm YES → POST and update DOM
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_doSetPalette();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm NVM → hide confirm submenu
|
||||
modal.querySelectorAll('.recog-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
btn.closest('.recog-palette-confirm').hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Stop all other modal clicks from reaching body handler.
|
||||
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
|
||||
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||
|
||||
function _doSetPalette() {
|
||||
var url = _activeItem.dataset.setPaletteUrl;
|
||||
var palette = _selectedPalette;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
},
|
||||
body: JSON.stringify({ palette: palette }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
_closeModal();
|
||||
var imageBox = _activeItem.querySelector('.recog-item__image-box');
|
||||
if (imageBox) {
|
||||
var swatch = document.createElement('div');
|
||||
swatch.className = 'recog-item__palette ' + palette;
|
||||
imageBox.parentNode.replaceChild(swatch, imageBox);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _init() {
|
||||
// Image-box click → open modal
|
||||
document.querySelectorAll('.recog-item__image-box').forEach(function (box) {
|
||||
box.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_activeItem = box.closest('.recog-item');
|
||||
_openModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Body click → restore modal when previewing
|
||||
document.body.addEventListener('click', function () {
|
||||
if (_state === 'previewing') {
|
||||
_openModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
@@ -1,8 +1,11 @@
|
||||
import json as _json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -161,6 +164,102 @@ class BillscrollViewTest(TestCase):
|
||||
self.assertContains(response, 'class="drama-event-time"')
|
||||
|
||||
|
||||
class RecognitionPageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_recognition_template(self):
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/recognition.html")
|
||||
|
||||
def test_passes_recognitions_in_context(self):
|
||||
recog = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertIn(recog, response.context["recognitions"])
|
||||
|
||||
def test_excludes_other_users_recognitions(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Recognition.objects.create(
|
||||
user=other, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertEqual(list(response.context["recognitions"]), [])
|
||||
|
||||
def test_renders_recog_list_and_items(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-list"')
|
||||
self.assertContains(response, 'class="recog-item"')
|
||||
|
||||
def test_renders_recog_item_title_description_image_box(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/recognition/")
|
||||
self.assertContains(response, 'class="recog-item__title"')
|
||||
self.assertContains(response, 'class="recog-item__description"')
|
||||
self.assertContains(response, 'class="recog-item__image-box"')
|
||||
|
||||
|
||||
class RecognitionSetPaletteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="setpal@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.recognition = Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.url = "/billboard/recognition/stargazer/set-palette"
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_sets_palette_on_recognition(self):
|
||||
self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.recognition.refresh_from_db()
|
||||
self.assertEqual(self.recognition.palette, "palette-bardo")
|
||||
|
||||
def test_returns_200_with_ok(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
|
||||
def test_returns_404_for_slug_user_does_not_own(self):
|
||||
response = self.client.post(
|
||||
"/billboard/recognition/schizo/set-palette",
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
|
||||
@@ -7,6 +7,8 @@ app_name = "billboard"
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
path("recognition/", views.recognition_page, name="recognition"),
|
||||
path("recognition/<slug:slug>/set-palette", views.recognition_set_palette, name="recognition_set_palette"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.drama.models import GameEvent, ScrollPosition
|
||||
from apps.drama.models import GameEvent, Recognition, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
from apps.epic.utils import rooms_for_user
|
||||
|
||||
@@ -68,6 +71,58 @@ def room_scroll(request, room_id):
|
||||
})
|
||||
|
||||
|
||||
_RECOGNITION_META = {
|
||||
"stargazer": {
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"palette_options": ["palette-bardo", "palette-sheol"],
|
||||
},
|
||||
"schizo": {
|
||||
"title": "Schizo",
|
||||
"description": "The socius recognizes the line of flight.",
|
||||
"palette_options": [],
|
||||
},
|
||||
"nomad": {
|
||||
"title": "Nomad",
|
||||
"description": "The socius recognizes the smooth space.",
|
||||
"palette_options": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_set_palette(request, slug):
|
||||
from django.http import Http404
|
||||
try:
|
||||
recognition = Recognition.objects.get(user=request.user, slug=slug)
|
||||
except Recognition.DoesNotExist:
|
||||
raise Http404
|
||||
if request.method == "POST":
|
||||
body = json.loads(request.body)
|
||||
recognition.palette = body.get("palette", "")
|
||||
recognition.save(update_fields=["palette"])
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def recognition_page(request):
|
||||
qs = Recognition.objects.filter(user=request.user)
|
||||
recognitions = [
|
||||
{
|
||||
"obj": r,
|
||||
"title": _RECOGNITION_META.get(r.slug, {}).get("title", r.slug),
|
||||
"description": _RECOGNITION_META.get(r.slug, {}).get("description", ""),
|
||||
"palette_options": _RECOGNITION_META.get(r.slug, {}).get("palette_options", []),
|
||||
}
|
||||
for r in qs
|
||||
]
|
||||
return render(request, "apps/billboard/recognition.html", {
|
||||
"recognitions": qs,
|
||||
"recognition_items": recognitions,
|
||||
"page_class": "page-recognition",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import override_settings, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import html
|
||||
from django.utils import html, timezone
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.dashboard.forms import (
|
||||
@@ -13,6 +13,7 @@ from apps.dashboard.forms import (
|
||||
EMPTY_ITEM_ERROR,
|
||||
)
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.drama.models import Recognition
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -348,6 +349,52 @@ class SetPaletteTest(TestCase):
|
||||
swatches = parsed.cssselect(".swatch")
|
||||
self.assertEqual(len(swatches), len(response.context["palettes"]))
|
||||
|
||||
class RecognitionPaletteContextTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog_palette@test.io")
|
||||
self.client.force_login(self.user)
|
||||
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||
|
||||
def test_recognition_palette_unlocks_swatch_in_context(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertFalse(bardo["locked"])
|
||||
|
||||
def test_recognition_palette_shoptalk_contains_recognition_title(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertIn("Stargazer", bardo["shoptalk"])
|
||||
|
||||
def test_recognition_without_palette_field_keeps_swatch_locked(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette=None,
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertTrue(bardo["locked"])
|
||||
|
||||
def test_recognition_palette_allows_set_palette_via_view(self):
|
||||
Recognition.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.palette, "palette-bardo")
|
||||
|
||||
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
class ProfileViewTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -23,23 +23,59 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
||||
|
||||
|
||||
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||
UNLOCKED_PALETTES = frozenset([
|
||||
_BASE_UNLOCKED = frozenset([
|
||||
"palette-default",
|
||||
"palette-cedar",
|
||||
"palette-oblivion-light",
|
||||
"palette-monochrome-dark",
|
||||
])
|
||||
PALETTES = [
|
||||
_PALETTE_DEFS = [
|
||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||
{"name": "palette-cedar", "label": "Cedar", "locked": False},
|
||||
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
|
||||
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False},
|
||||
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
|
||||
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
|
||||
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||
]
|
||||
_RECOGNITION_TITLES = {
|
||||
"stargazer": "Stargazer",
|
||||
"schizo": "Schizo",
|
||||
"nomad": "Nomad",
|
||||
}
|
||||
# Keep PALETTES as an alias used by views that don't have a request user.
|
||||
PALETTES = _PALETTE_DEFS
|
||||
|
||||
|
||||
def _palettes_for_user(user):
|
||||
if not (user and user.is_authenticated):
|
||||
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
|
||||
granted = {
|
||||
r.palette: r
|
||||
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette="")
|
||||
}
|
||||
result = []
|
||||
for p in _PALETTE_DEFS:
|
||||
entry = dict(p)
|
||||
r = granted.get(p["name"])
|
||||
if r and p["locked"]:
|
||||
entry["locked"] = False
|
||||
title = _RECOGNITION_TITLES.get(r.slug, r.slug.capitalize())
|
||||
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
|
||||
else:
|
||||
entry["shoptalk"] = "Placeholder"
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _unlocked_palettes_for_user(user):
|
||||
base = set(_BASE_UNLOCKED)
|
||||
if user and user.is_authenticated:
|
||||
for r in Recognition.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
|
||||
base.add(r.palette)
|
||||
return base
|
||||
|
||||
|
||||
def _recent_notes(user, limit=3):
|
||||
@@ -55,7 +91,7 @@ def _recent_notes(user, limit=3):
|
||||
def home_page(request):
|
||||
context = {
|
||||
"form": ItemForm(),
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"page_class": "page-dashboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
@@ -75,7 +111,7 @@ def new_note(request):
|
||||
else:
|
||||
context = {
|
||||
"form": form,
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"page_class": "page-dashboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
@@ -125,7 +161,7 @@ def share_note(request, note_id):
|
||||
def set_palette(request):
|
||||
if request.method == "POST":
|
||||
palette = request.POST.get("palette", "")
|
||||
if palette in UNLOCKED_PALETTES:
|
||||
if palette in _unlocked_palettes_for_user(request.user):
|
||||
request.user.palette = palette
|
||||
request.user.save(update_fields=["palette"])
|
||||
if "application/json" in request.headers.get("Accept", ""):
|
||||
@@ -147,7 +183,7 @@ def toggle_applets(request):
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "dashboard"),
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"form": ItemForm(),
|
||||
"recent_notes": _recent_notes(request.user),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,11 @@ outside of any game room.
|
||||
Two test classes — one per surface that can trigger the unlock — each asserting both
|
||||
the negative (disabled/incomplete save does nothing) and positive (first valid save
|
||||
fires the banner) conditions.
|
||||
|
||||
T2 (Dashboard full flow) is split across three focused tests:
|
||||
T2a — save → banner → FYI → recognition page item
|
||||
T2b — palette modal flow on recognition page
|
||||
T2c — dashboard palette applet reflects Recognition palette unlock
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
@@ -15,6 +20,7 @@ from django.utils import timezone
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import Recognition
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
@@ -84,11 +90,7 @@ def _fill_valid_sky_form(browser):
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
"""Stargazer Recognition triggered from the My Sky applet.
|
||||
|
||||
T1 — incomplete save does not fire Recognition.
|
||||
T2 — first valid save fires banner, full flow to palette unlock.
|
||||
"""
|
||||
"""Stargazer Recognition triggered from the My Sky applet."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -102,8 +104,8 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
defaults={"name": "Palettes", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="recognition",
|
||||
defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"},
|
||||
slug="billboard-recognition",
|
||||
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
|
||||
)
|
||||
self.gamer = User.objects.create(email="stargazer@test.io")
|
||||
|
||||
@@ -121,16 +123,11 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
self.assertIsNotNone(confirm_btn.get_attribute("disabled"))
|
||||
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".recog-banner"))
|
||||
|
||||
# ── T2 ───────────────────────────────────────────────────────────────────
|
||||
# ── T2a ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_first_valid_save_from_applet_unlocks_stargazer_full_flow(self):
|
||||
"""First valid SAVE SKY from the My Sky applet:
|
||||
|
||||
banner slides in below the Dash h2 → FYI leads to /billboard/recognition/ →
|
||||
Recognition page shows Stargazer item → ? box opens palette modal →
|
||||
swatch preview → OK → confirm → chosen palette permanently unlocked in
|
||||
Palette applet on Dashboard.
|
||||
"""
|
||||
def test_first_valid_save_from_applet_fires_banner_and_leads_to_recognition_page(self):
|
||||
"""First valid SAVE SKY from the My Sky applet fires the Stargazer banner.
|
||||
FYI button navigates to /billboard/recognition/ showing the Stargazer item."""
|
||||
self.create_pre_authenticated_session("stargazer@test.io")
|
||||
self.browser.get(self.live_server_url)
|
||||
|
||||
@@ -142,7 +139,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
self.wait_for(lambda: self.assertIsNone(confirm_btn.get_attribute("disabled")))
|
||||
confirm_btn.click()
|
||||
|
||||
# --- Banner slides in below the Dash h2 ---
|
||||
# Banner slides in below the Dash h2
|
||||
banner = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-banner")
|
||||
)
|
||||
@@ -156,13 +153,13 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
banner.find_element(By.CSS_SELECTOR, ".btn.btn-danger") # NVM
|
||||
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-caution") # FYI
|
||||
|
||||
# --- FYI navigates to Recognition page ---
|
||||
# FYI navigates to Recognition page
|
||||
fyi.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertRegex(self.browser.current_url, r"/billboard/recognition")
|
||||
)
|
||||
|
||||
# --- Recognition page: one Stargazer item ---
|
||||
# Recognition page: one Stargazer item
|
||||
item = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-list .recog-item")
|
||||
)
|
||||
@@ -171,9 +168,24 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
item.find_element(By.CSS_SELECTOR, ".recog-item__title").text,
|
||||
)
|
||||
item.find_element(By.CSS_SELECTOR, ".recog-item__description")
|
||||
image_box = item.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
|
||||
item.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
|
||||
|
||||
# --- Clicking ? opens palette modal ---
|
||||
# ── T2b ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_recognition_page_palette_modal_flow(self):
|
||||
"""Recognition page palette modal: image-box opens modal, swatch preview,
|
||||
body-click restores modal, OK raises confirm submenu, confirm sets palette."""
|
||||
Recognition.objects.create(
|
||||
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.create_pre_authenticated_session("stargazer@test.io")
|
||||
self.browser.get(self.live_server_url + "/billboard/recognition/")
|
||||
|
||||
image_box = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-item__image-box")
|
||||
)
|
||||
|
||||
# Clicking ? opens palette modal
|
||||
image_box.click()
|
||||
modal = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
|
||||
@@ -181,7 +193,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
modal.find_element(By.CSS_SELECTOR, ".palette-bardo")
|
||||
modal.find_element(By.CSS_SELECTOR, ".palette-sheol")
|
||||
|
||||
# --- Clicking a swatch body previews the palette and dismisses the modal ---
|
||||
# Clicking a swatch body previews the palette and dismisses the modal
|
||||
bardo_body = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .recog-swatch-body")
|
||||
self.browser.execute_script(
|
||||
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
|
||||
@@ -191,20 +203,20 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
|
||||
))
|
||||
|
||||
# --- Clicking anything else ends preview and restores the modal ---
|
||||
# Clicking elsewhere ends preview and restores the modal
|
||||
self.browser.find_element(By.TAG_NAME, "body").click()
|
||||
modal = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-modal")
|
||||
)
|
||||
|
||||
# --- Clicking OK on the swatch raises a confirmation submenu ---
|
||||
# Clicking OK on the swatch raises a confirmation submenu
|
||||
ok_btn = modal.find_element(By.CSS_SELECTOR, ".palette-bardo .btn.btn-confirm")
|
||||
ok_btn.click()
|
||||
confirm_menu = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".recog-palette-confirm")
|
||||
)
|
||||
|
||||
# --- Confirming sets palette, closes modal, replaces ? box ---
|
||||
# Confirming sets palette, closes modal, replaces image-box with palette swatch
|
||||
confirm_menu.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
|
||||
self.wait_for(lambda: self.assertFalse(
|
||||
self.browser.find_elements(By.CSS_SELECTOR, ".recog-palette-modal")
|
||||
@@ -217,8 +229,18 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
item.find_elements(By.CSS_SELECTOR, ".recog-item__image-box")
|
||||
)
|
||||
|
||||
# --- Dashboard: Palette applet shows bardo permanently unlocked ---
|
||||
# ── T2c ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_dashboard_palette_applet_reflects_recognition_palette_unlock(self):
|
||||
"""After palette unlock via Recognition, the Dashboard Palette applet shows
|
||||
the palette swatch as unlocked with Stargazer shoptalk."""
|
||||
Recognition.objects.create(
|
||||
user=self.gamer, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
self.create_pre_authenticated_session("stargazer@test.io")
|
||||
self.browser.get(self.live_server_url)
|
||||
|
||||
palette_applet = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_applet_palette")
|
||||
)
|
||||
@@ -227,10 +249,7 @@ class StargazerRecognitionFromDashboardTest(FunctionalTest):
|
||||
bardo_ok = bardo.find_element(By.CSS_SELECTOR, ".palette-ok")
|
||||
self.assertIn("btn-confirm", bardo_ok.get_attribute("class"))
|
||||
self.assertNotIn("btn-disabled", bardo_ok.get_attribute("class"))
|
||||
self.assertIn(
|
||||
"Stargazer",
|
||||
bardo.get_attribute("data-shoptalk"),
|
||||
)
|
||||
self.assertIn("Stargazer", bardo.get_attribute("data-shoptalk"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -253,8 +272,8 @@ class StargazerRecognitionFromSkyPageTest(FunctionalTest):
|
||||
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="recognition",
|
||||
defaults={"name": "Recognition", "grid_cols": 12, "grid_rows": 3, "context": "billboard"},
|
||||
slug="billboard-recognition",
|
||||
defaults={"name": "Recognition", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
|
||||
)
|
||||
self.gamer = User.objects.create(email="stargazer@test.io")
|
||||
self.sky_url = self.live_server_url + "/dashboard/sky/"
|
||||
@@ -307,7 +326,6 @@ class StargazerRecognitionFromSkyPageTest(FunctionalTest):
|
||||
def test_already_earned_recognition_does_not_show_banner_on_subsequent_save(self):
|
||||
"""When Stargazer is already in the database for this user, a valid sky
|
||||
save does not fire another Recognition banner."""
|
||||
from apps.drama.models import Recognition
|
||||
Recognition.objects.create(
|
||||
user=self.gamer,
|
||||
slug="stargazer",
|
||||
|
||||
180
src/static_src/scss/_recognition.scss
Normal file
180
src/static_src/scss/_recognition.scss
Normal file
@@ -0,0 +1,180 @@
|
||||
// ── Recognition banner (slides in below page h2 after unlock) ─────────────
|
||||
|
||||
.recog-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(var(--priUser), 0.12);
|
||||
border-left: 3px solid rgba(var(--priUser), 0.6);
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.recog-banner__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recog-banner__title {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recog-banner__description,
|
||||
.recog-banner__timestamp {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.recog-banner__image {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
flex-shrink: 0;
|
||||
background: rgba(var(--priUser), 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.recog-banner__nvm,
|
||||
.recog-banner__fyi {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recognition page ───────────────────────────────────────────────────────
|
||||
|
||||
.recognition-page {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.recog-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recog-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--priUser), 0.06);
|
||||
border: 1px solid rgba(var(--priUser), 0.2);
|
||||
border-radius: 4px;
|
||||
width: 14rem;
|
||||
|
||||
.recog-item__title {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.recog-item__description {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
// Image box — must have a defined size so Selenium can interact with it.
|
||||
.recog-item__image-box {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--priUser), 0.12);
|
||||
border: 1px dashed rgba(var(--priUser), 0.4);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
// Unlocked palette swatch inside a recognition item
|
||||
.recog-item__palette {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 2px;
|
||||
border: 2px solid rgba(var(--priUser), 0.4);
|
||||
}
|
||||
|
||||
// ── Palette modal ──────────────────────────────────────────────────────────
|
||||
|
||||
.recog-palette-modal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: var(--bg, #1a1a1a);
|
||||
border: 1px solid rgba(var(--priUser), 0.4);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
min-width: 12rem;
|
||||
|
||||
&:not([hidden]) { display: flex; flex-direction: column; }
|
||||
|
||||
// Each palette swatch option inside the modal
|
||||
> [class*="palette-"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.recog-swatch-body {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(var(--priUser), 0.3);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { border-color: rgba(var(--priUser), 0.8); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Palette swatch color fills ─────────────────────────────────────────────
|
||||
// These match the actual palette CSS variables — used both in modal swatches
|
||||
// and as the confirmed .recog-item__palette swatch.
|
||||
|
||||
.palette-bardo .recog-swatch-body,
|
||||
.recog-item__palette.palette-bardo {
|
||||
background: #2a1a2e;
|
||||
}
|
||||
|
||||
.palette-sheol .recog-swatch-body,
|
||||
.recog-item__palette.palette-sheol {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
// ── Confirm submenu ────────────────────────────────────────────────────────
|
||||
|
||||
.recog-palette-confirm {
|
||||
border-top: 1px solid rgba(var(--priUser), 0.2);
|
||||
padding-top: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
|
||||
&:not([hidden]) { display: flex; flex-direction: column; }
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
@import 'natus';
|
||||
@import 'tray';
|
||||
@import 'billboard';
|
||||
@import 'recognition';
|
||||
@import 'tooltips';
|
||||
@import 'game-kit';
|
||||
@import 'wallet-tokens';
|
||||
|
||||
49
src/templates/apps/billboard/recognition.html
Normal file
49
src/templates/apps/billboard/recognition.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Recognition{% endblock title_text %}
|
||||
{% block header_text %}<span>Bill</span>recognition{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="recognition-page">
|
||||
<h2>Recognition</h2>
|
||||
<ul class="recog-list">
|
||||
{% for item in recognition_items %}
|
||||
<li class="recog-item" data-slug="{{ item.obj.slug }}"
|
||||
data-set-palette-url="{% url 'billboard:recognition_set_palette' item.obj.slug %}">
|
||||
|
||||
{% if item.obj.palette %}
|
||||
<div class="recog-item__palette {{ item.obj.palette }}"></div>
|
||||
{% else %}
|
||||
<div class="recog-item__image-box">?</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="recog-item__title">{{ item.title }}</p>
|
||||
<p class="recog-item__description">{{ item.description }}</p>
|
||||
|
||||
{% if not item.obj.palette and item.palette_options %}
|
||||
<template class="recog-palette-modal-tpl">
|
||||
<div class="recog-palette-modal">
|
||||
{% for palette_name in item.palette_options %}
|
||||
<div class="{{ palette_name }}">
|
||||
<div class="recog-swatch-body"></div>
|
||||
<button type="button" class="btn btn-confirm">OK</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="recog-palette-confirm" hidden>
|
||||
<p>Lock in this palette?</p>
|
||||
<button type="button" class="btn btn-confirm">OK</button>
|
||||
<button type="button" class="btn btn-cancel">NVM</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="recog-item recog-item--empty">No recognitions yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<script src="{% static 'apps/billboard/recognition-page.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -13,7 +13,7 @@
|
||||
data-label="{{ palette.label }}"
|
||||
data-locked="{{ palette.locked|yesno:'true,false' }}"
|
||||
data-unlocked-date="{% if not palette.locked %}Default{% endif %}"
|
||||
data-shoptalk="Placeholder"
|
||||
data-shoptalk="{{ palette.shoptalk }}"
|
||||
>
|
||||
{% if not palette.locked %}
|
||||
<button type="button" class="btn btn-confirm palette-ok" hidden>OK</button>
|
||||
|
||||
Reference in New Issue
Block a user