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

@@ -165,6 +165,7 @@ def wallet(request):
return render(request, "apps/dashboard/wallet.html", { return render(request, "apps/dashboard/wallet.html", {
"wallet": 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, "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(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": free_tokens, "free_tokens": free_tokens,
"tithe_tokens": tithe_tokens, "tithe_tokens": tithe_tokens,
@@ -201,6 +202,7 @@ def toggle_wallet_applets(request):
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"wallet": 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, "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(), "coin": request.user.tokens.filter(token_type=Token.COIN).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

@@ -117,6 +117,9 @@ def select_token(user):
pass_token = user.tokens.filter(token_type=Token.PASS).first() pass_token = user.tokens.filter(token_type=Token.PASS).first()
if pass_token: if pass_token:
return 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() coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first()
if coin: if coin:
return coin return coin
@@ -145,7 +148,7 @@ def debit_token(user, slot, token):
user.save(update_fields=["equipped_trinket"]) user.save(update_fields=["equipped_trinket"])
elif token.token_type == Token.CARTE: elif token.token_type == Token.CARTE:
pass # current_room already set in drop_token; token not consumed 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 slot.debited_token_expires_at = token.expires_at
token.delete() token.delete()
slot.gamer = user slot.gamer = user

View File

@@ -48,6 +48,25 @@ class DebitTokenTest(TestCase):
self.assertEqual(self.slot.status, GateSlot.FILLED) self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner) 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): def test_debit_fills_last_slot_and_opens_gate(self):
for i in range(2, 7): for i in range(2, 7):
gamer = User.objects.create(email=f"g{i}@test.io") gamer = User.objects.create(email=f"g{i}@test.io")
@@ -138,6 +157,23 @@ class SelectTokenTest(TestCase):
token = select_token(self.user) token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS) 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): class RoomTableStatusTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -134,6 +134,9 @@ def _select_my_sea_token(user):
pass_token = user.tokens.filter(token_type=Token.PASS).first() pass_token = user.tokens.filter(token_type=Token.PASS).first()
if pass_token: if pass_token:
return pass_token return pass_token
band = user.tokens.filter(token_type=Token.BAND).first()
if band:
return band
coin = user.tokens.filter( coin = user.tokens.filter(
token_type=Token.COIN, current_room__isnull=True, token_type=Token.COIN, current_room__isnull=True,
).filter( ).filter(
@@ -171,8 +174,8 @@ def debit_my_sea_token(user, token):
if user.equipped_trinket_id == token.pk: if user.equipped_trinket_id == token.pk:
user.equipped_trinket = None user.equipped_trinket = None
user.save(update_fields=["equipped_trinket"]) user.save(update_fields=["equipped_trinket"])
elif token.token_type == Token.PASS: elif token.token_type in (Token.PASS, Token.BAND):
pass pass # auto-admit trinkets — never consumed, stay equipped
else: else:
token.delete() token.delete()

View File

@@ -39,6 +39,7 @@ GAMEBOARD_APPLET_ORDER = [
@login_required(login_url="/") @login_required(login_url="/")
def gameboard(request): def gameboard(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()
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() carte = request.user.tokens.filter(token_type=Token.CARTE).first()
free_tokens = list(request.user.tokens.filter( free_tokens = list(request.user.tokens.filter(
@@ -47,6 +48,7 @@ def gameboard(request):
return render( return render(
request, "apps/gameboard/gameboard.html", { request, "apps/gameboard/gameboard.html", {
"pass_token": pass_token, "pass_token": pass_token,
"band": band,
"coin": coin, "coin": coin,
"carte": carte, "carte": carte,
"equipped_trinket_id": request.user.equipped_trinket_id, "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", { return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"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(),
"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(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": request.user.equipped_trinket_id, "equipped_trinket_id": request.user.equipped_trinket_id,
@@ -133,6 +136,7 @@ def _game_kit_context(user):
from apps.lyric.models import PRONOUN_CHOICES from apps.lyric.models import PRONOUN_CHOICES
coin = user.tokens.filter(token_type=Token.COIN).first() 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 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() carte = user.tokens.filter(token_type=Token.CARTE).first()
free_tokens = list(user.tokens.filter( free_tokens = list(user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
@@ -145,6 +149,7 @@ def _game_kit_context(user):
return { return {
"coin": coin, "coin": coin,
"pass_token": pass_token, "pass_token": pass_token,
"band": band,
"carte": carte, "carte": carte,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"tithe_tokens": tithe_tokens, "tithe_tokens": tithe_tokens,

View File

@@ -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),
),
]

View File

@@ -232,12 +232,14 @@ class Token(models.Model):
FREE = "Free" FREE = "Free"
TITHE = "tithe" TITHE = "tithe"
PASS = "pass" PASS = "pass"
BAND = "band"
CARTE = "carte" CARTE = "carte"
TOKEN_TYPE_CHOICES = [ TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"), (COIN, "Coin-on-a-String"),
(FREE, "Free Token"), (FREE, "Free Token"),
(TITHE, "Tithe Token"), (TITHE, "Tithe Token"),
(PASS, "Backstage Pass"), (PASS, "Backstage Pass"),
(BAND, "Wristband"),
(CARTE, "Carte Blanche"), (CARTE, "Carte Blanche"),
] ]
@@ -276,7 +278,7 @@ class Token(models.Model):
def tooltip_description(self): def tooltip_description(self):
if self.token_type in (self.COIN, self.FREE): if self.token_type in (self.COIN, self.FREE):
return "Admit 1 Entry" return "Admit 1 Entry"
if self.token_type == self.PASS: if self.token_type in (self.PASS, self.BAND):
return "Admit All Entry" return "Admit All Entry"
if self.token_type == self.TITHE: if self.token_type == self.TITHE:
return "+ Writ bonus" return "+ Writ bonus"
@@ -285,7 +287,7 @@ class Token(models.Model):
return "" return ""
def tooltip_expiry(self): 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: if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry" return "no expiry"
@@ -306,6 +308,8 @@ class Token(models.Model):
return "a spot of good fortune" return "a spot of good fortune"
if self.token_type == self.PASS: if self.token_type == self.PASS:
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?" 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: if self.token_type == self.CARTE:
return "No, I\u2019m afraid we\u2019ll be taking over from here." return "No, I\u2019m afraid we\u2019ll be taking over from here."
return None return None

View File

@@ -360,6 +360,45 @@ class PassTokenStaffOnlyGuardTest(TestCase):
self.assertEqual(token.token_type, Token.TITHE) 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): class EquippedDeckTest(TestCase):
def test_new_user_gets_earthman_as_default_deck(self): def test_new_user_gets_earthman_as_default_deck(self):
from apps.epic.models import DeckVariant from apps.epic.models import DeckVariant

View File

@@ -56,6 +56,31 @@ class PassTokenTooltipTest(SimpleTestCase):
self.assertIn("no expiry", self.token.tooltip_text()) 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): class CarteTooltipTest(SimpleTestCase):
def setUp(self): def setUp(self):
self.token = Token() self.token = Token()

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.

View File

@@ -20,6 +20,22 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if band %}
<div id="id_kit_wristband" class="token" data-token-id="{{ band.pk }}">
<i class="fa-solid fa-ring"></i>
<div class="tt">
<div class="tt-equip-btns">
{% if band.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ band.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ band.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ band.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ band.pk }}">×</button>{% endif %}
</div>
<h4 class="tt-title">{{ band.tooltip_name }}</h4>
<p class="tt-description">{{ band.tooltip_description }}</p>
{% if band.tooltip_shoptalk %}
<p class="tt-shoptalk"><em>{{ band.tooltip_shoptalk }}</em></p>
{% endif %}
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
</div>
</div>
{% endif %}
{% if carte %} {% if carte %}
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}" data-current-room-name="{{ carte.current_room.name|default:'' }}"> <div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}" data-current-room-name="{{ carte.current_room.name|default:'' }}">
<i class="fa-solid fa-money-check"></i> <i class="fa-solid fa-money-check"></i>

View File

@@ -10,6 +10,12 @@
<span>{{ pass_token.tooltip_name }}</span> <span>{{ pass_token.tooltip_name }}</span>
</div> </div>
{% endif %} {% endif %}
{% if band %}
<div class="gk-trinket-card" data-token-id="{{ band.pk }}">
<i class="fa-solid fa-ring"></i>
<span>{{ band.tooltip_name }}</span>
</div>
{% endif %}
{% if carte %} {% if carte %}
<div class="gk-trinket-card" data-token-id="{{ carte.pk }}"> <div class="gk-trinket-card" data-token-id="{{ carte.pk }}">
<i class="fa-solid fa-money-check"></i> <i class="fa-solid fa-money-check"></i>
@@ -22,7 +28,7 @@
<span>{{ coin.tooltip_name }}</span> <span>{{ coin.tooltip_name }}</span>
</div> </div>
{% endif %} {% endif %}
{% if not pass_token and not carte and not coin %} {% if not pass_token and not band and not carte and not coin %}
<p class="gk-empty"><em>No trinkets yet.</em></p> <p class="gk-empty"><em>No trinkets yet.</em></p>
{% endif %} {% endif %}
</div> </div>

View File

@@ -16,6 +16,18 @@
<p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p> <p class="tt-expiry">{{ pass_token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% elif band %}
<div id="id_band_token" class="token">
<i class="fa-solid fa-ring"></i>
<div class="tt">
<h4 class="tt-title">{{ band.tooltip_name }}</h4>
<p class="tt-description">{{ band.tooltip_description }}</p>
{% if band.tooltip_shoptalk %}
<p class="tt-shoptalk"><em>{{ band.tooltip_shoptalk }}</em></p>
{% endif %}
<p class="tt-expiry">{{ band.tooltip_expiry }}</p>
</div>
</div>
{% elif coin %} {% elif 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>

View File

@@ -43,6 +43,8 @@
<i class="fa-solid fa-medal"></i> <i class="fa-solid fa-medal"></i>
{% elif token.token_type == "carte" %} {% elif token.token_type == "carte" %}
<i class="fa-solid fa-money-check"></i> <i class="fa-solid fa-money-check"></i>
{% elif token.token_type == "band" %}
<i class="fa-solid fa-ring"></i>
{% else %} {% else %}
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
{% endif %} {% endif %}