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:
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")
|
||||
Reference in New Issue
Block a user