diff --git a/src/apps/applets/migrations/0009_seed_my_sig_applet.py b/src/apps/applets/migrations/0009_seed_my_sig_applet.py new file mode 100644 index 0000000..19178c1 --- /dev/null +++ b/src/apps/applets/migrations/0009_seed_my_sig_applet.py @@ -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), + ] diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 67d86fc..e6287e4 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -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") diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index 460edfe..d61e0c2 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -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"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index d627e43..20d3a51 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -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. diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 97bfd6b..2213e77 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -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: diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index ac2debf..a493f60 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -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.""" diff --git a/src/apps/lyric/migrations/0006_user_significator_user_significator_reversed.py b/src/apps/lyric/migrations/0006_user_significator_user_significator_reversed.py new file mode 100644 index 0000000..6da0d54 --- /dev/null +++ b/src/apps/lyric/migrations/0006_user_significator_user_significator_reversed.py @@ -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), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 7985ce2..9125445 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -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="") diff --git a/src/functional_tests/test_bill_my_sign.py b/src/functional_tests/test_bill_my_sign.py new file mode 100644 index 0000000..ae85bc0 --- /dev/null +++ b/src/functional_tests/test_bill_my_sign.py @@ -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 , 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, + ) diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index 5edc5aa..e21814c 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -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); diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html new file mode 100644 index 0000000..104dbc5 --- /dev/null +++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html @@ -0,0 +1,22 @@ +
+

Game Sign

+
+ {% if request.user.significator %} + {% with card=request.user.significator %} +
+
+ {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} +
+

{{ card.name_title }}

+
+ {% endwith %} + {% else %} +

No sign chosen yet.

+ {% endif %} +
+
diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html new file mode 100644 index 0000000..1ead9e3 --- /dev/null +++ b/src/templates/apps/billboard/my_sign.html @@ -0,0 +1,122 @@ +{% extends "core/base.html" %} +{% load static %} + +{% block title_text %}Game Sign{% endblock title_text %} +{% block header_text %}GameSign{% 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". #} +
+ + {% if not equipped_deck %} +

+ Equip a card deck first in the + Game Kit to pick your significator. +

+ {% else %} +
+ +
+ +
+

Emanation

+
    +
    +
    +

    Reversal

    +
      +
      +
      +
      + +
      + {% for card in cards %} +
      +
      + {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} +
      +
      + {% endfor %} +
      + +
      + {% csrf_token %} + + + +
      + + {# 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. #} + + {% endif %} +
      +{% endblock content %} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 629bce7..736492c 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -1,13 +1,13 @@ {% extends "core/base.html" %} {% load static %} -{% block title_text %}My Sea{% endblock title_text %} -{% block header_text %}MySea{% endblock header_text %} +{% block title_text %}Game Sea{% endblock title_text %} +{% block header_text %}GameSea{% endblock header_text %} {% block content %}
      {# Sprint 3 shell only — gatekeeper / sig-select / sea-select phases #} {# will land here in later sprints of the My Sea roadmap. #} -

      Your sea is calm. Draws will appear here.

      +

      No draws yet—the depths remain unfathomable.

      {% endblock content %}