Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD

User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs)

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 22:23:24 -04:00
parent 5e71b1d5da
commit 400762c0e5
13 changed files with 570 additions and 10 deletions

View File

@@ -0,0 +1,162 @@
"""FTs for the Game Sign (a.k.a. My Significator) picker + billboard applet.
Sprint 4a of [[project-my-sea-roadmap]]. The picker lives at
`/billboard/my-sign/` — solo lift of the room's sig-select grid (no
countdown / polarity / multi-user). Selection persists on
User.significator + User.significator_reversed. "Significator" remains
the storage-layer term + room sig-select context; this billboard surface
is branded "Sign" / "Game Sign".
"""
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User
def _seed_my_sign_applet():
Applet.objects.get_or_create(
slug="my-sign",
defaults={"name": "Game Sign", "context": "billboard",
"default_visible": True, "grid_cols": 4, "grid_rows": 6},
)
class MySignPickerTest(FunctionalTest):
"""Happy-path picker: a user with the Earthman deck equipped lands at
/billboard/my-sign/, picks a card, clicks SAVE SIGN, and sees the sig
propagate to the Game Sign applet on /billboard/."""
# StaticLiveServerTestCase → TransactionTestCase flushes DB between tests,
# wiping migration-seeded DeckVariant + TarotCard rows. Without this flag,
# personal_sig_cards(user) returns [] because the signal that auto-equips
# Earthman can't find the deck. See [[feedback_transactiontestcase_flush]].
serialized_rollback = True
def setUp(self):
super().setUp()
_seed_my_sign_applet()
# Seed the rest of the billboard applets so /billboard/ renders
# without missing-applet errors.
for slug, name in [
("my-scrolls", "My Scrolls"),
("my-buds", "My Buds"),
("most-recent-scroll", "Most Recent Scroll"),
]:
Applet.objects.get_or_create(
slug=slug, defaults={"name": name, "context": "billboard"},
)
self.email = "sig@test.io"
self.gamer = User.objects.create(email=self.email)
# post_save signal auto-equips Earthman. Picker uses personal_sig_cards
# (= 16 middle arcana + Major 0 & 1, filtered by Note unlocks) so the
# target must come from that subset, not the full deck.
sig_pile = personal_sig_cards(self.gamer)
self.target_card = sig_pile[0] if sig_pile else None
self.assertIsNotNone(
self.target_card,
"personal_sig_cards(user) returned no cards — check Earthman seed"
" + DeckVariant fixture availability in the FT DB.",
)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_picker_renders_card_grid_from_equipped_deck(self):
"""GET /billboard/my-sign/ → page renders w. a card grid + the page
wordmark reads "Game Sign", populated by the user's equipped_deck."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/my-sign/")
# Wordmark. The h2 letter-splitter (base.html) wraps each character
# in its own <span>, so Selenium's `.text` joins them w. newlines —
# strip whitespace before the substring check.
self.wait_for(
lambda: self.assertIn(
"GAMESIGN",
"".join(
self.browser.find_element(By.CSS_SELECTOR, "h2").text.upper().split()
),
)
)
# Target card present in the grid. The data-card-id selector itself
# is the assertion — find_element raises if absent. Avoid asserting
# against `.fan-corner-rank .text` (CSS hides it via font-size or
# similar, so Selenium returns "").
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]',
)
)
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_pick_card_then_save_persists_choice_and_shows_in_applet(self):
"""Click card → SAVE SIGN btn enables → click → DB updated → applet
on /billboard/ shows the chosen card."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/my-sign/")
# Click target card
card_el = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]',
)
)
self.browser.execute_script("arguments[0].click()", card_el)
# SAVE SIGN should be enabled now
save_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_save_sign_btn")
)
self.wait_for(
lambda: self.assertFalse(
save_btn.get_attribute("disabled"),
"SAVE SIGN should be enabled after card click",
)
)
# Hidden card_id input should match the clicked card
self.assertEqual(
str(self.target_card.id),
self.browser.find_element(By.ID, "id_save_sign_card_id").get_attribute("value"),
)
# Save → DB updated
self.browser.execute_script("arguments[0].click()", save_btn)
self.wait_for(
lambda: self.gamer.refresh_from_db() or self.assertEqual(
self.gamer.significator_id, self.target_card.id,
)
)
# Navigate to /billboard/ → applet shows the saved card. Pin by
# data-card-id (same reasoning as test 1 re: CSS-hidden corner rank).
self.browser.get(self.live_server_url + "/billboard/")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'#id_applet_my_sign .my-sign-applet-card[data-card-id="{self.target_card.id}"]',
)
)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_applet_renders_blank_state_when_no_sig_chosen(self):
"""Fresh user with no significator → applet shows the empty-state
copy, no card."""
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/billboard/")
empty = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-empty"
)
)
self.assertIn("No sign chosen", empty.text)
self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-card"
)),
0,
)