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:
162
src/functional_tests/test_bill_my_sign.py
Normal file
162
src/functional_tests/test_bill_my_sign.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user