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:
@@ -48,6 +48,65 @@ class WalletViewTest(TestCase):
|
||||
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):
|
||||
"""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
|
||||
|
||||
@@ -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,
|
||||
"band": request.user.tokens.filter(token_type=Token.BAND).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,
|
||||
"tithe_tokens": tithe_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,
|
||||
"band": request.user.tokens.filter(token_type=Token.BAND).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)),
|
||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.applets.models import Applet
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
class WalletDisplayTest(FunctionalTest):
|
||||
@@ -179,6 +180,50 @@ class WalletDisplayTest(FunctionalTest):
|
||||
)
|
||||
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):
|
||||
# 1. Log in, navigate to wallet page
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
>
|
||||
<h2>Tokens</h2>
|
||||
<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 %}
|
||||
<div id="id_pass_token" class="token">
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
@@ -16,7 +22,8 @@
|
||||
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif band %}
|
||||
{% endif %}
|
||||
{% if band %}
|
||||
<div id="id_band_token" class="token">
|
||||
<i class="fa-solid fa-ring"></i>
|
||||
<div class="tt">
|
||||
@@ -28,7 +35,8 @@
|
||||
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif coin %}
|
||||
{% endif %}
|
||||
{% if coin %}
|
||||
<div id="id_coin_on_a_string" class="token">
|
||||
<i class="fa-solid fa-medal"></i>
|
||||
<div class="tt">
|
||||
@@ -41,6 +49,19 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
{% with free_tokens.0 as token %}
|
||||
<div id="id_free_token" class="token">
|
||||
|
||||
Reference in New Issue
Block a user