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:
Disco DeDisco
2026-04-22 04:02:14 -04:00
parent 565f727aa6
commit 6d9d3d4f54
11 changed files with 683 additions and 53 deletions

View File

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

View File

@@ -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 = [
{"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-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},
_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-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),
})