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,39 @@
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
term (User.significator FK, room sig-select) — this billboard surface is
branded "Game Sign". 4×6 (narrow + tall), seeded after all other billboard
applets so it renders at the end of the billboard grid. Shows the user's
saved significator card or a blank state.
"""
from django.db import migrations
def seed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.update_or_create(
slug="my-sign",
defaults={
"name": "Game Sign",
"context": "billboard",
"default_visible": True,
"grid_cols": 4,
"grid_rows": 6,
},
)
def unseed(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0008_seed_my_sea_applet"),
]
operations = [
migrations.RunPython(seed, unseed),
]

View File

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

View File

@@ -23,4 +23,6 @@ urlpatterns = [
path("my-buds/", views.my_buds, name="my_buds"),
path("buds/add", views.add_bud, name="add_bud"),
path("buds/search", views.search_buds, name="search_buds"),
path("my-sign/", views.my_sign, name="my_sign"),
path("my-sign/save", views.save_sign, name="save_sign"),
]

View File

@@ -253,6 +253,52 @@ def doff_title(request, slug):
return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
# ── My Sign — global Significator picker (billboard surface) ────────────────
# Standalone page where a user picks their global personal significator. The
# selection persists on User.significator + User.significator_reversed and is
# reused across My Sea draws (and eventually other contexts). "Sign" is the
# billboard-context branding; "significator" stays at the storage layer +
# room sig-select context to keep the DRY model. Sprint 4a of
# [[project-my-sea-roadmap]] — picker UI is a simplified lift of the room's
# `_sig_select_overlay.html` (no countdown / WS / polarity / multi-user).
# Deck-source fallback (Brief-redirect to Game Kit when no equipped deck;
# Earthman-Backup default) deferred to a follow-up sub-sprint.
@login_required(login_url="/")
def my_sign(request):
"""Render the picker — same 18-card pile as room sig-select (16 middle
arcana courts + Major 0 & 1), pulled from the user's equipped deck.
Polarity is determined post-hoc by the FLIP btn (significator_reversed)."""
from apps.epic.models import personal_sig_cards
deck = request.user.equipped_deck
cards = personal_sig_cards(request.user) if deck else []
return render(request, "apps/billboard/my_sign.html", {
"cards": cards,
"equipped_deck": deck,
"current_significator": request.user.significator,
"current_significator_reversed": request.user.significator_reversed,
"page_class": "page-billboard page-my-sign",
})
@login_required(login_url="/")
def save_sign(request):
"""Persist the user's sign choice — POST { card_id, reversed }."""
from apps.epic.models import TarotCard
if request.method != "POST":
return redirect("billboard:my_sign")
card_id = request.POST.get("card_id")
reversed_flag = request.POST.get("reversed") in ("1", "true", "True", "on")
try:
card = TarotCard.objects.get(pk=card_id)
except (TarotCard.DoesNotExist, ValueError, TypeError):
return HttpResponseForbidden("invalid card_id")
request.user.significator = card
request.user.significator_reversed = reversed_flag
request.user.save(update_fields=["significator", "significator_reversed"])
return redirect("billboard:my_sign")
# ── Post / Line CRUD (relocated from apps.dashboard) ────────────────────────
# Templates also live under templates/apps/billboard/. URL names sit in the
# `billboard:` namespace so reversers across the codebase carry the prefix.

View File

@@ -495,9 +495,11 @@ def sig_deck_cards(room):
return unique_cards + unique_cards # × 2 = 36
def _sig_unique_cards(room):
"""Return the 18 unique TarotCard objects that form one sig pile."""
deck_variant = _room_deck_variant(room)
def _sig_unique_cards_for_deck(deck_variant):
"""Return the 18 unique TarotCards forming one sig pile for the given
deck variant. Shared between room sig-select (called via _sig_unique_cards
after room → deck_variant lookup) and the solo My Sig picker (called
via personal_sig_cards from User.equipped_deck)."""
if deck_variant is None:
return []
wands_crowns = list(TarotCard.objects.filter(
@@ -520,6 +522,21 @@ def _sig_unique_cards(room):
return wands_crowns + swords_cups + major
def _sig_unique_cards(room):
"""Return the 18 unique TarotCard objects that form one sig pile."""
return _sig_unique_cards_for_deck(_room_deck_variant(room))
def personal_sig_cards(user):
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
User.equipped_deck instead of room.deck_variant. For the My Sig picker
at /billboard/my-sig/. Same 18-card pile (16 middle arcana + Major 0 + 1),
filtered by the user's Note unlocks (Schizo/Nomad lines)."""
return _filter_major_unlocks(
_sig_unique_cards_for_deck(user.equipped_deck), user,
)
def _filter_major_unlocks(cards, user):
"""Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
if user is None or not user.is_authenticated:

View File

@@ -503,6 +503,37 @@ class SigCardHelperTest(TestCase):
self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
class PersonalSigCardsTest(TestCase):
"""personal_sig_cards(user) — solo (room-less) sig pile sourced from
User.equipped_deck. Same 18-card pile + Note-unlock filtering as
levity_sig_cards / gravity_sig_cards (which route through a room)."""
def test_fresh_user_gets_16_cards_via_auto_equipped_earthman(self):
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="solo@test.io")
# post_save signal auto-equips Earthman; no Schizo/Nomad notes yet,
# so Majors 0 and 1 are filtered out by _filter_major_unlocks.
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 16)
def test_empty_when_user_has_no_equipped_deck(self):
from apps.epic.models import personal_sig_cards
user = User.objects.create(email="dekless@test.io")
user.equipped_deck = None
user.save(update_fields=["equipped_deck"])
self.assertEqual(personal_sig_cards(user), [])
def test_schizo_note_unlocks_major_1(self):
from apps.drama.models import Note
from apps.epic.models import personal_sig_cards
from django.utils import timezone
user = User.objects.create(email="schizo@test.io")
Note.objects.create(user=user, slug="schizo", earned_at=timezone.now())
cards = personal_sig_cards(user)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
class TarotCardCautionsTest(TestCase):
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2026-05-19 01:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0008_blades_reversal_fickle'),
('lyric', '0005_rename_buddies_to_buds'),
]
operations = [
migrations.AddField(
model_name='user',
name='significator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='epic.tarotcard'),
),
migrations.AddField(
model_name='user',
name='significator_reversed',
field=models.BooleanField(default=False),
),
]

View File

@@ -125,6 +125,15 @@ class User(AbstractBaseUser):
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
# Global personal significator — chosen at /billboard/my-sig/ + persisted
# for reuse across My Sea draws (and eventually other contexts). Single
# FK; the orientation in `significator_reversed` (FLIP btn in the picker
# carousel) determines polarity at draw time.
significator = models.ForeignKey(
"epic.TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
significator_reversed = models.BooleanField(default=False)
ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="")