Compare commits

...

3 Commits

Author SHA1 Message Date
Disco DeDisco
df9cf1eee8 CI: route trinket FTs to test-FTs-room stage alongside game_room_*
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
User ask: ensure trinket FTs (test_trinket_carte_blanche.py, test_trinket_coin_on_a_string.py, test_trinket_backstage_pass.py) run in the room-stage CI bucket so they exercise the same room-template surface as test_game_room_* — relevant when a sprint touches the table hex SCSS / chair geometry / gatekeeper flow, since trinket FTs create rooms + walk thru the gate. Previously they fell into test-FTs-non-room by elimination (the non-room glob was `grep -v 'test_game_room_'`) ; main.yaml updates: non-room step's grep is now `-vE 'test_(game_room|trinket)_'` to exclude both clusters; room step's ls now globs BOTH `test_game_room_*.py` AND `test_trinket_*.py` ; companion memory tweak in feedback_ft_naming_prefix.md documents the new convention so future trinket FTs end up in the right bucket without re-asking ; pipeline behavior unchanged otherwise — both steps still depend_on test-two-browser-FTs only, parallel re-enable still blocked by the shared-sqlite issue from 2026-05-12 (cf. memo note)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:41:37 -04:00
Disco DeDisco
5e5bc5a6af COIN: unequip on deposit (parity w. CARTE) ; fix FT false-positive masking the bug
User reported on iPhone: after depositing a COIN at a game's gatekeeper, the Kit Bag's Trinket slot still shows the COIN — even though the tooltip correctly carries the room attribution ("Ready 2026-05-25 / Billingsworth"). Expected behavior matches CARTE: the deposited token disappears from the Kit Bag Trinket slot because it's committed elsewhere & can't be re-used as the active trinket until released. PASS preserved — auto-admits w.o ever going thru the deposit path so it stays equipped ; **the real bug**: `debit_token` in epic/models.py's COIN branch set `current_room` + `next_ready_at` but never cleared `user.equipped_trinket`. CARTE's `drop_token` view (epic/views.py:440-442) explicitly unequips at deposit time via `user.equipped_trinket = None; user.save(update_fields=["equipped_trinket"])`; COIN had no parity. Fix: same 4-line unequip stanza now lives inside the COIN branch of `debit_token`, guarded by `if user.equipped_trinket_id == token.pk` so a fresh-purchased COIN deposit (not the equipped one) doesn't accidentally clear another trinket. PASS untouched — falls thru `debit_token` w.o entering any branch & never reaches this path; CARTE untouched too (its branch is `pass`, unequip happens at `drop_token` time before debit_token is even called) ; **the FT false-positive**: yesterday's Sprint 2 commit (d2491c5) shipped `test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot` w. selector `#id_kit_bag_dialog .kit-bag-placeholder`. That selector was matching the **Dice** section's placeholder (Dice feature isn't built — `_kit_bag_panel.html` L23-29 renders `.kit-bag-placeholder` unconditionally), masking the bug whether or not the Trinket section was empty. Tightened to `.kit-bag-section--trinket .kit-bag-placeholder` w. comment explaining why a bare selector is unsafe ; template change in `_kit_bag_panel.html` L31: Trinket section gains a `kit-bag-section--trinket` modifier class so the FT (and any future selector that needs to single out the trinket section vs the deck/dice/tokens siblings) has an anchor. Mirrors the existing `kit-bag-section--tokens` class at L70 ; TDD trail: (1) tightened selector + reran → red on `NoSuchElement` (no `.kit-bag-section--trinket .kit-bag-placeholder` because COIN still equipped post-deposit, so trinket section renders the token card not the placeholder); (2) added unequip stanza to debit_token; (3) reran → green. 10 trinket FTs in 99s; 999 IT/UT in 46s — no regressions ; **generalizable trap**: when an FT waits for an element via a CSS selector, scope the selector to the section/container that uniquely identifies the assertion target — a class like `.kit-bag-placeholder` that's reused across multiple sections will silently pass even when the section you care about is in the wrong state. This is the second false-positive trap in two days (cf. d2491c5's wrong-selector trap where `.token-slot.claimed` was Carte-specific); pattern's worth noting

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:35:08 -04:00
Disco DeDisco
d2491c5e1b COIN: Carte treatment in Game Kit applet — data-current-room-name + deposited-state btn-disabled — TDD ; PASS skeleton FT
User-driven roadmap step (Sprint 2 of My Sea cluster): give the COIN trinket parity w. CARTE's deposit-aware Game Kit surface — when COIN is deposited in a room (Token.current_room FK set via debit_token in epic/models.py:135), its Game Kit token should expose the room name on `data-current-room-name` so the mini-portal can render "In-Use: <room name>" on hover, and both DON / DOFF btns should go btn-disabled (token is committed elsewhere, neither equip nor un-equip is valid). Mirrors the 3-state branch CARTE has in the same template (deposited / equipped / unequipped); PASS not in scope — auto-admits w.o deposit ; template change in _applet-game-kit.html — line 40 (COIN's div) gains `data-current-room-name="{{ coin.current_room.name|default:'' }}"` + an extra `{% if coin.current_room %}…{% elif coin.pk == equipped_trinket_id %}…{% else %}…{% endif %}` branch that fronts the existing 2-way w. a deposited-state arm (both btns "×" btn-disabled). View-side wiring already in place — coin context var is the user's COIN token incl. its `current_room` FK; no Python change needed ; TDD trail — test_trinket_coin_on_a_string.py (new, 3 FTs): T1 hover equipped COIN → mini portal "Equipped" + main portal tooltip prose (Coin-on-a-String / Admit 1 Entry / "…and another after that…" / no expiry); T2 deposit flow — rails-click → slot 1 RESERVED → click `.btn-confirm` inside the reserved gate-slot (NOT `.drop-token-btn` which is Carte's carte_active path) → slot fills + COIN admits only 1 entry (slot 2 has no follow-up btn cf. Carte's 6) + kit-bag Trinkets section empty (COIN unequipped on deposit); T3 navigate back to /gameboard/ → COIN's `#id_kit_coin_on_a_string` has `data-current-room-name="Commitment Room"` + both DON & DOFF btns inside `.tt` are btn-disabled ; initial red run hit a Carte-specific selector trap — `.token-slot.claimed` (the Carte machine UI from `user_filled_slot or carte_active` branch in _gatekeeper.html L23) doesn't fire for COIN, which lands on `.token-slot.pending` (user_reserved_slot branch); diagnosed via screendump grep — slot 1 carried class "gate-slot reserved" + token-slot was "pending"; FT rewritten to wait for `.gate-slot[data-slot='1'].reserved` → click `.btn-confirm` (the OK btn rendered for the reserving user in _table_positions.html L7-15) → wait for `.filled`. T1+T2 then green; T3 stayed red on `data-current-room-name` AttributeError (None != "Commitment Room") which is the actual bug the template fix addresses ; test_trinket_backstage_pass.py (new, 4 skeleton FTs): T1 staff-user signal contract — `gamer.equipped_trinket_id == pass_token.pk` post-signal; T2 tooltip renders title/description/shoptalk/expiry (Backstage Pass / Admit All Entry / "'Entry fee'? …" / no expiry); T3 equipped PASS mini portal says "Equipped"; T4 PASS btn apparatus — DON × btn-disabled, DOFF active w. label "DOFF" (symmetric to COIN's equipped state cf. test_gameboard.py:207-220). DEPOSIT FLOW DEFERRED to future sprint w. TODO comment block — PASS magically auto-admits any gate w.o going through the `.token-rails` deposit path that CARTE / COIN share, so no `data-current-room-name` parity work applies; user explicitly chose "Auto-admits, never deposited — keep current behavior" for this sprint ; 10 trinket FTs green in 93s (carte 4 + coin 3 + pass 3 — wait, pass has 4: 4); full IT/UT 999 green in 46s — no regressions; coin context already passing `coin.current_room` correctly thru _game_kit_context (no Python change). Sprint 2 of [[project_my_sea_applet]] cluster — next: My Sea applet shell (Sprint 3+)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:25:46 -04:00
6 changed files with 324 additions and 11 deletions

View File

@@ -107,10 +107,11 @@ steps:
commands: commands:
- pip install -r requirements.dev.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
# Every FT file EXCEPT test_game_room_* — that cluster runs in # Every FT file EXCEPT test_game_room_* and test_trinket_* — both
# test-FTs-room. Channels + two-browser tags already covered upstream. # clusters run in test-FTs-room. Channels + two-browser tags already
# `ls | grep -v | sed` enumerates module dotted-paths from filenames. # covered upstream. `ls | grep -v | sed` enumerates module dotted-paths
- python manage.py test --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_*.py | grep -v 'test_game_room_' | sed 's|/|.|g;s|\.py||') # from filenames.
- python manage.py test --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_*.py | grep -vE 'test_(game_room|trinket)_' | sed 's|/|.|g;s|\.py||')
when: when:
- event: push - event: push
path: path:
@@ -137,11 +138,14 @@ steps:
commands: commands:
- pip install -r requirements.dev.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
# Heavy Selenium room flows — 9 files (deck_contrib, gatekeeper, # Heavy Selenium room flows — test_game_room_* (deck_contrib,
# invite, select_role/sea/sig/sky, tray, tray_tooltip) isolated # gatekeeper, invite, select_role/sea/sig/sky, tray, tray_tooltip)
# into their own sub-step. Runs in parallel w. test-FTs-non-room # AND test_trinket_* (carte_blanche, coin_on_a_string, backstage_pass)
# since trinket FTs create rooms + load the room template (where the
# table hex SCSS + chair geometry live), so they exercise the same
# surface as test_game_room_*. Runs in parallel w. test-FTs-non-room
# (distinct DATABASE_URL paths under /tmp; see split-rationale). # (distinct DATABASE_URL paths under /tmp; see split-rationale).
- python manage.py test --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_game_room_*.py | sed 's|/|.|g;s|\.py||') - python manage.py test --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_game_room_*.py functional_tests/test_trinket_*.py | sed 's|/|.|g;s|\.py||')
when: when:
- event: push - event: push
path: path:

View File

@@ -136,6 +136,13 @@ def debit_token(user, slot, token):
period = slot.room.renewal_period or timedelta(days=7) period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period token.next_ready_at = timezone.now() + period
token.save() token.save()
# Parity w. CARTE's drop_token unequip: a deposited COIN is committed
# elsewhere & can't be re-used as the active trinket until the deposit
# is released, so clear `equipped_trinket` to drop it out of the Kit
# Bag's Trinket slot. PASS stays equipped (auto-admits, never deposits).
if user.equipped_trinket_id == token.pk:
user.equipped_trinket = None
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 != Token.PASS:

View File

@@ -0,0 +1,116 @@
"""Default-structure FTs for the Backstage Pass trinket.
PASS is the magical, never-deposited trinket — unlike CARTE / COIN it
auto-admits the user to any gate without going through the `.token-rails`
deposit flow, so there's no `data-current-room-name` / In-Use parity work
to do. These tests scaffold the auto-equip + tooltip rendering surfaces
that PASS DOES share w. the other trinkets; deposit-side behavior is left
as TODO until PASS's UX direction is decided in a future sprint.
"""
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 BackstagePassTest(FunctionalTest):
"""PASS auto-equips for is_staff users via the post_save signal in
apps.lyric.models.create_wallet_and_tokens — coverage focuses on the
initial-equipped state + tooltip prose. PASS doesn't go through the
`current_room` deposit path that CARTE / COIN use."""
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",
},
)
# is_staff branch of the signal grants PASS + auto-equips it
self.gamer = User.objects.create(email="vip@test.io", is_staff=True)
self.pass_token = self.gamer.tokens.get(token_type=Token.PASS)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_pass_auto_equipped_for_staff_user(self):
"""Verify the signal contract — staff users land on /gameboard/ with
PASS already equipped (mirror of COIN's auto-equip path for non-staff)."""
self.gamer.refresh_from_db()
self.assertEqual(self.gamer.equipped_trinket_id, self.pass_token.pk)
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_pass_tooltip_renders_full_prose(self):
"""Hover the Backstage Pass token in the Game Kit applet → main
tooltip shows title, description, shoptalk, and expiry."""
self.create_pre_authenticated_session("vip@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
pass_el = self.browser.find_element(By.ID, "id_kit_pass")
self.browser.execute_script(
"arguments[0].scrollIntoView({block: 'center'})", pass_el
)
ActionChains(self.browser).move_to_element(pass_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("Backstage Pass", portal.text)
self.assertIn("Admit All Entry", portal.text)
self.assertIn("Entry fee", portal.text) # shoptalk
self.assertIn("no expiry", portal.text)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_equipped_pass_shows_equipped_mini_tooltip(self):
"""Mini portal under the main tooltip says 'Equipped' for the auto-
equipped PASS (same shape as COIN's equipped-state mini portal)."""
self.create_pre_authenticated_session("vip@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
pass_el = self.browser.find_element(By.ID, "id_kit_pass")
ActionChains(self.browser).move_to_element(pass_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 4 ───────────────────────────────────────────────────────────────
def test_equipped_pass_shows_doff_active_don_disabled(self):
"""Equipped PASS apparatus: DOFF active (clickable), DON is × disabled.
Symmetric to the unequipped state COIN tests exercise after DOFF."""
self.create_pre_authenticated_session("vip@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
pass_el = self.browser.find_element(By.ID, "id_kit_pass")
ActionChains(self.browser).move_to_element(pass_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 — PASS deposit/admit flow:
# PASS auto-admits any gate w.o going through the rails like CARTE / COIN.
# When PASS's deposit UX is decided in a future sprint, add tests for the
# "PASS admits N slots" flow + (optional) data-current-room-name parity.

View File

@@ -0,0 +1,186 @@
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 CoinOnAStringTest(FunctionalTest):
"""COIN trinket gets the CARTE treatment — gets deposited through the
kit-bag rails, becomes unequipped on deposit, fills 1 gate slot (vs.
Carte's +6), shows In-Use: <room name> on Game Kit hover, and applies
btn-disabled to its DON / DOFF apparatus while deposited."""
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 → post_save signal creates COIN + FREE; COIN auto-equipped
# (cf. apps.lyric.models.create_wallet_and_tokens — is_staff branch grants
# PASS instead and auto-equips that; we want COIN as the starter trinket).
self.gamer = User.objects.create(email="coin@test.io")
self.coin = self.gamer.tokens.get(token_type=Token.COIN)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_equipped_coin_shows_equipped_mini_tooltip_in_game_kit(self):
"""COIN auto-equipped for non-staff users; hover on its Game Kit
token should surface the mini portal w. 'Equipped' just like the
Carte / Pass equipped-state path does."""
self.create_pre_authenticated_session("coin@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
coin_el = self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
self.browser.execute_script(
"arguments[0].scrollIntoView({block: 'center'})", coin_el
)
ActionChains(self.browser).move_to_element(coin_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("Coin-on-a-String", portal.text)
self.assertIn("Admit 1 Entry", portal.text)
self.assertIn("and another after that", portal.text)
self.assertIn("no expiry", portal.text)
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 2 ───────────────────────────────────────────────────────────────
def test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot(self):
"""Equipped COIN → create room → open kit bag → select COIN → click
rails btn → slot 1 enters RESERVED w. OK btn → click OK → slot fills
(COIN admits 1 Entry, cf. Carte's +6) → kit bag's Trinkets section
is now empty (COIN unequipped on deposit same shape as Carte)."""
self.create_pre_authenticated_session("coin@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
# Create a new room → land on gatekeeper
self.browser.find_element(By.ID, "id_new_game_name").send_keys("Coin Room")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url)
)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay"))
# Open kit bag, pick COIN, click rails btn — drop_token POSTs w. token_id
# via the kit-bag JS that injects a hidden token_id input before submit.
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'#id_kit_bag_dialog [data-token-type="{Token.COIN}"]',
)
).click()
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
# Slot 1 enters RESERVED — _table_positions.html renders an OK btn
# (.btn-confirm) inside the reserved slot pointing at confirm_token.
def get_slot(i):
return self.browser.find_element(
By.CSS_SELECTOR, f".gate-slot[data-slot='{i + 1}']"
)
self.wait_for(
lambda: self.assertIn("reserved", get_slot(0).get_attribute("class"))
)
self.wait_for(
lambda: get_slot(0).find_element(By.CSS_SELECTOR, ".btn-confirm")
).click()
self.wait_for(
lambda: self.assertIn("filled", get_slot(0).get_attribute("class"))
)
# COIN admits 1 Entry — slot 2 stays empty w. no confirm btn (cf.
# Carte's carte_active path that would surface drop-token-btn there).
self.assertEqual(
len(get_slot(1).find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0
)
# Kit bag's Trinket section is now empty (COIN unequipped on deposit).
# Selector is scoped to .kit-bag-section--trinket because the Dice
# section ALSO renders a .kit-bag-placeholder unconditionally (dice
# feature isn't built yet) — a bare `.kit-bag-placeholder` selector
# would silently pass even if the trinket section still rendered COIN.
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_kit_bag_dialog .kit-bag-section--trinket .kit-bag-placeholder",
)
)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_coin_in_use_game_kit_shows_room_attribution_and_btn_disabled(self):
"""While COIN is deposited in a room, its Game Kit token carries the
room name on data-current-room-name (parity w. Carte) so the mini
portal can render 'In-Use: <room name>' on hover; the DON & DOFF btns
are both btn-disabled while the COIN is committed elsewhere."""
self.create_pre_authenticated_session("coin@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
# Create room, deposit COIN — rails-click puts slot 1 in RESERVED;
# OK btn fills it (debit_token sets coin.current_room = room).
self.browser.find_element(By.ID, "id_new_game_name").send_keys("Commitment Room")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(lambda: self.assertIn("/gate/", self.browser.current_url))
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f'#id_kit_bag_dialog [data-token-type="{Token.COIN}"]',
)
).click()
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'].reserved"
)
)
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm"
)
).click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='1'].filled"
)
)
# Game Kit panel only renders on /gameboard/, not the gatekeeper page
self.browser.get(self.live_server_url + "/gameboard/")
coin_el = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_coin_on_a_string")
)
self.assertEqual(
"Commitment Room",
coin_el.get_attribute("data-current-room-name"),
)
# Both DON and DOFF btns inside the COIN tooltip should be btn-disabled
equip_btns = coin_el.find_elements(By.CSS_SELECTOR, ".tt .btn-equip, .tt .btn-unequip")
self.assertEqual(len(equip_btns), 2, "expected one DON btn + one DOFF btn")
for btn in equip_btns:
self.assertIn(
"btn-disabled", btn.get_attribute("class"),
f"expected btn-disabled on {btn.get_attribute('class')}",
)

View File

@@ -37,11 +37,11 @@
</div> </div>
{% endif %} {% endif %}
{% if coin %} {% if coin %}
<div id="id_kit_coin_on_a_string" class="token" data-token-id="{{ coin.pk }}"> <div id="id_kit_coin_on_a_string" class="token" data-token-id="{{ coin.pk }}" data-current-room-name="{{ coin.current_room.name|default:'' }}">
<i class="fa-solid fa-medal"></i> <i class="fa-solid fa-medal"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns"> <div class="tt-equip-btns">
{% if coin.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ coin.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ coin.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ coin.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ coin.pk }}">×</button>{% endif %} {% if coin.current_room %}<button class="btn btn-equip btn-disabled" data-token-id="{{ coin.pk }}">×</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ coin.pk }}">×</button>{% elif coin.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ coin.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ coin.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ coin.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ coin.pk }}">×</button>{% endif %}
</div> </div>
<h4 class="tt-title">{{ coin.tooltip_name }}</h4> <h4 class="tt-title">{{ coin.tooltip_name }}</h4>
<p class="tt-description">{{ coin.tooltip_description }}</p> <p class="tt-description">{{ coin.tooltip_description }}</p>

View File

@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
<div class="kit-bag-section"> <div class="kit-bag-section kit-bag-section--trinket">
<span class="kit-bag-label">Trinket</span> <span class="kit-bag-label">Trinket</span>
<div class="kit-bag-row"> <div class="kit-bag-row">
{% if equipped_trinket %} {% if equipped_trinket %}