Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a --sixBlt: 162,170,173 neutral) + two .palette-baltimore / .palette-maryland palette classes wiring those hues into the standard --priUser…--decUser slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: _NOTE_DISPLAY (drama/models.py) — {"greeting": "Ayo,", "title": "Ard!"} so DON flips navbar Welcome, Earthman → Ayo, Ard!; _NOTE_TITLES (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; _NOTE_META (billboard/views.py) — Baltimorean title + the literal description "Aaron earned an iron urn." + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; set_pronouns view rewired (dashboard/views.py) — first-time pronouns = bawlmorese selection calls Note.grant_if_new(user, "baltimorean") + returns {"brief": brief.to_banner_dict()} JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns commit() handles the 200 JSON path — resp.json().then(data => Brief.showBanner(data.brief)) instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; game_kit.html pulls in apps/dashboard/note.js so Brief is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — note.js showBanner() now measures the .row .col-lg-6 h2 at render-time + sets inline top so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default top: 0.5rem (which had it lurking above the wordmark area on every page); portrait-only (gated if window.innerWidth > window.innerHeight return) — landscape h2 lives in a writing-mode: vertical-rl fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for _NOTE_DISPLAY[baltimorean] greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — SetPronounsBawlmoreseUnlockTest (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title Ard! / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing SetPronounsViewTest.test_post_each_valid_choice docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. Ard! title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting Welcome, Earthman → Ayo, Ard!; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. data-description carrying Baltimorean + non-empty data-unlocked-date + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,12 @@ _NOTE_META = {
|
|||||||
"palette_options": [],
|
"palette_options": [],
|
||||||
"swatch_label": "0",
|
"swatch_label": "0",
|
||||||
},
|
},
|
||||||
|
"baltimorean": {
|
||||||
|
"title": "Baltimorean",
|
||||||
|
"description": '"Aaron earned an iron urn."',
|
||||||
|
"palette_options": _palette_opts(["palette-baltimore", "palette-maryland"]),
|
||||||
|
"swatch_label": None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ const Brief = (() => {
|
|||||||
} else {
|
} else {
|
||||||
document.body.insertBefore(banner, document.body.firstChild);
|
document.body.insertBefore(banner, document.body.firstChild);
|
||||||
}
|
}
|
||||||
|
_alignToH2(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot the (position:fixed) banner over the page's h2 letter-spread
|
||||||
|
// header so it visually portals across the wordmark rather than parking
|
||||||
|
// at the top of the viewport.
|
||||||
|
//
|
||||||
|
// Portrait only: h2 is a horizontal wordmark in `.container .row`; its
|
||||||
|
// top edge is navbar-height + row-padding which differs per viewport,
|
||||||
|
// so we measure at render-time + set inline `top`.
|
||||||
|
//
|
||||||
|
// Landscape: h2 is rotated into the fixed --h2-col-w sidebar slot via
|
||||||
|
// `writing-mode: vertical-rl`. A horizontally-flowing banner can't
|
||||||
|
// "overlay" a vertical h2 without a full reorientation of its own
|
||||||
|
// contents (writing-mode + flex-direction restyle). Defer that to a
|
||||||
|
// landscape-vertical-banner sprint; for now landscape keeps the SCSS
|
||||||
|
// default `top: 0.5rem` (banner reads as a horizontal strip near the
|
||||||
|
// top of the page content area).
|
||||||
|
function _alignToH2(banner) {
|
||||||
|
if (window.innerWidth > window.innerHeight) return; // landscape: skip
|
||||||
|
var h2 = document.querySelector('.row .col-lg-6 h2');
|
||||||
|
if (!h2) return;
|
||||||
|
var rect = h2.getBoundingClientRect();
|
||||||
|
if (rect.height <= 0) return;
|
||||||
|
banner.style.top = rect.top + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveResponse(data) {
|
function handleSaveResponse(data) {
|
||||||
|
|||||||
@@ -714,6 +714,10 @@ class SetPronounsViewTest(TestCase):
|
|||||||
self.assertEqual(self.user.pronouns, "misogyny")
|
self.assertEqual(self.user.pronouns, "misogyny")
|
||||||
|
|
||||||
def test_post_each_valid_choice(self):
|
def test_post_each_valid_choice(self):
|
||||||
|
"""Every PRONOUN_TABLE key persists. Note: bawlmorese also triggers the
|
||||||
|
Baltimorean Note-unlock JSON response (status 200) on first call —
|
||||||
|
covered separately by SetPronounsBawlmoreseUnlockTest. Here we just
|
||||||
|
confirm the user.pronouns write lands for each choice."""
|
||||||
for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
|
for key in ("pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"):
|
||||||
with self.subTest(key=key):
|
with self.subTest(key=key):
|
||||||
self.client.post(self.url, data={"pronouns": key})
|
self.client.post(self.url, data={"pronouns": key})
|
||||||
@@ -735,3 +739,74 @@ class SetPronounsViewTest(TestCase):
|
|||||||
self.client.logout()
|
self.client.logout()
|
||||||
response = self.client.post(self.url, data={"pronouns": "misogyny"})
|
response = self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class SetPronounsBawlmoreseUnlockTest(TestCase):
|
||||||
|
"""First-time selection of `pronouns = bawlmorese` grants the Baltimorean
|
||||||
|
Note + returns a Brief banner payload (similar to sky_save's stargazer
|
||||||
|
unlock). Subsequent selections are idempotent — no new Note, no new
|
||||||
|
Brief, status 204 like other pronoun choices."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="aaron@bawlmore.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.url = reverse("set_pronouns")
|
||||||
|
|
||||||
|
def test_first_bawlmorese_returns_200_with_brief_json(self):
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("brief", data)
|
||||||
|
self.assertIsNotNone(data["brief"])
|
||||||
|
|
||||||
|
def test_first_bawlmorese_grants_baltimorean_note(self):
|
||||||
|
self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.assertTrue(
|
||||||
|
Note.objects.filter(user=self.user, slug="baltimorean").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_brief_payload_carries_baltimorean_title(self):
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
brief = response.json()["brief"]
|
||||||
|
self.assertEqual(brief["title"], "Ard!")
|
||||||
|
|
||||||
|
def test_brief_payload_square_url_jumps_to_my_notes(self):
|
||||||
|
"""NOTE_UNLOCK Briefs carry a `square_url` pointing at /billboard/my-notes/
|
||||||
|
so the banner's `?` square jumps straight to the user's Note list."""
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
brief = response.json()["brief"]
|
||||||
|
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
|
||||||
|
|
||||||
|
def test_second_bawlmorese_is_idempotent_no_new_note(self):
|
||||||
|
self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.assertEqual(
|
||||||
|
Note.objects.filter(user=self.user, slug="baltimorean").count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_bawlmorese_returns_204(self):
|
||||||
|
"""No fresh Brief on the repeat call — status returns to the no-payload
|
||||||
|
204 that other pronoun choices use."""
|
||||||
|
self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_non_bawlmorese_choice_does_not_grant_baltimorean(self):
|
||||||
|
self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
|
self.assertFalse(
|
||||||
|
Note.objects.filter(user=self.user, slug="baltimorean").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_bawlmorese_choice_still_returns_204(self):
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_bawlmorese_after_other_choice_still_grants_note(self):
|
||||||
|
"""User picks misogyny first, then bawlmorese — the first bawlmorese
|
||||||
|
click is still the unlock moment regardless of prior pronoun history."""
|
||||||
|
self.client.post(self.url, data={"pronouns": "misogyny"})
|
||||||
|
response = self.client.post(self.url, data={"pronouns": "bawlmorese"})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
|
Note.objects.filter(user=self.user, slug="baltimorean").exists()
|
||||||
|
)
|
||||||
|
|||||||
@@ -38,11 +38,14 @@ _PALETTE_DEFS = [
|
|||||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||||
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||||
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||||
|
{"name": "palette-baltimore", "label": "Baltimore", "locked": True},
|
||||||
|
{"name": "palette-maryland", "label": "Maryland", "locked": True},
|
||||||
]
|
]
|
||||||
_NOTE_TITLES = {
|
_NOTE_TITLES = {
|
||||||
"stargazer": "Stargazer",
|
"stargazer": "Stargazer",
|
||||||
"schizo": "Schizo",
|
"schizo": "Schizo",
|
||||||
"nomad": "Nomad",
|
"nomad": "Nomad",
|
||||||
|
"baltimorean": "Baltimorean",
|
||||||
}
|
}
|
||||||
# Keep PALETTES as an alias used by views that don't have a request user.
|
# Keep PALETTES as an alias used by views that don't have a request user.
|
||||||
PALETTES = _PALETTE_DEFS
|
PALETTES = _PALETTE_DEFS
|
||||||
@@ -131,6 +134,14 @@ def set_pronouns(request):
|
|||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
request.user.pronouns = choice
|
request.user.pronouns = choice
|
||||||
request.user.save(update_fields=["pronouns"])
|
request.user.save(update_fields=["pronouns"])
|
||||||
|
# bawlmorese is the pronoun-side trigger for the Baltimorean Note unlock —
|
||||||
|
# mirrors sky_save's stargazer grant. Grant is idempotent (grant_if_new
|
||||||
|
# returns no Brief on the second + later calls) so the 204 path resumes
|
||||||
|
# naturally after the first unlock.
|
||||||
|
if choice == "bawlmorese":
|
||||||
|
_note, _created, brief = Note.grant_if_new(request.user, "baltimorean")
|
||||||
|
if brief is not None:
|
||||||
|
return JsonResponse({"brief": brief.to_banner_dict()})
|
||||||
return HttpResponse(status=204)
|
return HttpResponse(status=204)
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ _NOTE_DISPLAY = {
|
|||||||
"nomad": {"greeting": "Welcome,", "title": "Nomad"},
|
"nomad": {"greeting": "Welcome,", "title": "Nomad"},
|
||||||
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
|
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
|
||||||
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
|
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
|
||||||
|
"baltimorean": {"greeting": "Ayo,", "title": "Ard!"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Note slugs whose grant prose uses the long admin format ("The administration
|
# Note slugs whose grant prose uses the long admin format ("The administration
|
||||||
|
|||||||
0
src/apps/drama/tests/unit/__init__.py
Normal file
0
src/apps/drama/tests/unit/__init__.py
Normal file
39
src/apps/drama/tests/unit/test_models.py
Normal file
39
src/apps/drama/tests/unit/test_models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.drama.models import Note
|
||||||
|
|
||||||
|
|
||||||
|
def _note(slug):
|
||||||
|
n = Note()
|
||||||
|
n.slug = slug
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
class NoteDisplayBaltimoreanTest(SimpleTestCase):
|
||||||
|
"""`_NOTE_DISPLAY['baltimorean']` — greeting/title combo "Ayo, Ard!" replaces
|
||||||
|
"Welcome, Earthman" once the user dons the Baltimorean Note (unlocked by
|
||||||
|
first-time `pronouns = bawlmorese` selection)."""
|
||||||
|
|
||||||
|
def test_baltimorean_display_title_is_ard(self):
|
||||||
|
self.assertEqual(_note("baltimorean").display_title, "Ard!")
|
||||||
|
|
||||||
|
def test_baltimorean_display_greeting_is_ayo(self):
|
||||||
|
self.assertEqual(_note("baltimorean").display_greeting, "Ayo,")
|
||||||
|
|
||||||
|
def test_baltimorean_display_name_is_baltimorean(self):
|
||||||
|
"""The my-notes heading label — `.title()` of the slug recovers the
|
||||||
|
proper rendering for any slug without a `name` override in
|
||||||
|
`_NOTE_DISPLAY`. Baltimorean has no override, so this should fall
|
||||||
|
through to `slug.title()` → 'Baltimorean'."""
|
||||||
|
self.assertEqual(_note("baltimorean").display_name, "Baltimorean")
|
||||||
|
|
||||||
|
|
||||||
|
class NoteDisplayStargazerTest(SimpleTestCase):
|
||||||
|
"""Smoke tests for the existing stargazer entry — pins the contract that
|
||||||
|
new entries follow."""
|
||||||
|
|
||||||
|
def test_stargazer_display_title_is_stargazer(self):
|
||||||
|
self.assertEqual(_note("stargazer").display_title, "Stargazer")
|
||||||
|
|
||||||
|
def test_stargazer_display_greeting_is_welcome(self):
|
||||||
|
self.assertEqual(_note("stargazer").display_greeting, "Welcome,")
|
||||||
@@ -450,6 +450,16 @@ var GameKit = (function () {
|
|||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
}).then(function (resp) {
|
}).then(function (resp) {
|
||||||
|
if (resp.status === 200) {
|
||||||
|
// First-time `bawlmorese` selection — server returns the
|
||||||
|
// Baltimorean Brief payload to render inline (no reload, or the
|
||||||
|
// banner would be lost the moment the page navigates).
|
||||||
|
return resp.json().then(function (data) {
|
||||||
|
if (data && data.brief && typeof Brief !== 'undefined') {
|
||||||
|
Brief.showBanner(data.brief);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (resp.status === 204) {
|
if (resp.status === 204) {
|
||||||
// Reload so any provenance prose currently on the page renders
|
// Reload so any provenance prose currently on the page renders
|
||||||
// with the new pronouns and the .active class moves to the new card.
|
// with the new pronouns and the .active class moves to the new card.
|
||||||
|
|||||||
321
src/functional_tests/test_bill_baltimorean.py
Normal file
321
src/functional_tests/test_bill_baltimorean.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Functional tests for the Baltimorean Note unlock loop.
|
||||||
|
|
||||||
|
Mirrors the Stargazer unlock pattern (see test_bill_my_notes.py) but the
|
||||||
|
trigger lives on Game Kit's Pronouns applet instead of the My Sky save —
|
||||||
|
selecting `bawlmorese` as the global pronoun for the first time grants the
|
||||||
|
Baltimorean Note and fires the slide-down Brief banner.
|
||||||
|
|
||||||
|
End-to-end loop captured here:
|
||||||
|
1. Game Kit → pronouns applet → click `bawlmorese` card → guard portal "OK"
|
||||||
|
2. Brief banner slides in with title "Ard!" + Look! prose + FYI/NVM/?-square
|
||||||
|
3. Navigate to /billboard/my-notes/ → Baltimorean item w. the Aaron quote
|
||||||
|
4. DON the Baltimorean Note → navbar greeting flips
|
||||||
|
"Welcome, Earthman" → "Ayo, Ard!"
|
||||||
|
5. Click the ? swatch box → modal opens w. Baltimore + Maryland swatches
|
||||||
|
6. Click a Baltimore swatch body → body class previews palette-baltimore
|
||||||
|
7. Click OK in the confirm bar → palette commits (Note.palette saves,
|
||||||
|
user.palette saves, ? box replaced by palette swatch)
|
||||||
|
8. Navigate back to /dashboard/ → Palette applet shows Baltimore swatch
|
||||||
|
unlocked + data-description carrying "Baltimorean" attribution
|
||||||
|
|
||||||
|
Each scenario is its own test method so a regression in any segment fails
|
||||||
|
in isolation (and the slowest segments — Game Kit pronouns flip vs palette
|
||||||
|
modal commit — run independently in --parallel).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from django.utils import timezone
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.drama.models import Note
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_applets():
|
||||||
|
"""Both surfaces this loop visits need their applets registered."""
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="pronouns",
|
||||||
|
defaults={"name": "Pronouns", "grid_cols": 6, "grid_rows": 6, "context": "game-kit"},
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="palette",
|
||||||
|
defaults={"name": "Palette", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="notes",
|
||||||
|
defaults={"name": "Note", "grid_cols": 4, "grid_rows": 4, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaltimoreanNoteFromGameKitTest(FunctionalTest):
|
||||||
|
"""The full Baltimorean unlock loop. Setup creates a fresh gamer; each
|
||||||
|
test seeds either nothing (the unlock-moment tests) or a pre-granted
|
||||||
|
Note (the post-unlock tests) so the surfaces under test are isolated."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# Portrait viewport — kit dialog + my-notes layouts both target the
|
||||||
|
# narrow layout in their respective FT suites (mirrors test_dash_palette
|
||||||
|
# + test_bill_my_notes conventions). 800x1200 keeps the navbar h1 +
|
||||||
|
# guard portal + brief banner all in-frame.
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
_setup_applets()
|
||||||
|
self.gamer = User.objects.create(email="ard@bawlmore.io")
|
||||||
|
self.game_kit_url = self.live_server_url + "/gameboard/game-kit/"
|
||||||
|
self.my_notes_url = self.live_server_url + "/billboard/my-notes/"
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _click_bawlmorese_with_guard_ok(self):
|
||||||
|
"""Game Kit pronouns flow: click bawlmorese card → guard "OK" → commit.
|
||||||
|
Uses execute_script for the OK click to bypass any in-flight scroll-
|
||||||
|
into-view contention (same pattern as test_core_bud_btn helpers)."""
|
||||||
|
card = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, '.gk-pronoun-card[data-pronoun="bawlmorese"]'
|
||||||
|
))
|
||||||
|
card.click()
|
||||||
|
guard_ok = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
|
||||||
|
))
|
||||||
|
self.browser.execute_script(
|
||||||
|
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
|
||||||
|
guard_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── T1 — Game-Kit pronouns click fires the Brief banner ─────────────
|
||||||
|
|
||||||
|
def test_first_bawlmorese_click_fires_baltimorean_brief_banner(self):
|
||||||
|
"""First `bawlmorese` selection from Game Kit's Pronouns applet must
|
||||||
|
slide in a Brief banner carrying the Baltimorean title + a square that
|
||||||
|
jumps to my-notes. The FYI button navigates to the underlying Post."""
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.game_kit_url)
|
||||||
|
|
||||||
|
# No banner on the cold page
|
||||||
|
self.assertFalse(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"),
|
||||||
|
"Brief banner must not be present before the unlock click",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._click_bawlmorese_with_guard_ok()
|
||||||
|
|
||||||
|
banner = self.wait_for_slow(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-banner"
|
||||||
|
))
|
||||||
|
# The title slot carries the recognition title — for baltimorean
|
||||||
|
# that's "Ard!" (vs stargazer's "Stargazer").
|
||||||
|
self.assertIn(
|
||||||
|
"Ard!",
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__title").text,
|
||||||
|
)
|
||||||
|
# Look! prose lands in the description.
|
||||||
|
desc_text = banner.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-banner__description"
|
||||||
|
).text
|
||||||
|
self.assertIn("Look!", desc_text)
|
||||||
|
# Banner gets a timestamp + the ? square anchored to /billboard/my-notes/.
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
|
||||||
|
square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image")
|
||||||
|
self.assertEqual(square.tag_name, "a")
|
||||||
|
self.assertEqual(
|
||||||
|
square.get_attribute("href").rstrip("/"),
|
||||||
|
self.live_server_url + "/billboard/my-notes",
|
||||||
|
)
|
||||||
|
# FYI navigates to the Brief's underlying Post detail.
|
||||||
|
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info")
|
||||||
|
fyi.click()
|
||||||
|
self.wait_for(lambda: self.assertRegex(
|
||||||
|
self.browser.current_url, r"/billboard/post/[0-9a-f-]+/"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── T2 — Idempotence: re-selecting bawlmorese does NOT re-fire banner
|
||||||
|
|
||||||
|
def test_second_bawlmorese_click_does_not_re_fire_banner(self):
|
||||||
|
"""The Baltimorean Note grant is idempotent — clicking bawlmorese
|
||||||
|
again after it's already the user's pronouns + Note is granted must
|
||||||
|
not slide in another banner."""
|
||||||
|
# Pre-grant — short-circuits the Brief on the subsequent click.
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.gamer, slug="baltimorean", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.gamer.pronouns = "bawlmorese"
|
||||||
|
self.gamer.save()
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.game_kit_url)
|
||||||
|
self._click_bawlmorese_with_guard_ok()
|
||||||
|
# The commit reloads — wait for the page to settle, then assert
|
||||||
|
# no banner. Give the reload + render a beat.
|
||||||
|
time.sleep(1.2)
|
||||||
|
self.assertFalse(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner"),
|
||||||
|
"Repeat bawlmorese click must not re-fire the Brief banner",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── T3 — my-notes page renders the Baltimorean item w. the Aaron quote
|
||||||
|
|
||||||
|
def test_my_notes_baltimorean_item_renders_aaron_quote(self):
|
||||||
|
"""Once the Note exists, /billboard/my-notes/ shows a Baltimorean
|
||||||
|
item whose description is literally the Aaron quote (the central
|
||||||
|
joke of the Note)."""
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.gamer, slug="baltimorean", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.my_notes_url)
|
||||||
|
|
||||||
|
item = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, '.note-list .note-item[data-slug="baltimorean"]'
|
||||||
|
))
|
||||||
|
self.assertIn(
|
||||||
|
"Baltimorean",
|
||||||
|
item.find_element(By.CSS_SELECTOR, ".note-item__title").text,
|
||||||
|
)
|
||||||
|
# The Aaron quote is the description verbatim, including the
|
||||||
|
# surrounding double-quotes that the user spec'd.
|
||||||
|
self.assertIn(
|
||||||
|
'"Aaron earned an iron urn."',
|
||||||
|
item.find_element(By.CSS_SELECTOR, ".note-item__description").text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── T4 — DON flips the navbar greeting to "Ayo, Ard!"
|
||||||
|
|
||||||
|
def test_don_baltimorean_changes_navbar_greeting(self):
|
||||||
|
"""Pressing DON on the Baltimorean note item rewrites the navbar h1
|
||||||
|
greeting from "Welcome, Earthman" to "Ayo, Ard!"."""
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.gamer, slug="baltimorean", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.my_notes_url)
|
||||||
|
|
||||||
|
# Pre-condition: cold navbar greeting
|
||||||
|
prefix = self.browser.find_element(By.ID, "id_greeting_prefix")
|
||||||
|
name = self.browser.find_element(By.ID, "id_greeting_name")
|
||||||
|
self.assertEqual(prefix.text.strip().rstrip(","), "Welcome")
|
||||||
|
self.assertEqual(name.text.strip(), "Earthman")
|
||||||
|
|
||||||
|
# Click-lock the item first (the note-page locks on a click before
|
||||||
|
# the DON button becomes interactive — mirrors the test_bill_my_notes
|
||||||
|
# opacity:0 pattern).
|
||||||
|
item = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]'
|
||||||
|
)
|
||||||
|
item.click()
|
||||||
|
don = item.find_element(By.CSS_SELECTOR, ".note-don-btn")
|
||||||
|
self.browser.execute_script(
|
||||||
|
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
|
||||||
|
don,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The greeting flips client-side via the don_title JSON response.
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
self.browser.find_element(By.ID, "id_greeting_prefix").text.strip().rstrip(","),
|
||||||
|
"Ayo",
|
||||||
|
))
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_element(By.ID, "id_greeting_name").text.strip(),
|
||||||
|
"Ard!",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── T5 — palette modal opens to Baltimore + Maryland swatches
|
||||||
|
|
||||||
|
def test_palette_modal_shows_baltimore_and_maryland_swatches(self):
|
||||||
|
"""The ? swatch box opens a modal carrying exactly the two
|
||||||
|
palettes Baltimorean unlocks: Baltimore + Maryland."""
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.gamer, slug="baltimorean", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.my_notes_url)
|
||||||
|
|
||||||
|
item = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]'
|
||||||
|
))
|
||||||
|
# Click-lock the item, then click the ? box.
|
||||||
|
item.click()
|
||||||
|
image_box = item.find_element(By.CSS_SELECTOR, ".note-item__image-box")
|
||||||
|
image_box.click()
|
||||||
|
modal = self.wait_for(lambda: item.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-palette-modal"
|
||||||
|
))
|
||||||
|
# Both swatches present, each w. its human-readable label.
|
||||||
|
labels = [
|
||||||
|
el.text.strip()
|
||||||
|
for el in modal.find_elements(By.CSS_SELECTOR, ".note-swatch-label")
|
||||||
|
]
|
||||||
|
self.assertIn("Baltimore", labels)
|
||||||
|
self.assertIn("Maryland", labels)
|
||||||
|
# Bardo + Sheol (stargazer's palettes) must NOT bleed through.
|
||||||
|
self.assertNotIn("Bardo", labels)
|
||||||
|
self.assertNotIn("Sheol", labels)
|
||||||
|
|
||||||
|
# ── T6 — Baltimore swatch commit + dashboard reflects unlock + ts
|
||||||
|
|
||||||
|
def test_palette_baltimore_commit_persists_and_unlocks_dashboard_swatch(self):
|
||||||
|
"""Clicking the Baltimore swatch body previews palette-baltimore on
|
||||||
|
the body; OK commits — Note.palette + user.palette both saved — so
|
||||||
|
navigating to /dashboard/ shows palette-baltimore active + the
|
||||||
|
Palette applet swatch unlocked with Baltimorean shoptalk +
|
||||||
|
the unlocked-date timestamp wired through."""
|
||||||
|
note = Note.objects.create(
|
||||||
|
user=self.gamer, slug="baltimorean", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.create_pre_authenticated_session("ard@bawlmore.io")
|
||||||
|
self.browser.get(self.my_notes_url)
|
||||||
|
|
||||||
|
item = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, '.note-item[data-slug="baltimorean"]'
|
||||||
|
))
|
||||||
|
item.click()
|
||||||
|
item.find_element(By.CSS_SELECTOR, ".note-item__image-box").click()
|
||||||
|
modal = self.wait_for(lambda: item.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-palette-modal"
|
||||||
|
))
|
||||||
|
baltimore_body = modal.find_element(
|
||||||
|
By.CSS_SELECTOR, ".palette-baltimore .note-swatch-body"
|
||||||
|
)
|
||||||
|
self.browser.execute_script(
|
||||||
|
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
|
||||||
|
baltimore_body,
|
||||||
|
)
|
||||||
|
# Body previews the palette
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"palette-baltimore",
|
||||||
|
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
|
||||||
|
))
|
||||||
|
# OK commits
|
||||||
|
confirm = self.wait_for(lambda: item.find_element(
|
||||||
|
By.CSS_SELECTOR, ".note-palette-confirm"
|
||||||
|
))
|
||||||
|
confirm.find_element(By.CSS_SELECTOR, ".btn.btn-confirm").click()
|
||||||
|
self.wait_for(lambda: self.assertFalse(
|
||||||
|
item.find_elements(By.CSS_SELECTOR, ".note-palette-modal")
|
||||||
|
))
|
||||||
|
|
||||||
|
# Navigate to dashboard — palette-baltimore persists on body +
|
||||||
|
# the Palette applet swatch is unlocked w. the "recognized via
|
||||||
|
# Baltimorean" data-description + a non-empty unlocked-date.
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"palette-baltimore",
|
||||||
|
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
|
||||||
|
))
|
||||||
|
palette_applet = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.ID, "id_applet_palette"
|
||||||
|
))
|
||||||
|
baltimore = palette_applet.find_element(
|
||||||
|
By.CSS_SELECTOR, ".swatch.palette-baltimore"
|
||||||
|
)
|
||||||
|
self.assertNotIn("locked", baltimore.get_attribute("class"))
|
||||||
|
self.assertIn("Baltimorean", baltimore.get_attribute("data-description"))
|
||||||
|
# Unlocked-date carries the Note's earned_at as ISO — non-empty proves
|
||||||
|
# the wire from Note.earned_at through _palettes_for_user.
|
||||||
|
self.assertTrue(baltimore.get_attribute("data-unlocked-date"))
|
||||||
|
# And the same Note row in DB now carries the palette commit.
|
||||||
|
note.refresh_from_db()
|
||||||
|
self.assertEqual(note.palette, "palette-baltimore")
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
:root {
|
:root {
|
||||||
/* rgb Variable Index */
|
/* rgb Variable Index */
|
||||||
|
|
||||||
/* Precious Metal Palette */
|
/* Precious Metal Hues */
|
||||||
// nickel
|
// nickel
|
||||||
--priNi: 141, 142, 140;
|
--priNi: 141, 142, 140;
|
||||||
--secNi: 118, 120, 118;
|
--secNi: 118, 120, 118;
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
--quiAg: 175, 175, 175;
|
--quiAg: 175, 175, 175;
|
||||||
--sixAg: 240, 240, 240;
|
--sixAg: 240, 240, 240;
|
||||||
|
|
||||||
/* Cosmic Metal Palette */
|
/* Cosmic Metal Hues */
|
||||||
// mercury (Mercury)
|
// mercury (Mercury)
|
||||||
--priHg: 23, 31, 51;
|
--priHg: 23, 31, 51;
|
||||||
--secHg: 51, 62, 87;
|
--secHg: 51, 62, 87;
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
--quiPu: 189, 175, 214;
|
--quiPu: 189, 175, 214;
|
||||||
--sixPu: 235, 211, 217;
|
--sixPu: 235, 211, 217;
|
||||||
|
|
||||||
/* Chroma Palette */
|
/* Chroma Hues */
|
||||||
// red (A-Fire)
|
// red (A-Fire)
|
||||||
--priRd: 233, 53, 37;
|
--priRd: 233, 53, 37;
|
||||||
--secRd: 193, 43, 28;
|
--secRd: 193, 43, 28;
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
--quiMe: 89, 0, 48;
|
--quiMe: 89, 0, 48;
|
||||||
--sixMe: 59, 0, 32;
|
--sixMe: 59, 0, 32;
|
||||||
|
|
||||||
/* Earthman Palette */
|
/* Earthman Hues */
|
||||||
// bark
|
// bark
|
||||||
--priBrk: 162, 103, 98;
|
--priBrk: 162, 103, 98;
|
||||||
--secBrk: 117, 78, 68;
|
--secBrk: 117, 78, 68;
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
--secFor: 94, 124, 61;
|
--secFor: 94, 124, 61;
|
||||||
--terFor: 74, 102, 43;
|
--terFor: 74, 102, 43;
|
||||||
|
|
||||||
/* Technoman Palette */
|
/* Technoman Hue */
|
||||||
// carbon steel
|
// carbon steel
|
||||||
// stainless steel
|
// stainless steel
|
||||||
// maraging steel
|
// maraging steel
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
--tooltip-bg: 0, 0, 0;
|
--tooltip-bg: 0, 0, 0;
|
||||||
--title-shadow-offset: -0.125rem;
|
--title-shadow-offset: -0.125rem;
|
||||||
|
|
||||||
/* Inferno Palette (4 per) */
|
/* Inferno Hues (4 per) */
|
||||||
// mist (Elpis's Lethe)
|
// mist (Elpis's Lethe)
|
||||||
--priMst: 168, 202, 172;
|
--priMst: 168, 202, 172;
|
||||||
--secMst: 103, 145, 105;
|
--secMst: 103, 145, 105;
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
--terIce: 74, 119, 125;
|
--terIce: 74, 119, 125;
|
||||||
--quaIce: 35, 65, 75;
|
--quaIce: 35, 65, 75;
|
||||||
|
|
||||||
/* Terrestre Palette (6 per) */
|
/* Terrestre Hues (6 per) */
|
||||||
// crumbling perse (Contrition)
|
// crumbling perse (Contrition)
|
||||||
--priPer: 34, 30, 77;
|
--priPer: 34, 30, 77;
|
||||||
--secPer: 52, 45, 99;
|
--secPer: 52, 45, 99;
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
--quiAdm: 197, 213, 228;
|
--quiAdm: 197, 213, 228;
|
||||||
--sixAdm: 226, 244, 253;
|
--sixAdm: 226, 244, 253;
|
||||||
|
|
||||||
/* Emanation Palettes */
|
/* Emanation Hues */
|
||||||
// Plant Bundle
|
// Plant Bundle
|
||||||
// • beige-pink (streetlamps)
|
// • beige-pink (streetlamps)
|
||||||
--priBpk: 223, 159, 140;
|
--priBpk: 223, 159, 140;
|
||||||
@@ -336,6 +336,23 @@
|
|||||||
--ninClh: 192, 77, 1;
|
--ninClh: 192, 77, 1;
|
||||||
--decClh: 255, 174, 0;
|
--decClh: 255, 174, 0;
|
||||||
|
|
||||||
|
/* Lord Baltimore Hues */
|
||||||
|
// yellow
|
||||||
|
--priBlt: 235, 171, 0;
|
||||||
|
--secBlt: 187, 147, 52;
|
||||||
|
// white
|
||||||
|
--terBlt: 255, 255, 255;
|
||||||
|
// --quaBlt: ;
|
||||||
|
// black
|
||||||
|
--quiBlt: 0, 0, 0;
|
||||||
|
--sixBlt: 162, 170, 173;
|
||||||
|
// purple
|
||||||
|
--sepBlt: 26, 25, 95;
|
||||||
|
--octBlt: 157, 34, 53;
|
||||||
|
// orange
|
||||||
|
--ninBlt: 221, 73, 38;
|
||||||
|
// --decBlt: ;
|
||||||
|
|
||||||
// Felt values
|
// Felt values
|
||||||
--undUser: var(--priFor);
|
--undUser: var(--priFor);
|
||||||
--duoUser: var(--terFor);
|
--duoUser: var(--terFor);
|
||||||
@@ -423,6 +440,34 @@
|
|||||||
|
|
||||||
/* Nebula Palette */
|
/* Nebula Palette */
|
||||||
|
|
||||||
|
/* Baltimore Palette */
|
||||||
|
.palette-baltimore {
|
||||||
|
--priUser: var(--sepBlt);
|
||||||
|
--secUser: var(--terBlt);
|
||||||
|
--terUser: var(--ninBlt);
|
||||||
|
--quaUser: var(--priYl);
|
||||||
|
--quiUser: var(--priBlt);
|
||||||
|
--sixUser: var(--quiBlt);
|
||||||
|
--sepUser: var(--quiBlt);
|
||||||
|
--octUser: var(--quiBlt);
|
||||||
|
--ninUser: var(--sixBlt);
|
||||||
|
--decUser: var(--quiBlt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maryland Palette */
|
||||||
|
.palette-maryland {
|
||||||
|
--priUser: var(--quiBlt);
|
||||||
|
--secUser: var(--sixBlt);
|
||||||
|
--terUser: var(--octBlt);
|
||||||
|
--quaUser: var(--priBlt);
|
||||||
|
--quiUser: var(--secBlt);
|
||||||
|
--sixUser: var(--quiBlt);
|
||||||
|
--sepUser: var(--quiBlt);
|
||||||
|
--octUser: var(--quiBlt);
|
||||||
|
--ninUser: var(--priRd);
|
||||||
|
--decUser: var(--quiBlt);
|
||||||
|
}
|
||||||
|
|
||||||
/* Monochrome Dark Palette */
|
/* Monochrome Dark Palette */
|
||||||
.palette-monochrome-dark {
|
.palette-monochrome-dark {
|
||||||
--priUser: var(--priAg); /* 30,30,30 — near-black bg */
|
--priUser: var(--priAg); /* 30,30,30 — near-black bg */
|
||||||
|
|||||||
@@ -38,5 +38,8 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
<script src="{% static 'apps/epic/stage-card.js' %}"></script>
|
||||||
|
{# Brief.showBanner — needed for the Baltimorean Note-unlock banner the #}
|
||||||
|
{# pronouns applet fires on first `bawlmorese` selection. #}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
<script src="{% static 'apps/gameboard/game-kit.js' %}"></script>
|
<script src="{% static 'apps/gameboard/game-kit.js' %}"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user