From 8e476f5658d023c7b0e23621b93609db9778e08d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 21 May 2026 23:07:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20wallet=20Tokens=20applet=20shows=20CART?= =?UTF-8?q?E=20+=20BAND=20+=20COIN=20+=20PASS=20independently=20=E2=80=94?= =?UTF-8?q?=20Chunk=201=20of=20the=20Shop=20applet=20rollout=20per=20[[pro?= =?UTF-8?q?ject-wallet-shop-expansion]].=20Pre-Chunk-1=20the=20`=5Fapplet-?= =?UTF-8?q?wallet-tokens.html`=20template=20used=20a=20`{%=20if=20pass=5Ft?= =?UTF-8?q?oken=20%}=20...=20{%=20elif=20band=20%}=20...=20{%=20elif=20coi?= =?UTF-8?q?n=20%}`=20chain=20that=20suppressed=202-of-3=20trinkets=20from?= =?UTF-8?q?=20the=20wallet=20whenever=20the=20user=20held=20a=20higher-pri?= =?UTF-8?q?ority=20one=20=E2=80=94=20bad=20UX=20since=20the=20equip=20slot?= =?UTF-8?q?=20is=20now=20the=20user's=20opt-in=20for=20trinket-as-token=20?= =?UTF-8?q?use=20per=20[[feedback-equip-slot-gates-trinket-use]],=20so=20A?= =?UTF-8?q?LL=20owned=20trinkets=20need=20visibility.=20Fix:=20dropped=20t?= =?UTF-8?q?he=20elif=20chain=20=E2=86=92=20independent=20`{%=20if=20%}`=20?= =?UTF-8?q?blocks=20for=20PASS=20/=20BAND=20/=20COIN;=20added=20a=20new=20?= =?UTF-8?q?CARTE=20block=20w.=20`fa-money-check`=20icon=20mirroring=20the?= =?UTF-8?q?=20Game=20Kit's=20render.=20View=20context=20(`apps.dashboard.v?= =?UTF-8?q?iews.wallet`=20+=20`:toggle=5Fwallet=5Fapplets`)=20now=20passes?= =?UTF-8?q?=20`carte=20=3D=20user.tokens.filter(token=5Ftype=3DToken.CARTE?= =?UTF-8?q?).first()`=20alongside=20the=20existing=20pass/band/coin=20keys?= =?UTF-8?q?=20(no=20`is=5Fstaff`=20filter=20=E2=80=94=20CARTE=20has=20no?= =?UTF-8?q?=20admin=20gate).=20TDD=20=E2=80=94=20new=20`WalletTokensApplet?= =?UTF-8?q?AllTrinketsVisibleTest`=20(9=20ITs):=206=20pin=20individual=20`?= =?UTF-8?q?#id=5F`=20visibility=20for=20a=20staff=20user=20holding?= =?UTF-8?q?=20all=205=20types,=202=20pin=20view-context=20shape=20(`carte`?= =?UTF-8?q?=20+=20`band`=20keys),=201=20pins=20CARTE-on-non-staff.=20New?= =?UTF-8?q?=20FT=20`test=5Fwallet=5Ftokens=5Fapplet=5Fshows=5Fall=5Fowned?= =?UTF-8?q?=5Ftrinket=5Ftypes`=20reads=20BAND/CARTE=20`.tt`=20`innerHTML`?= =?UTF-8?q?=20directly=20(no=20hover=20ceremony=20=E2=80=94=20already=20co?= =?UTF-8?q?vered=20by=20the=20COIN/FREE=20hover=20paths=20in=20`test=5Fnew?= =?UTF-8?q?=5Fuser=5Fwallet=5Fshows=5Fstarting=5Fbalances`)=20to=20pin=20t?= =?UTF-8?q?he=20new=20template=20blocks=20server-render=20full=20tooltip?= =?UTF-8?q?=20prose.=20**Trap=20caught=20mid-build**:=20initial=20multi-li?= =?UTF-8?q?ne=20`{#=20...=20#}`=20Django=20comment=20leaked=20as=20plain?= =?UTF-8?q?=20text=20into=20the=20rendered=20DOM=20(Django's=20hash-commen?= =?UTF-8?q?t=20is=20single-line=20only),=20pushing=20the=20COIN=20tile=20o?= =?UTF-8?q?ff-screen=20+=20breaking=20the=20existing=20hover=20FT.=20Switc?= =?UTF-8?q?hed=20to=20`{%=20comment=20%}...{%=20endcomment=20%}`.=20Captur?= =?UTF-8?q?ed=20in=20[[feedback-django-comments-single-line-only]]=20?= =?UTF-8?q?=E2=80=94=20symptom=20signature:=20previously-passing=20Seleniu?= =?UTF-8?q?m=20hover=20times=20out=20+=20screendump=20shows=20literal=20`{?= =?UTF-8?q?#=20...`=20text=20near=20the=20broken=20element.=201169=20IT/UT?= =?UTF-8?q?=20+=206=20wallet=20FTs=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/integrated/test_wallet_views.py | 59 +++++++++++++++++++ src/apps/dashboard/views.py | 2 + src/functional_tests/test_dash_wallet.py | 45 ++++++++++++++ .../_partials/_applet-wallet-tokens.html | 25 +++++++- 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/apps/dashboard/tests/integrated/test_wallet_views.py b/src/apps/dashboard/tests/integrated/test_wallet_views.py index abe6827..81610a5 100644 --- a/src/apps/dashboard/tests/integrated/test_wallet_views.py +++ b/src/apps/dashboard/tests/integrated/test_wallet_views.py @@ -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 diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 1a26fa0..080c029 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -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)), }) diff --git a/src/functional_tests/test_dash_wallet.py b/src/functional_tests/test_dash_wallet.py index ef01a41..c4c44c7 100644 --- a/src/functional_tests/test_dash_wallet.py +++ b/src/functional_tests/test_dash_wallet.py @@ -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") diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html index 031ddc8..4acc0fa 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html @@ -4,6 +4,12 @@ >

Tokens

+ {% 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 %}
@@ -16,7 +22,8 @@

{{ pass_token.tooltip_expiry }}

- {% elif band %} + {% endif %} + {% if band %}
@@ -28,7 +35,8 @@

{{ band.tooltip_expiry }}

- {% elif coin %} + {% endif %} + {% if coin %}
@@ -41,6 +49,19 @@
{% endif %} + {% if carte %} +
+ +
+

{{ carte.tooltip_name }}

+

{{ carte.tooltip_description }}

+ {% if carte.tooltip_shoptalk %} +

{{ carte.tooltip_shoptalk }}

+ {% endif %} +

{{ carte.tooltip_expiry }}

+
+
+ {% endif %} {% if free_tokens %} {% with free_tokens.0 as token %}