feat: wallet Tokens applet shows CARTE + BAND + COIN + PASS independently — Chunk 1 of the Shop applet rollout per [[project-wallet-shop-expansion]]. Pre-Chunk-1 the _applet-wallet-tokens.html template used a {% if pass_token %} ... {% elif band %} ... {% elif coin %} chain that suppressed 2-of-3 trinkets from the wallet whenever the user held a higher-priority one — bad UX since the equip slot is now the user's opt-in for trinket-as-token use per [[feedback-equip-slot-gates-trinket-use]], so ALL owned trinkets need visibility. Fix: dropped the elif chain → independent {% if %} blocks for PASS / BAND / COIN; added a new CARTE block w. fa-money-check icon mirroring the Game Kit's render. View context (apps.dashboard.views.wallet + :toggle_wallet_applets) now passes carte = user.tokens.filter(token_type=Token.CARTE).first() alongside the existing pass/band/coin keys (no is_staff filter — CARTE has no admin gate). TDD — new WalletTokensAppletAllTrinketsVisibleTest (9 ITs): 6 pin individual #id_<token> visibility for a staff user holding all 5 types, 2 pin view-context shape (carte + band keys), 1 pins CARTE-on-non-staff. New FT test_wallet_tokens_applet_shows_all_owned_trinket_types reads BAND/CARTE .tt innerHTML directly (no hover ceremony — already covered by the COIN/FREE hover paths in test_new_user_wallet_shows_starting_balances) to pin the new template blocks server-render full tooltip prose. **Trap caught mid-build**: initial multi-line {# ... #} Django comment leaked as plain text into the rendered DOM (Django's hash-comment is single-line only), pushing the COIN tile off-screen + breaking the existing hover FT. Switched to {% comment %}...{% endcomment %}. Captured in [[feedback-django-comments-single-line-only]] — symptom signature: previously-passing Selenium hover times out + screendump shows literal {# ... text near the broken element. 1169 IT/UT + 6 wallet FTs green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-21 23:07:42 -04:00
parent eb8666ba40
commit 8e476f5658
4 changed files with 129 additions and 2 deletions

View File

@@ -48,6 +48,65 @@ class WalletViewTest(TestCase):
self.assertGreater(len(bundles), 0) self.assertGreater(len(bundles), 0)
class WalletTokensAppletAllTrinketsVisibleTest(TestCase):
"""Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet
in `wallet.html` must show every owned trinket-as-token type at once.
Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %}
... {% elif coin %}` chain hid two of the three from any user holding
a higher-priority trinket — bad UX since all three are usable at the
gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks
WHICH one fires via the equip slot)."""
def setUp(self):
self.user = User.objects.create(email="multitoken@test.io", is_staff=True)
# Auto-COIN (equipped) + FREE created by post_save signal; PASS auto-
# granted by the is_staff branch of the same signal. Add the rest.
Token.objects.create(user=self.user, token_type=Token.BAND)
Token.objects.create(user=self.user, token_type=Token.CARTE)
Token.objects.create(user=self.user, token_type=Token.TITHE)
self.client.force_login(self.user)
response = self.client.get("/dashboard/wallet/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_shows_pass_token(self):
[_] = self.parsed.cssselect("#id_pass_token")
def test_wallet_shows_band_token(self):
[_] = self.parsed.cssselect("#id_band_token")
def test_wallet_shows_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_shows_carte_token(self):
[_] = self.parsed.cssselect("#id_carte_token")
def test_wallet_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token")
def test_wallet_shows_tithe_token(self):
[_] = self.parsed.cssselect("#id_tithe_token")
def test_view_context_passes_carte(self):
"""Defense-in-depth: not just the template but the view context too —
a renamed/refactored template should still receive `carte` in ctx."""
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["carte"].token_type, Token.CARTE)
def test_view_context_passes_band(self):
response = self.client.get("/dashboard/wallet/")
self.assertEqual(response.context["band"].token_type, Token.BAND)
def test_non_staff_user_with_carte_still_sees_carte(self):
"""CARTE has no `is_staff` gating (unlike PASS) — a regular gamer
holding a CARTE must see it in the Tokens applet."""
non_staff = User.objects.create(email="grunt@test.io")
Token.objects.create(user=non_staff, token_type=Token.CARTE)
self.client.force_login(non_staff)
response = self.client.get("/dashboard/wallet/")
parsed = lxml.html.fromstring(response.content)
[_] = parsed.cssselect("#id_carte_token")
class WalletPassTokenVisibilityTest(TestCase): class WalletPassTokenVisibilityTest(TestCase):
"""PASS is admin-only — the model guard blocks bogus rows from existing """PASS is admin-only — the model guard blocks bogus rows from existing
for non-staff users, but defend the wallet surface too so a future for non-staff users, but defend the wallet surface too so a future

View File

@@ -167,6 +167,7 @@ def wallet(request):
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(), "band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"free_tokens": free_tokens, "free_tokens": free_tokens,
"tithe_tokens": tithe_tokens, "tithe_tokens": tithe_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
@@ -204,6 +205,7 @@ def toggle_wallet_applets(request):
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"band": request.user.tokens.filter(token_type=Token.BAND).first(), "band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
}) })

View File

@@ -3,6 +3,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.lyric.models import Token, User
class WalletDisplayTest(FunctionalTest): class WalletDisplayTest(FunctionalTest):
@@ -179,6 +180,50 @@ class WalletDisplayTest(FunctionalTest):
) )
self.assertEqual(rows, '3') self.assertEqual(rows, '3')
def test_wallet_tokens_applet_shows_all_owned_trinket_types(self):
"""Wallet Tokens applet renders every owned trinket-as-token type
independently — PASS (staff), BAND, COIN, CARTE — alongside FREE
+ TITHE. Pre-2026-05-22 the template had an if/elif chain that
only showed ONE of {PASS, BAND, COIN}; that hid BAND + COIN +
CARTE entirely from the wallet for any staff user holding a PASS,
even though all three are usable at the gate. Chunk 1 of the
Shop applet rollout drops that exclusivity so each trinket gets
its own tooltipped icon in the row."""
# 1. Build a staff user holding every trinket-as-token type
staff = User.objects.create(email="ledger@test.io", is_staff=True)
# post_save signal already created COIN (auto-equipped) + FREE + PASS;
# mint the rest manually so we exercise the full inventory render.
Token.objects.create(user=staff, token_type=Token.BAND)
Token.objects.create(user=staff, token_type=Token.CARTE)
Token.objects.create(user=staff, token_type=Token.TITHE)
# 2. Log in + land on wallet page
self.create_pre_authenticated_session("ledger@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 3. Every trinket-as-token icon is present (no if/elif suppression)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pass_token"))
self.browser.find_element(By.ID, "id_band_token")
self.browser.find_element(By.ID, "id_coin_on_a_string")
self.browser.find_element(By.ID, "id_carte_token")
# 4. Consumable tokens still present
self.browser.find_element(By.ID, "id_free_token")
self.browser.find_element(By.ID, "id_tithe_token")
# 5. BAND tile carries its tooltip content in the DOM (the wallet's
# `initWalletTooltips` clones `.tt` innerHTML into the portal on
# hover — already exercised by the COIN/FREE hover paths in
# `test_new_user_wallet_shows_starting_balances`; here we just pin
# that the new BAND/CARTE template blocks server-render their full
# tooltip prose, which is the Chunk 1 contract).
band_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_band_token .tt").get_attribute("innerHTML")
self.assertIn("Wristband", band_tt)
self.assertIn("Admit All Entry", band_tt)
self.assertIn("no expiry", band_tt)
# 6. CARTE tile carries its tooltip content too
carte_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_carte_token .tt").get_attribute("innerHTML")
self.assertIn("Carte Blanche", carte_tt)
self.assertIn("Admit up to +6", carte_tt)
self.assertIn("no expiry", carte_tt)
def test_user_can_purchase_tithe_token_bundle(self): def test_user_can_purchase_tithe_token_bundle(self):
# 1. Log in, navigate to wallet page # 1. Log in, navigate to wallet page
self.create_pre_authenticated_session("capman@test.io") self.create_pre_authenticated_session("capman@test.io")

View File

@@ -4,6 +4,12 @@
> >
<h2>Tokens</h2> <h2>Tokens</h2>
<div class="token-row"> <div class="token-row">
{% comment %}
Trinket-as-token row — each trinket type renders independently (no
if/elif suppression). Every one of PASS/BAND/COIN/CARTE is usable at
the gate; the user picks WHICH via the equip slot per
[[feedback-equip-slot-gates-trinket-use]].
{% endcomment %}
{% if pass_token %} {% if pass_token %}
<div id="id_pass_token" class="token"> <div id="id_pass_token" class="token">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
@@ -16,7 +22,8 @@
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p> <p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% elif band %} {% endif %}
{% if band %}
<div id="id_band_token" class="token"> <div id="id_band_token" class="token">
<i class="fa-solid fa-ring"></i> <i class="fa-solid fa-ring"></i>
<div class="tt"> <div class="tt">
@@ -28,7 +35,8 @@
<p class="tt-expiry">{{ band.tooltip_expiry }}</p> <p class="tt-expiry">{{ band.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% elif coin %} {% endif %}
{% if coin %}
<div id="id_coin_on_a_string" class="token"> <div id="id_coin_on_a_string" class="token">
<i class="fa-solid fa-medal"></i> <i class="fa-solid fa-medal"></i>
<div class="tt"> <div class="tt">
@@ -41,6 +49,19 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if carte %}
<div id="id_carte_token" class="token">
<i class="fa-solid fa-money-check"></i>
<div class="tt">
<h4 class="tt-title">{{ carte.tooltip_name }}</h4>
<p class="tt-description">{{ carte.tooltip_description }}</p>
{% if carte.tooltip_shoptalk %}
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
{% endif %}
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
</div>
</div>
{% endif %}
{% if free_tokens %} {% if free_tokens %}
{% with free_tokens.0 as token %} {% with free_tokens.0 as token %}
<div id="id_free_token" class="token"> <div id="id_free_token" class="token">