From 5e5bc5a6af6d2197e4632e82b86a7a798de9fb85 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 18 May 2026 19:35:08 -0400 Subject: [PATCH] COIN: unequip on deposit (parity w. CARTE) ; fix FT false-positive masking the bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Git commit message Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/models.py | 7 +++++++ src/functional_tests/test_trinket_coin_on_a_string.py | 9 +++++++-- src/templates/core/_partials/_kit_bag_panel.html | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 6a450ab..97bfd6b 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -136,6 +136,13 @@ def debit_token(user, slot, token): period = slot.room.renewal_period or timedelta(days=7) token.next_ready_at = timezone.now() + period 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: pass # current_room already set in drop_token; token not consumed elif token.token_type != Token.PASS: diff --git a/src/functional_tests/test_trinket_coin_on_a_string.py b/src/functional_tests/test_trinket_coin_on_a_string.py index 7779b32..48ac9c9 100644 --- a/src/functional_tests/test_trinket_coin_on_a_string.py +++ b/src/functional_tests/test_trinket_coin_on_a_string.py @@ -113,11 +113,16 @@ class CoinOnAStringTest(FunctionalTest): len(get_slot(1).find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 ) - # Kit bag's Trinkets section is now empty (COIN unequipped on deposit). + # 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-placeholder" + By.CSS_SELECTOR, + "#id_kit_bag_dialog .kit-bag-section--trinket .kit-bag-placeholder", ) ) diff --git a/src/templates/core/_partials/_kit_bag_panel.html b/src/templates/core/_partials/_kit_bag_panel.html index 955a917..51736d8 100644 --- a/src/templates/core/_partials/_kit_bag_panel.html +++ b/src/templates/core/_partials/_kit_bag_panel.html @@ -28,7 +28,7 @@ -
+
Trinket
{% if equipped_trinket %}