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:
@@ -15,6 +15,7 @@ def _seed_billboard_applets():
|
||||
("my-scrolls", "My Scrolls", 4, 3),
|
||||
("my-buds", "My Buds", 4, 3),
|
||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
||||
("my-sign", "Game Sign", 4, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
@@ -812,3 +813,87 @@ class AnonymousPostViewerTest(TestCase):
|
||||
reverse("billboard:view_post", args=[self.post.id])
|
||||
)
|
||||
self.assertNotIn(b"id_post_menu", response.content)
|
||||
|
||||
|
||||
class MySignViewTest(TestCase):
|
||||
"""Game Sign picker view at /billboard/my-sign/ — Sprint 4a of
|
||||
[[project-my-sea-roadmap]]. Pins the GET render + POST save contract."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="sign@test.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_my_sign_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
self.assertRedirects(
|
||||
response, "/?next=/billboard/my-sign/", fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_my_sign_renders_200(self):
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/my_sign.html")
|
||||
|
||||
def test_my_sign_passes_18_card_pile_for_user_w_no_notes(self):
|
||||
# The signal auto-equips Earthman; personal_sig_cards returns 16
|
||||
# middle arcana courts (Majors 0/1 filtered out w.o Schizo/Nomad).
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
self.assertEqual(len(response.context["cards"]), 16)
|
||||
|
||||
def test_save_sign_persists_card_and_reversed_flag(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
response = self.client.post(
|
||||
reverse("billboard:save_sign"),
|
||||
{"card_id": target.id, "reversed": "1"},
|
||||
)
|
||||
self.assertRedirects(response, reverse("billboard:my_sign"))
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.significator_id, target.id)
|
||||
self.assertTrue(self.user.significator_reversed)
|
||||
|
||||
def test_save_sign_rejects_invalid_card_id(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:save_sign"),
|
||||
{"card_id": 999999, "reversed": "0"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.significator_id)
|
||||
|
||||
def test_save_sign_get_redirects_back_to_picker(self):
|
||||
response = self.client.get(reverse("billboard:save_sign"))
|
||||
self.assertRedirects(response, reverse("billboard:my_sign"))
|
||||
|
||||
|
||||
class BillboardAppletMySignTest(TestCase):
|
||||
"""My Sign applet rendering on /billboard/."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="apllet@test.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_billboard_shows_my_sign_applet(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertContains(response, 'id="id_applet_my_sign"')
|
||||
|
||||
def test_my_sign_applet_renders_empty_state_when_no_sig(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertContains(response, "my-sign-applet-empty")
|
||||
self.assertContains(response, "No sign chosen yet.")
|
||||
self.assertNotContains(response, "my-sign-applet-card")
|
||||
|
||||
def test_my_sign_applet_renders_card_when_sig_set(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = target
|
||||
self.user.significator_reversed = True
|
||||
self.user.save(update_fields=["significator", "significator_reversed"])
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertContains(response, "my-sign-applet-card")
|
||||
self.assertContains(response, f'data-card-id="{target.id}"')
|
||||
# significator_reversed = True → card carries stage-card--reversed class
|
||||
self.assertContains(response, "stage-card--reversed")
|
||||
|
||||
Reference in New Issue
Block a user