My Sign: Brief banner + Earthman [Shabby Cardstock] backup deck when no equipped — TDD
User-reported gap on /billboard/my-sign/ — admin user's only deck was in-use as `TableSeat.deck_variant` in another room (Wonderbeard) → `equipped_deck` cleared → previous my-sign template showed "Equip a card deck first…" w. no actionable next step. User scoped fix: don't force equip, just nudge via a Brief banner "Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.", title "Default deck warning". NVM dismisses + picker proceeds against an Earthman card pile labeled in-copy as the temporary backup; FYI links to /gameboard/ (Game Kit equip). User-modified line text in template: "Paperboard" → "Cardstock" mid-session ; **helper fallback** (epic/models.py): `personal_sig_cards(user)` now falls back to `DeckVariant.objects.filter(slug='earthman').first()` when `user.equipped_deck` is None — same 16-or-18 card pile, just sourced from the canonical Earthman deck rather than the empty FK. No new DeckVariant row needed; "Shabby Cardstock" is purely UX framing (cards are the same TarotCard records the room sig-select uses). Preserves the existing helper signature so no callers had to change ; **view + template** (billboard/views.py + my_sign.html): view passes `no_equipped_deck` + `show_backup_intro_banner` flags. Template removes the old `{% if not equipped_deck %}` forced-equip branch — picker now renders unconditionally w. cards from the backup helper when no deck is equipped. Brief banner fires via `Brief.showBanner({...})` on DOMContentLoaded when `show_backup_intro_banner` is true — gets h2-overlay positioning + NVM behavior + portal styling for free (per [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement in note.js's `_alignToH2`). Added `<script src="note.js">` to my_sign.html since the page didn't load it before. Post-render JS tags the Brief w. a `.my-sign-intro-banner` class so FTs (and any future my-sign-specific styling) can distinguish this nudge from other Briefs on the page ; **TDD trail** — 4 new FTs in `MySignBackupDeckTest` (test_bill_my_sign.py): T1 banner renders w. "Default deck warning" title + "no deck is equipped" + "Shabby Cardstock" copy + both action btns visible; T2 picker still populates 16 cards from backup; T3 NVM click removes the banner from the DOM; T4 FYI href ends w. /gameboard/. Initial reds (`NoSuchElementException` on all 4) confirmed before implementation. Plus 1 new IT in `PersonalSigCardsTest` pinning the helper fallback (16 cards w. all `c.deck_variant.slug == "earthman"`) ; pre-existing change picked up: `static_src/scss/rootvars.scss` (user-modified mid-session) ; 1020 IT/UT green; 7 FTs green (3 picker happy-path + 4 backup deck) in 56s. Sprint 4a-follow complete — primary deferral from Sprint 4a (deck-source fallback UX) now landed. Unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/ when no sig set)
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:
@@ -268,14 +268,21 @@ def doff_title(request, slug):
|
|||||||
def my_sign(request):
|
def my_sign(request):
|
||||||
"""Render the picker — same 18-card pile as room sig-select (16 middle
|
"""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.
|
arcana courts + Major 0 & 1), pulled from the user's equipped deck.
|
||||||
Polarity is determined post-hoc by the FLIP btn (significator_reversed)."""
|
Polarity is determined post-hoc by the FLIP btn (significator_reversed).
|
||||||
|
|
||||||
|
Backup-deck branch: if the user has no equipped_deck AND no saved sig,
|
||||||
|
`personal_sig_cards` falls back to the Earthman pile and the template
|
||||||
|
renders an intro Brief banner labeling the backup as "Earthman [Shabby
|
||||||
|
Paperboard]" with FYI (→ Game Kit) + NVM (dismiss + proceed) actions."""
|
||||||
from apps.epic.models import personal_sig_cards
|
from apps.epic.models import personal_sig_cards
|
||||||
deck = request.user.equipped_deck
|
cards = personal_sig_cards(request.user)
|
||||||
cards = personal_sig_cards(request.user) if deck else []
|
no_equipped_deck = request.user.equipped_deck is None
|
||||||
|
sig = request.user.significator
|
||||||
return render(request, "apps/billboard/my_sign.html", {
|
return render(request, "apps/billboard/my_sign.html", {
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
"equipped_deck": deck,
|
"no_equipped_deck": no_equipped_deck,
|
||||||
"current_significator": request.user.significator,
|
"show_backup_intro_banner": no_equipped_deck and sig is None,
|
||||||
|
"current_significator": sig,
|
||||||
"current_significator_reversed": request.user.significator_reversed,
|
"current_significator_reversed": request.user.significator_reversed,
|
||||||
"page_class": "page-billboard page-my-sign",
|
"page_class": "page-billboard page-my-sign",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -529,12 +529,18 @@ def _sig_unique_cards(room):
|
|||||||
|
|
||||||
def personal_sig_cards(user):
|
def personal_sig_cards(user):
|
||||||
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
|
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
|
||||||
User.equipped_deck instead of room.deck_variant. For the My Sig picker
|
User.equipped_deck instead of room.deck_variant. For the Game Sign
|
||||||
at /billboard/my-sig/. Same 18-card pile (16 middle arcana + Major 0 + 1),
|
picker at /billboard/my-sign/. Same 18-card pile (16 middle arcana +
|
||||||
filtered by the user's Note unlocks (Schizo/Nomad lines)."""
|
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,
|
Fallback: if the user has no equipped_deck (e.g. their only deck is
|
||||||
)
|
in-use as a TableSeat.deck_variant in an active room), fall back to
|
||||||
|
the Earthman deck. The picker UI labels this "Earthman [Shabby
|
||||||
|
Paperboard]" via a Brief banner — the cards are identical, the deck
|
||||||
|
identity is just a UX framing for "temporary, doesn't belong to your
|
||||||
|
Game Kit inventory"."""
|
||||||
|
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
return _filter_major_unlocks(_sig_unique_cards_for_deck(deck), user)
|
||||||
|
|
||||||
|
|
||||||
def _filter_major_unlocks(cards, user):
|
def _filter_major_unlocks(cards, user):
|
||||||
|
|||||||
@@ -516,12 +516,20 @@ class PersonalSigCardsTest(TestCase):
|
|||||||
cards = personal_sig_cards(user)
|
cards = personal_sig_cards(user)
|
||||||
self.assertEqual(len(cards), 16)
|
self.assertEqual(len(cards), 16)
|
||||||
|
|
||||||
def test_empty_when_user_has_no_equipped_deck(self):
|
def test_falls_back_to_earthman_when_no_equipped_deck(self):
|
||||||
|
"""Sprint 4a-follow contract: instead of returning an empty pile when
|
||||||
|
the user has no equipped_deck (e.g. their deck is in-use as a
|
||||||
|
TableSeat.deck_variant in an active room), personal_sig_cards falls
|
||||||
|
back to the Earthman deck. The picker labels this "Earthman [Shabby
|
||||||
|
Paperboard]" via a Brief banner at the view layer."""
|
||||||
from apps.epic.models import personal_sig_cards
|
from apps.epic.models import personal_sig_cards
|
||||||
user = User.objects.create(email="dekless@test.io")
|
user = User.objects.create(email="dekless@test.io")
|
||||||
user.equipped_deck = None
|
user.equipped_deck = None
|
||||||
user.save(update_fields=["equipped_deck"])
|
user.save(update_fields=["equipped_deck"])
|
||||||
self.assertEqual(personal_sig_cards(user), [])
|
cards = personal_sig_cards(user)
|
||||||
|
self.assertEqual(len(cards), 16)
|
||||||
|
# All cards should belong to the Earthman deck (the fallback)
|
||||||
|
self.assertTrue(all(c.deck_variant.slug == "earthman" for c in cards))
|
||||||
|
|
||||||
def test_schizo_note_unlocks_major_1(self):
|
def test_schizo_note_unlocks_major_1(self):
|
||||||
from apps.drama.models import Note
|
from apps.drama.models import Note
|
||||||
|
|||||||
@@ -160,3 +160,109 @@ class MySignPickerTest(FunctionalTest):
|
|||||||
)),
|
)),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MySignBackupDeckTest(FunctionalTest):
|
||||||
|
"""User with no equipped_deck + no saved sig should still be able to pick.
|
||||||
|
|
||||||
|
Sprint 4a-follow: no-equipped-deck path. Page renders a Brief banner
|
||||||
|
nudging "Look!—no deck is equipped. Navigate to the game kit to equip
|
||||||
|
one (FYI) or (NVM) proceed with Earthman [Shabby Paperboard] deck." —
|
||||||
|
NVM dismisses + picker stays usable w. backup deck cards; FYI links
|
||||||
|
to the gameboard (Game Kit applet)."""
|
||||||
|
|
||||||
|
serialized_rollback = True
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
for slug, name in [
|
||||||
|
("my-sign", "Game Sign"), ("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 = "dekless@test.io"
|
||||||
|
self.gamer = User.objects.create(email=self.email)
|
||||||
|
# Simulate "deck-in-use elsewhere" — clear equipped_deck (the
|
||||||
|
# symmetry of the room flow that hands the deck off to a TableSeat).
|
||||||
|
self.gamer.equipped_deck = None
|
||||||
|
self.gamer.save(update_fields=["equipped_deck"])
|
||||||
|
|
||||||
|
# ── Test 1 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_brief_banner_renders_when_no_deck_equipped_and_no_sig(self):
|
||||||
|
"""Banner should appear via Brief.showBanner() w. title "Default deck
|
||||||
|
warning" + the Shabby Cardstock copy + FYI + NVM buttons."""
|
||||||
|
self.create_pre_authenticated_session(self.email)
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-sign/")
|
||||||
|
banner = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".my-sign-intro-banner"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIn("Default deck warning", banner.text)
|
||||||
|
self.assertIn("no deck is equipped", banner.text)
|
||||||
|
self.assertIn("Shabby Cardstock", banner.text)
|
||||||
|
# Brief banner action buttons: .note-banner__nvm + .note-banner__fyi
|
||||||
|
self.assertTrue(
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi").is_displayed()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm").is_displayed()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Test 2 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_picker_renders_18_card_pile_using_backup_deck(self):
|
||||||
|
"""No equipped deck → personal_sig_cards falls back to Earthman, so the
|
||||||
|
picker grid still has the full sig pile (16 cards w. no Note unlocks)."""
|
||||||
|
self.create_pre_authenticated_session(self.email)
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-sign/")
|
||||||
|
# Grid present + populated (find_elements returns N cards)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sign-deck-grid")
|
||||||
|
)
|
||||||
|
cards = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".my-sign-deck-grid .sig-card"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(cards), 16)
|
||||||
|
|
||||||
|
# ── Test 3 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_nvm_dismisses_banner_and_keeps_picker_usable(self):
|
||||||
|
"""NVM click hides the banner; the grid remains interactive (can
|
||||||
|
click a sig-card + SAVE SIGN persists against the backup deck)."""
|
||||||
|
self.create_pre_authenticated_session(self.email)
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-sign/")
|
||||||
|
banner = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sign-intro-banner")
|
||||||
|
)
|
||||||
|
banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
len(self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".my-sign-intro-banner"
|
||||||
|
)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fyi_links_to_gameboard(self):
|
||||||
|
"""FYI button is `.note-banner__fyi` rendered as <a href> by
|
||||||
|
Brief.showBanner pointing at the Brief's `post_url` — here /gameboard/
|
||||||
|
so the user can equip a deck via Game Kit."""
|
||||||
|
self.create_pre_authenticated_session(self.email)
|
||||||
|
self.browser.get(self.live_server_url + "/billboard/my-sign/")
|
||||||
|
fyi = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".my-sign-intro-banner .note-banner__fyi"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
href = fyi.get_attribute("href") or ""
|
||||||
|
self.assertTrue(
|
||||||
|
href.endswith("/gameboard/"),
|
||||||
|
f"FYI should link to /gameboard/, got {href!r}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -338,7 +338,7 @@
|
|||||||
|
|
||||||
/* Lord Baltimore Hues */
|
/* Lord Baltimore Hues */
|
||||||
// yellow
|
// yellow
|
||||||
--priBlt: 235, 171, 0;
|
--priBlt: 235, 191, 0;
|
||||||
--secBlt: 187, 147, 52;
|
--secBlt: 187, 147, 52;
|
||||||
// white
|
// white
|
||||||
--terBlt: 255, 255, 255;
|
--terBlt: 255, 255, 255;
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
--quiBlt: 0, 0, 0;
|
--quiBlt: 0, 0, 0;
|
||||||
--sixBlt: 162, 170, 173;
|
--sixBlt: 162, 170, 173;
|
||||||
// purple
|
// purple
|
||||||
--sepBlt: 26, 25, 95;
|
--sepBlt: 50, 30, 95;
|
||||||
--octBlt: 157, 34, 53;
|
--octBlt: 157, 34, 53;
|
||||||
// orange
|
// orange
|
||||||
--ninBlt: 221, 73, 38;
|
--ninBlt: 221, 73, 38;
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
--secUser: var(--terBlt);
|
--secUser: var(--terBlt);
|
||||||
--terUser: var(--ninBlt);
|
--terUser: var(--ninBlt);
|
||||||
--quaUser: var(--priBlt);
|
--quaUser: var(--priBlt);
|
||||||
--quiUser: var(--terYl);
|
--quiUser: var(--terMze);
|
||||||
--sixUser: var(--quiBlt);
|
--sixUser: var(--quiBlt);
|
||||||
--sepUser: var(--quiBlt);
|
--sepUser: var(--quiBlt);
|
||||||
--octUser: var(--quiBlt);
|
--octUser: var(--quiBlt);
|
||||||
|
|||||||
@@ -16,12 +16,6 @@
|
|||||||
{% if current_significator %}data-current-card-id="{{ current_significator.id }}"{% endif %}
|
{% if current_significator %}data-current-card-id="{{ current_significator.id }}"{% endif %}
|
||||||
data-current-reversed="{{ current_significator_reversed|yesno:'true,false' }}">
|
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="my-sign-stage">
|
||||||
<div class="sig-stage-card" style="display:none">
|
<div class="sig-stage-card" style="display:none">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
@@ -117,6 +111,31 @@
|
|||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
|
{# No equipped deck + no saved sig — fire a Brief banner via the #}
|
||||||
|
{# shared note.js API so positioning + NVM behavior + h2-overlay #}
|
||||||
|
{# styling matches every other Brief on the site (per #}
|
||||||
|
{# [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement). #}
|
||||||
|
{# FYI links to /gameboard/ (Game Kit equip); NVM dismisses + the #}
|
||||||
|
{# picker proceeds against the Earthman [Shabby Paperboard] fallback. #}
|
||||||
|
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||||
|
{% if show_backup_intro_banner %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (!window.Brief || !Brief.showBanner) return;
|
||||||
|
Brief.showBanner({
|
||||||
|
title: 'Default deck warning',
|
||||||
|
line_text: 'Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.',
|
||||||
|
post_url: '{% url "gameboard" %}',
|
||||||
|
created_at: '',
|
||||||
|
kind: 'NUDGE',
|
||||||
|
});
|
||||||
|
// Tag the banner so FTs (and any my-sign-specific styling) can
|
||||||
|
// distinguish this intro nudge from other Briefs on the page.
|
||||||
|
var banner = document.querySelector('.note-banner');
|
||||||
|
if (banner) banner.classList.add('my-sign-intro-banner');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user