feat: Token.BAND (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO is_staff coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no current_room tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". fa-ring icon across all 4 surfaces (Game Kit applet #id_kit_wristband between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both apps.epic.models.select_token (room gatekeeper) + apps.gameboard.models._select_my_sea_token (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. debit_token + debit_my_sea_token treat BAND same as PASS: slot marked FILLED w. debited_token_type=BAND, token row preserved, current_room untouched, equipped_trinket unchanged. View contexts (gameboard, toggle_game_applets, _game_kit_context, wallet, toggle_wallet_applets) pass a band key — universal lookup, NO is_staff filter. Migration lyric/0007_alter_token_token_type — choices-only AlterField. TDD — 5 FTs in test_trinket_wristband.py (test_band_not_auto_equipped_after_award, test_band_tooltip_renders_full_prose, test_band_uses_fa_ring_icon, test_equipped_band_shows_equipped_mini_tooltip, test_equipped_band_shows_doff_active_don_disabled); 4 tooltip UTs (BandTokenTooltipTest); 5 model ITs (BandTokenAdminAwardTest — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (test_returns_band_when_held_and_no_pass, test_pass_still_wins_over_band_for_staff); 1 debit IT (test_debit_band_does_not_consume_or_unequip). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-21 12:33:09 -04:00
parent 0f60c73f3b
commit 99ffdb3943
14 changed files with 312 additions and 6 deletions

View File

@@ -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.