From 99ffdb3943a32517bf29bbfac323bcf19f9b584c Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 21 May 2026 12:33:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20`Token.BAND`=20(Wristband)=20=E2=80=94?= =?UTF-8?q?=20non-admin=20variant=20of=20PASS,=20admin-awarded=20via=20Dja?= =?UTF-8?q?ngo=20admin=20to=20any=20user=20(NOT=20auto-granted=20on=20sign?= =?UTF-8?q?al,=20NO=20`is=5Fstaff`=20coupling,=20NO=20model-layer=20guard)?= =?UTF-8?q?.=20Mirrors=20PASS=20at=20runtime=20=E2=80=94=20fills=201=20gat?= =?UTF-8?q?e=20slot,=20never=20consumed,=20stays=20equipped,=20no=20`curre?= =?UTF-8?q?nt=5Froom`=20tie,=20no=20expiry,=20no=20In-Use=20microtooltip?= =?UTF-8?q?=20=E2=80=94=20but=20separates=20the=20policy=20concerns=20so?= =?UTF-8?q?=20PASS=20stays=20a=20deliberate=20staff-only=20trinket=20while?= =?UTF-8?q?=20BAND=20becomes=20the=20regular-user=20version=20(promotional?= =?UTF-8?q?=20/=20play-reward=20/=20staging=20give-away).=20Tooltip=20pros?= =?UTF-8?q?e:=20name=20"Wristband",=20desc=20"Admit=20All=20Entry"=20(shar?= =?UTF-8?q?ed=20w.=20PASS=20=E2=80=94=20phrasing=20reflects=20the=20never-?= =?UTF-8?q?depleted=20lifetime,=20not=20multi-slot=20semantics),=20shoptal?= =?UTF-8?q?k=20"Unlimited=20free=20entry=20(BYOB)",=20expiry=20"no=20expir?= =?UTF-8?q?y".=20`fa-ring`=20icon=20across=20all=204=20surfaces=20(Game=20?= =?UTF-8?q?Kit=20applet=20`#id=5Fkit=5Fwristband`=20between=20PASS=20+=20C?= =?UTF-8?q?ARTE,=20gk-trinkets=20section,=20kit-bag=20dialog=20Trinket=20s?= =?UTF-8?q?lot,=20wallet=20PASS=E2=86=92BAND=E2=86=92COIN=20elif=20chain).?= =?UTF-8?q?=20Priority=20chain=20=E2=80=94=20PASS=20=E2=86=92=20BAND=20?= =?UTF-8?q?=E2=86=92=20COIN=20=E2=86=92=20FREE=20=E2=86=92=20TITHE=20?= =?UTF-8?q?=E2=80=94=20wired=20identically=20into=20both=20`apps.epic.mode?= =?UTF-8?q?ls.select=5Ftoken`=20(room=20gatekeeper)=20+=20`apps.gameboard.?= =?UTF-8?q?models.=5Fselect=5Fmy=5Fsea=5Ftoken`=20(my-sea=20gatekeeper);?= =?UTF-8?q?=20BAND=20wins=20over=20consumables=20for=20any=20holder=20whil?= =?UTF-8?q?e=20PASS=20still=20wins=20for=20staff=20who=20happen=20to=20hol?= =?UTF-8?q?d=20both.=20`debit=5Ftoken`=20+=20`debit=5Fmy=5Fsea=5Ftoken`=20?= =?UTF-8?q?treat=20BAND=20same=20as=20PASS:=20slot=20marked=20FILLED=20w.?= =?UTF-8?q?=20`debited=5Ftoken=5Ftype=3DBAND`,=20token=20row=20preserved,?= =?UTF-8?q?=20`current=5Froom`=20untouched,=20`equipped=5Ftrinket`=20uncha?= =?UTF-8?q?nged.=20View=20contexts=20(`gameboard`,=20`toggle=5Fgame=5Fappl?= =?UTF-8?q?ets`,=20`=5Fgame=5Fkit=5Fcontext`,=20`wallet`,=20`toggle=5Fwall?= =?UTF-8?q?et=5Fapplets`)=20pass=20a=20`band`=20key=20=E2=80=94=20universa?= =?UTF-8?q?l=20lookup,=20NO=20`is=5Fstaff`=20filter.=20Migration=20`lyric/?= =?UTF-8?q?0007=5Falter=5Ftoken=5Ftoken=5Ftype`=20=E2=80=94=20choices-only?= =?UTF-8?q?=20AlterField.=20TDD=20=E2=80=94=205=20FTs=20in=20`test=5Ftrink?= =?UTF-8?q?et=5Fwristband.py`=20(`test=5Fband=5Fnot=5Fauto=5Fequipped=5Faf?= =?UTF-8?q?ter=5Faward`,=20`test=5Fband=5Ftooltip=5Frenders=5Ffull=5Fprose?= =?UTF-8?q?`,=20`test=5Fband=5Fuses=5Ffa=5Fring=5Ficon`,=20`test=5Fequippe?= =?UTF-8?q?d=5Fband=5Fshows=5Fequipped=5Fmini=5Ftooltip`,=20`test=5Fequipp?= =?UTF-8?q?ed=5Fband=5Fshows=5Fdoff=5Factive=5Fdon=5Fdisabled`);=204=20too?= =?UTF-8?q?ltip=20UTs=20(`BandTokenTooltipTest`);=205=20model=20ITs=20(`Ba?= =?UTF-8?q?ndTokenAdminAwardTest`=20=E2=80=94=20no-auto-grant=20for=20non-?= =?UTF-8?q?staff=20+=20staff,=20admin-can-award=20to=20either=20branch,=20?= =?UTF-8?q?not-auto-equipped);=202=20priority-chain=20ITs=20(`test=5Fretur?= =?UTF-8?q?ns=5Fband=5Fwhen=5Fheld=5Fand=5Fno=5Fpass`,=20`test=5Fpass=5Fst?= =?UTF-8?q?ill=5Fwins=5Fover=5Fband=5Ffor=5Fstaff`);=201=20debit=20IT=20(`?= =?UTF-8?q?test=5Fdebit=5Fband=5Fdoes=5Fnot=5Fconsume=5For=5Funequip`).=20?= =?UTF-8?q?1145=20IT/UT=20+=205=20FT=20green.=20A=20boost-pass=20/=20promo?= =?UTF-8?q?-band=20w.=20richer=20semantics=20(multi-slot=20admit,=20time-w?= =?UTF-8?q?indow,=20etc.)=20lands=20as=20YET-ANOTHER=20token=5Ftype=20late?= =?UTF-8?q?r=20=E2=80=94=20keep=20BAND=20the=20minimal=20"PASS=20minus=20a?= =?UTF-8?q?dmin=20gate"=20trinket=20so=20the=20policy=20axis=20stays=20cle?= =?UTF-8?q?an.=20Captured=20in=20[[sprint-band-trinket-may21]]=20alongside?= =?UTF-8?q?=20the=20standing=20auto-commit=20rule=20[[feedback-auto-commit?= =?UTF-8?q?-after-build]]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/dashboard/views.py | 2 + src/apps/epic/models.py | 5 +- src/apps/epic/tests/integrated/test_models.py | 36 +++++ src/apps/gameboard/models.py | 7 +- src/apps/gameboard/views.py | 5 + .../migrations/0007_alter_token_token_type.py | 18 +++ src/apps/lyric/models.py | 8 +- .../lyric/tests/integrated/test_models.py | 39 +++++ src/apps/lyric/tests/unit/test_tokens.py | 25 ++++ .../test_trinket_wristband.py | 135 ++++++++++++++++++ .../gameboard/_partials/_applet-game-kit.html | 16 +++ .../_partials/_game_kit_sections.html | 8 +- .../_partials/_applet-wallet-tokens.html | 12 ++ .../core/_partials/_kit_bag_panel.html | 2 + 14 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/apps/lyric/migrations/0007_alter_token_token_type.py create mode 100644 src/functional_tests/test_trinket_wristband.py diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 8818bc7..1a26fa0 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -165,6 +165,7 @@ def wallet(request): return render(request, "apps/dashboard/wallet.html", { "wallet": request.user.wallet, "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(), "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, @@ -201,6 +202,7 @@ def toggle_wallet_applets(request): "applets": applet_context(request.user, "wallet"), "wallet": request.user.wallet, "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(), "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/apps/epic/models.py b/src/apps/epic/models.py index e77b1ea..385a5e6 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -117,6 +117,9 @@ def select_token(user): pass_token = user.tokens.filter(token_type=Token.PASS).first() if pass_token: return pass_token + band = user.tokens.filter(token_type=Token.BAND).first() + if band: + return band coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first() if coin: return coin @@ -145,7 +148,7 @@ def debit_token(user, slot, token): user.save(update_fields=["equipped_trinket"]) elif token.token_type == Token.CARTE: pass # current_room already set in drop_token; token not consumed - elif token.token_type != Token.PASS: + elif token.token_type not in (Token.PASS, Token.BAND): slot.debited_token_expires_at = token.expires_at token.delete() slot.gamer = user diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index ca9dd3c..8d99513 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -48,6 +48,25 @@ class DebitTokenTest(TestCase): self.assertEqual(self.slot.status, GateSlot.FILLED) self.assertEqual(self.slot.gamer, self.owner) + def test_debit_band_does_not_consume_or_unequip(self): + """BAND mirrors PASS — fills the slot, but never deleted, never + gets `current_room` set, and stays equipped (debit_token's PASS + branch is the model). The wallet should keep showing the BAND + after the user enters a gate w. it.""" + band = Token.objects.create(user=self.owner, token_type=Token.BAND) + self.owner.equipped_trinket = band + self.owner.save(update_fields=["equipped_trinket"]) + debit_token(self.owner, self.slot, band) + self.assertTrue(Token.objects.filter(pk=band.pk).exists()) + band.refresh_from_db() + self.assertIsNone(band.current_room_id) + self.owner.refresh_from_db() + self.assertEqual(self.owner.equipped_trinket_id, band.pk) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.FILLED) + self.assertEqual(self.slot.gamer, self.owner) + self.assertEqual(self.slot.debited_token_type, Token.BAND) + def test_debit_fills_last_slot_and_opens_gate(self): for i in range(2, 7): gamer = User.objects.create(email=f"g{i}@test.io") @@ -138,6 +157,23 @@ class SelectTokenTest(TestCase): token = select_token(self.user) self.assertEqual(token.token_type, Token.PASS) + def test_returns_band_when_held_and_no_pass(self): + """BAND beats COIN/FREE/TITHE for non-staff (same never-consumed + rationale as PASS — burn the cheaper consumables first).""" + band = Token.objects.create(user=self.user, token_type=Token.BAND) + token = select_token(self.user) + self.assertEqual(token.pk, band.pk) + + def test_pass_still_wins_over_band_for_staff(self): + """Staff holding both PASS and BAND get PASS — PASS sits at the top + of the priority chain, BAND slots in below it.""" + self.user.is_staff = True + self.user.save() + pass_token = Token.objects.create(user=self.user, token_type=Token.PASS) + Token.objects.create(user=self.user, token_type=Token.BAND) + token = select_token(self.user) + self.assertEqual(token.pk, pass_token.pk) + class RoomTableStatusTest(TestCase): def setUp(self): diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index d613c4a..83e0457 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -134,6 +134,9 @@ def _select_my_sea_token(user): pass_token = user.tokens.filter(token_type=Token.PASS).first() if pass_token: return pass_token + band = user.tokens.filter(token_type=Token.BAND).first() + if band: + return band coin = user.tokens.filter( token_type=Token.COIN, current_room__isnull=True, ).filter( @@ -171,8 +174,8 @@ def debit_my_sea_token(user, token): if user.equipped_trinket_id == token.pk: user.equipped_trinket = None user.save(update_fields=["equipped_trinket"]) - elif token.token_type == Token.PASS: - pass + elif token.token_type in (Token.PASS, Token.BAND): + pass # auto-admit trinkets — never consumed, stay equipped else: token.delete() diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 3b14458..1f1b6c8 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -39,6 +39,7 @@ GAMEBOARD_APPLET_ORDER = [ @login_required(login_url="/") def gameboard(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( @@ -47,6 +48,7 @@ def gameboard(request): return render( request, "apps/gameboard/gameboard.html", { "pass_token": pass_token, + "band": band, "coin": coin, "carte": carte, "equipped_trinket_id": request.user.equipped_trinket_id, @@ -71,6 +73,7 @@ def toggle_game_applets(request): return render(request, "apps/gameboard/_partials/_applets.html", { "applets": applet_context(request.user, "gameboard"), "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(), "equipped_trinket_id": request.user.equipped_trinket_id, @@ -133,6 +136,7 @@ def _game_kit_context(user): from apps.lyric.models import PRONOUN_CHOICES coin = user.tokens.filter(token_type=Token.COIN).first() pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None + band = user.tokens.filter(token_type=Token.BAND).first() carte = user.tokens.filter(token_type=Token.CARTE).first() free_tokens = list(user.tokens.filter( token_type=Token.FREE, expires_at__gt=timezone.now() @@ -145,6 +149,7 @@ def _game_kit_context(user): return { "coin": coin, "pass_token": pass_token, + "band": band, "carte": carte, "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, diff --git a/src/apps/lyric/migrations/0007_alter_token_token_type.py b/src/apps/lyric/migrations/0007_alter_token_token_type.py new file mode 100644 index 0000000..3631c2b --- /dev/null +++ b/src/apps/lyric/migrations/0007_alter_token_token_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-21 15:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0006_user_significator_user_significator_reversed'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='token_type', + field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass'), ('band', 'Wristband'), ('carte', 'Carte Blanche')], max_length=8), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index bf5ef54..e38cfb1 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -232,12 +232,14 @@ class Token(models.Model): FREE = "Free" TITHE = "tithe" PASS = "pass" + BAND = "band" CARTE = "carte" TOKEN_TYPE_CHOICES = [ (COIN, "Coin-on-a-String"), (FREE, "Free Token"), (TITHE, "Tithe Token"), (PASS, "Backstage Pass"), + (BAND, "Wristband"), (CARTE, "Carte Blanche"), ] @@ -276,7 +278,7 @@ class Token(models.Model): def tooltip_description(self): if self.token_type in (self.COIN, self.FREE): return "Admit 1 Entry" - if self.token_type == self.PASS: + if self.token_type in (self.PASS, self.BAND): return "Admit All Entry" if self.token_type == self.TITHE: return "+ Writ bonus" @@ -285,7 +287,7 @@ class Token(models.Model): return "" def tooltip_expiry(self): - if self.token_type in (self.COIN, self.PASS, self.CARTE): + if self.token_type in (self.COIN, self.PASS, self.BAND, self.CARTE): if self.token_type == self.COIN and self.next_ready_at: return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return "no expiry" @@ -306,6 +308,8 @@ class Token(models.Model): return "a spot of good fortune" if self.token_type == self.PASS: return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?" + if self.token_type == self.BAND: + return "Unlimited free entry (BYOB)" if self.token_type == self.CARTE: return "No, I\u2019m afraid we\u2019ll be taking over from here." return None diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 024d82f..5e7d3ec 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -360,6 +360,45 @@ class PassTokenStaffOnlyGuardTest(TestCase): self.assertEqual(token.token_type, Token.TITHE) +class BandTokenAdminAwardTest(TestCase): + """BAND is the non-admin variant of PASS. Contract: NOT auto-granted by + the post_save signal (unlike PASS for staff, or COIN/FREE for everyone), + so admin can hand-mint it for any user via the Django admin without + every fresh account spawning one. No `is_staff` coupling — staff or + non-staff can hold one — so no model-layer guard either.""" + + def test_band_not_auto_granted_on_user_creation(self): + user = User.objects.create(email="newgamer@test.io") + self.assertFalse(user.tokens.filter(token_type=Token.BAND).exists()) + + def test_band_not_auto_granted_on_staff_creation(self): + """Staff get PASS, not BAND, on creation. BAND is admin-awarded + post-hoc to either staff or non-staff (no role-coupling like PASS).""" + staff = User.objects.create(email="admin@test.io", is_staff=True) + self.assertFalse(staff.tokens.filter(token_type=Token.BAND).exists()) + + def test_admin_can_award_band_to_non_staff(self): + non_staff = User.objects.create(email="gamer@test.io") + token = Token.objects.create(user=non_staff, token_type=Token.BAND) + self.assertEqual(token.token_type, Token.BAND) + + def test_admin_can_award_band_to_staff(self): + staff = User.objects.create(email="admin@test.io", is_staff=True) + token = Token.objects.create(user=staff, token_type=Token.BAND) + self.assertEqual(token.token_type, Token.BAND) + + def test_band_not_auto_equipped_after_award(self): + """Award diverges from PASS — admin-minted BAND lands in the wallet + but the user must DON it manually (`equipped_trinket` stays at its + post-signal default of COIN for non-staff).""" + non_staff = User.objects.create(email="bro@test.io") + coin = non_staff.tokens.get(token_type=Token.COIN) + band = Token.objects.create(user=non_staff, token_type=Token.BAND) + non_staff.refresh_from_db() + self.assertEqual(non_staff.equipped_trinket, coin) + self.assertNotEqual(non_staff.equipped_trinket, band) + + class EquippedDeckTest(TestCase): def test_new_user_gets_earthman_as_default_deck(self): from apps.epic.models import DeckVariant diff --git a/src/apps/lyric/tests/unit/test_tokens.py b/src/apps/lyric/tests/unit/test_tokens.py index 4aae629..15c9019 100644 --- a/src/apps/lyric/tests/unit/test_tokens.py +++ b/src/apps/lyric/tests/unit/test_tokens.py @@ -56,6 +56,31 @@ class PassTokenTooltipTest(SimpleTestCase): self.assertIn("no expiry", self.token.tooltip_text()) +class BandTokenTooltipTest(SimpleTestCase): + """Wristband mirrors PASS's "Admit All Entry" / never-expires shape but + is non-admin (no `is_staff` gate). Tooltip prose is the only place the + two diverge — different title + shoptalk.""" + + def setUp(self): + self.token = Token() + self.token.token_type = Token.BAND + self.token.expires_at = None + self.token.next_ready_at = None + + def test_tooltip_contains_name(self): + self.assertIn("Wristband", self.token.tooltip_text()) + + def test_tooltip_contains_admit_all(self): + self.assertIn("Admit All Entry", self.token.tooltip_text()) + + def test_tooltip_contains_shoptalk(self): + self.assertIn("Unlimited free entry", self.token.tooltip_text()) + self.assertIn("BYOB", self.token.tooltip_text()) + + def test_tooltip_contains_no_expiry(self): + self.assertIn("no expiry", self.token.tooltip_text()) + + class CarteTooltipTest(SimpleTestCase): def setUp(self): self.token = Token() diff --git a/src/functional_tests/test_trinket_wristband.py b/src/functional_tests/test_trinket_wristband.py new file mode 100644 index 0000000..5ecde5b --- /dev/null +++ b/src/functional_tests/test_trinket_wristband.py @@ -0,0 +1,135 @@ +"""Default-structure FTs for the Wristband trinket. + +BAND is the non-admin variant of PASS — same magical 'Admit All Entry' tooltip +and same never-deposited, stays-equipped behavior, but admin-assigned (not +auto-granted on user creation) and not gated behind `is_staff`. Like PASS, it +doesn't go through the `.token-rails` deposit flow that CARTE / COIN use, so +there's no `data-current-room-name` / In-Use parity work to do. +""" + +from selenium.webdriver.common.action_chains import ActionChains +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 WristbandTest(FunctionalTest): + """BAND is admin-awarded to a non-staff user — coverage pins the + awarded-but-not-auto-equipped state + tooltip prose + DON/DOFF parity.""" + + def setUp(self): + super().setUp() + for slug, name, cols, rows in [ + ("new-game", "New Game", 6, 3), + ("my-games", "My Games", 6, 3), + ("game-kit", "Game Kit", 6, 3), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={ + "name": name, "grid_cols": cols, + "grid_rows": rows, "context": "gameboard", + }, + ) + # Non-staff user — BAND is admin-awarded after creation, NOT auto-granted + # by the post_save signal (the way PASS is for staff users). + self.gamer = User.objects.create(email="bro@test.io", is_staff=False) + self.band = Token.objects.create(user=self.gamer, token_type=Token.BAND) + + # ── Test 1 ─────────────────────────────────────────────────────────────── + + def test_band_not_auto_equipped_after_award(self): + """Award diverges from PASS — BAND lands in the wallet but the user + must DON it manually. Confirms `create_wallet_and_tokens` doesn't + auto-equip a freshly-minted BAND (the way it auto-equips PASS for + staff at user-creation).""" + self.gamer.refresh_from_db() + self.assertNotEqual(self.gamer.equipped_trinket_id, self.band.pk) + + # ── Test 2 ─────────────────────────────────────────────────────────────── + + def test_band_tooltip_renders_full_prose(self): + """Hover the Wristband token in the Game Kit applet → main tooltip + shows title, description, shoptalk, and expiry.""" + self.create_pre_authenticated_session("bro@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + band_el = self.browser.find_element(By.ID, "id_kit_wristband") + self.browser.execute_script( + "arguments[0].scrollIntoView({block: 'center'})", band_el + ) + ActionChains(self.browser).move_to_element(band_el).perform() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + portal = self.browser.find_element(By.ID, "id_tooltip_portal") + self.assertIn("Wristband", portal.text) + self.assertIn("Admit All Entry", portal.text) + self.assertIn("Unlimited free entry", portal.text) # shoptalk + self.assertIn("BYOB", portal.text) + self.assertIn("no expiry", portal.text) + + # ── Test 3 ─────────────────────────────────────────────────────────────── + + def test_band_uses_fa_ring_icon(self): + """Wristband renders w. the fa-ring icon — distinct from PASS's + fa-clipboard, CARTE's fa-money-check, COIN's fa-medal.""" + self.create_pre_authenticated_session("bro@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + band_el = self.browser.find_element(By.ID, "id_kit_wristband") + icon = band_el.find_element(By.CSS_SELECTOR, "i") + self.assertIn("fa-ring", icon.get_attribute("class")) + + # ── Test 4 ─────────────────────────────────────────────────────────────── + + def test_equipped_band_shows_equipped_mini_tooltip(self): + """After DON-ing BAND, mini portal under the main tooltip says + 'Equipped' (same shape as PASS's equipped-state mini portal).""" + self.gamer.equipped_trinket = self.band + self.gamer.save() + self.create_pre_authenticated_session("bro@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + band_el = self.browser.find_element(By.ID, "id_kit_wristband") + ActionChains(self.browser).move_to_element(band_el).perform() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + self.assertIn("Equipped", mini.text) + + # ── Test 5 ─────────────────────────────────────────────────────────────── + + def test_equipped_band_shows_doff_active_don_disabled(self): + """Equipped BAND apparatus: DOFF active (clickable), DON is × disabled. + Symmetric to PASS's equipped state.""" + self.gamer.equipped_trinket = self.band + self.gamer.save() + self.create_pre_authenticated_session("bro@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + band_el = self.browser.find_element(By.ID, "id_kit_wristband") + ActionChains(self.browser).move_to_element(band_el).perform() + portal = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal") + ) + don = portal.find_element(By.CSS_SELECTOR, ".btn-equip") + doff = portal.find_element(By.CSS_SELECTOR, ".btn-unequip") + self.assertIn("btn-disabled", don.get_attribute("class")) + self.assertEqual(don.text, "×") + self.assertNotIn("btn-disabled", doff.get_attribute("class")) + self.assertEqual(doff.text, "DOFF") + + # TODO — BAND deposit/admit flow: + # Like PASS, BAND auto-admits any gate w.o going through the rails like + # CARTE / COIN. When the gatekeeper UX for never-deposited trinkets is + # formalized (PASS still has the same TODO open), add tests for the + # "BAND admits 1 slot, stays equipped" flow on both room.html + my_sea.html. diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index 934e485..8f4fe48 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -20,6 +20,22 @@ {% endif %} + {% if band %} +
+ +
+
+ {% if band.pk == equipped_trinket_id %}{% else %}{% endif %} +
+

{{ band.tooltip_name }}

+

{{ band.tooltip_description }}

+ {% if band.tooltip_shoptalk %} +

{{ band.tooltip_shoptalk }}

+ {% endif %} +

{{ band.tooltip_expiry }}

+
+
+ {% endif %} {% if carte %}
diff --git a/src/templates/apps/gameboard/_partials/_game_kit_sections.html b/src/templates/apps/gameboard/_partials/_game_kit_sections.html index 3c0fdec..cc9a870 100644 --- a/src/templates/apps/gameboard/_partials/_game_kit_sections.html +++ b/src/templates/apps/gameboard/_partials/_game_kit_sections.html @@ -10,6 +10,12 @@ {{ pass_token.tooltip_name }}
{% endif %} + {% if band %} +
+ + {{ band.tooltip_name }} +
+ {% endif %} {% if carte %}
@@ -22,7 +28,7 @@ {{ coin.tooltip_name }}
{% endif %} - {% if not pass_token and not carte and not coin %} + {% if not pass_token and not band and not carte and not coin %}

No trinkets yet.

{% endif %} diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html index 041bcae..031ddc8 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html @@ -16,6 +16,18 @@

{{ pass_token.tooltip_expiry }}

+ {% elif band %} +
+ +
+

{{ band.tooltip_name }}

+

{{ band.tooltip_description }}

+ {% if band.tooltip_shoptalk %} +

{{ band.tooltip_shoptalk }}

+ {% endif %} +

{{ band.tooltip_expiry }}

+
+
{% elif coin %}
diff --git a/src/templates/core/_partials/_kit_bag_panel.html b/src/templates/core/_partials/_kit_bag_panel.html index 51736d8..80f4f2e 100644 --- a/src/templates/core/_partials/_kit_bag_panel.html +++ b/src/templates/core/_partials/_kit_bag_panel.html @@ -43,6 +43,8 @@ {% elif token.token_type == "carte" %} + {% elif token.token_type == "band" %} + {% else %} {% endif %}