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:
39
src/apps/applets/migrations/0009_seed_my_sig_applet.py
Normal file
39
src/apps/applets/migrations/0009_seed_my_sig_applet.py
Normal 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),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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="")
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -445,8 +445,8 @@
|
||||
--priUser: var(--sepBlt);
|
||||
--secUser: var(--terBlt);
|
||||
--terUser: var(--ninBlt);
|
||||
--quaUser: var(--priYl);
|
||||
--quiUser: var(--priBlt);
|
||||
--quaUser: var(--priBlt);
|
||||
--quiUser: var(--terYl);
|
||||
--sixUser: var(--quiBlt);
|
||||
--sepUser: var(--quiBlt);
|
||||
--octUser: var(--quiBlt);
|
||||
@@ -458,9 +458,9 @@
|
||||
.palette-maryland {
|
||||
--priUser: var(--quiBlt);
|
||||
--secUser: var(--sixBlt);
|
||||
--terUser: var(--octBlt);
|
||||
--terUser: var(--terYl);
|
||||
--quaUser: var(--priBlt);
|
||||
--quiUser: var(--secBlt);
|
||||
--quiUser: var(--octBlt);
|
||||
--sixUser: var(--quiBlt);
|
||||
--sepUser: var(--quiBlt);
|
||||
--octUser: var(--quiBlt);
|
||||
|
||||
22
src/templates/apps/billboard/_partials/_applet-my-sign.html
Normal file
22
src/templates/apps/billboard/_partials/_applet-my-sign.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<section
|
||||
id="id_applet_my_sign"
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>
|
||||
<div class="my-sign-applet-body">
|
||||
{% if request.user.significator %}
|
||||
{% with card=request.user.significator %}
|
||||
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
|
||||
data-card-id="{{ card.id }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<p class="fan-card-name">{{ card.name_title }}</p>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p class="my-sign-applet-empty">No sign chosen yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
122
src/templates/apps/billboard/my_sign.html
Normal file
122
src/templates/apps/billboard/my_sign.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}Game Sign{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Sign</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# Solo lift of `_sig_select_overlay.html`. Same card-grid + stage-card #}
|
||||
{# choreography as the room sig-select, minus countdown / WebSocket / #}
|
||||
{# polarity / multi-user. FLIP btn (.spin-btn) lets the user choose the #}
|
||||
{# card's orientation; SAVE SIG persists it on the User model. #}
|
||||
{# "Significator" is preserved at the storage layer (User.significator) + #}
|
||||
{# game-room context; this billboard surface re-brands to "Sign". #}
|
||||
<div class="my-sign-page"
|
||||
data-save-url="{% url 'billboard:save_sign' %}"
|
||||
{% if current_significator %}data-current-card-id="{{ current_significator.id }}"{% endif %}
|
||||
data-current-reversed="{{ current_significator_reversed|yesno:'true,false' }}">
|
||||
|
||||
{% if not equipped_deck %}
|
||||
<p class="my-sign-empty">
|
||||
Equip a card deck first in the
|
||||
<a href="{% url 'gameboard' %}">Game Kit</a> to pick your significator.
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="my-sign-stage">
|
||||
<div class="sig-stage-card" style="display:none">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<p class="fan-card-name"></p>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block">
|
||||
<button class="btn btn-reverse spin-btn" type="button">FLIP</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-sign-deck-grid">
|
||||
{% for card in cards %}
|
||||
<div class="sig-card"
|
||||
data-card-id="{{ card.id }}"
|
||||
data-suit-icon="{{ card.suit_icon }}"
|
||||
data-corner-rank="{{ card.corner_rank }}"
|
||||
data-name-group="{{ card.name_group }}"
|
||||
data-name-title="{{ card.name_title }}"
|
||||
data-arcana="{{ card.get_arcana_display }}"
|
||||
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
||||
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
||||
data-reversal-qualifier="{{ card.reversal_qualifier }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form id="id_save_sign_form" method="POST" action="{% url 'billboard:save_sign' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="card_id" id="id_save_sign_card_id" value="{{ current_significator.id|default:'' }}">
|
||||
<input type="hidden" name="reversed" id="id_save_sign_reversed" value="{{ current_significator_reversed|yesno:'1,0' }}">
|
||||
<button type="submit" id="id_save_sign_btn" class="btn btn-primary"{% if not current_significator %} disabled{% endif %}>SAVE SIGN</button>
|
||||
</form>
|
||||
|
||||
{# Minimal picker JS — click .sig-card to pick + enable SAVE SIGN. #}
|
||||
{# FLIP-btn integration (reversed toggle) lands w. the stage-card #}
|
||||
{# preview JS in a Sprint 4a-follow-up; for now we just record #}
|
||||
{# the chosen card_id + the FLIP state. #}
|
||||
<script>
|
||||
(function () {
|
||||
var grid = document.querySelector('.my-sign-deck-grid');
|
||||
if (!grid) return;
|
||||
var cardIdInput = document.getElementById('id_save_sign_card_id');
|
||||
var saveBtn = document.getElementById('id_save_sign_btn');
|
||||
var flipBtn = document.querySelector('.my-sign-stage .spin-btn');
|
||||
var revInput = document.getElementById('id_save_sign_reversed');
|
||||
grid.addEventListener('click', function (e) {
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (!card) return;
|
||||
grid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
c.classList.remove('sig-focused');
|
||||
});
|
||||
card.classList.add('sig-focused');
|
||||
cardIdInput.value = card.dataset.cardId;
|
||||
saveBtn.removeAttribute('disabled');
|
||||
});
|
||||
if (flipBtn) {
|
||||
flipBtn.addEventListener('click', function () {
|
||||
var current = revInput.value === '1';
|
||||
revInput.value = current ? '0' : '1';
|
||||
flipBtn.classList.toggle('is-reversed', !current);
|
||||
});
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title_text %}My Sea{% endblock title_text %}
|
||||
{% block header_text %}<span>My</span><span>Sea</span>{% endblock header_text %}
|
||||
{% block title_text %}Game Sea{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="my-sea-page">
|
||||
{# Sprint 3 shell only — gatekeeper / sig-select / sea-select phases #}
|
||||
{# will land here in later sprints of the My Sea roadmap. #}
|
||||
<p class="my-sea-page__empty">Your sea is calm. Draws will appear here.</p>
|
||||
<p class="my-sea-page__empty">No draws yet—the depths remain unfathomable.</p>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user