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, EarthmanAyo, 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, EarthmanAyo, 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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

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:
Disco DeDisco
2026-05-18 02:17:07 -04:00
parent bc77296dd4
commit 435a192349
11 changed files with 544 additions and 8 deletions

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