add Carte Blanche trinket: equip system, gatekeeper multi-slot, mini tooltip portal; new token type Token.CARTE ('carte') with fa-money-check icon; migrations 0010-0012:
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

CARTE type, User.equipped_trinket FK, Token.slots_claimed field; post_save signal sets
equipped_trinket=COIN for new users, PASS for staff; kit bag now shows only the equipped
trinket in Trinkets section; Game Kit applet mini tooltip portal shows Equipped or Equip
Trinket per token; AJAX POST equip-trinket id updates equippedId in-place; equip btn
now works for COIN, PASS, and CARTE (data-token-id added to all three); Gatekeeper CARTE
flow: drop_token sets current_room (no slot reserved); each empty slot up to
slots_claimed+1 gets a drop-token-btn; slots_claimed high-water mark advances on fill,
never decrements; highest CARTE-filled slot gets NVM (release_slot); token_return_btn
resets current_room + slots_claimed + un-fills all CARTE slots; gate_status always returns
full template so launch-game-btn persists via HTMX when gate_status == OPEN; room.html
includes gatekeeper when GATHERING or OPEN; new FT test_trinket_carte_blanche.py (2
tests, both passing); 299 tests green
This commit is contained in:
Disco DeDisco
2026-03-16 00:07:52 -04:00
parent b49218b45b
commit 4239245902
26 changed files with 842 additions and 105 deletions

View File

@@ -0,0 +1,325 @@
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 CarteBlancheTest(FunctionalTest):
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 triggers COIN + FREE + PASS via post_save signal; PASS auto-equipped
self.gamer = User.objects.create(email="blanche@test.io", is_staff=True)
Token.objects.create(user=self.gamer, token_type=Token.TITHE)
self.carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_equipped_pass_shows_mini_tooltip_in_game_kit(self):
# 1. Log in, land on dashboard
self.create_pre_authenticated_session("blanche@test.io")
self.browser.get(self.live_server_url)
# 2. Open kit bag — Backstage Pass visible in Trinkets section
self.wait_for(lambda: 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.PASS}"]',
)
)
# 3. Navigate to gameboard
self.browser.find_element(
By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]'
).click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/gameboard/$")
)
# 4. Find Backstage Pass in the Game Kit applet
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
)
# 5. Hover over Pass — main tooltip appears via portal
ActionChains(self.browser).move_to_element(pass_el).perform()
self.wait_for(
lambda: self.assertTrue(
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)
# 6. A mini tooltip appears below the main tooltip, flush with its right edge.
# Since Pass is the equipped trinket, it says "Equipped."
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)
portal_rect = self.browser.execute_script(
"return arguments[0].getBoundingClientRect()", portal
)
mini_rect = self.browser.execute_script(
"return arguments[0].getBoundingClientRect()", mini
)
self.assertGreater(mini_rect["top"], portal_rect["bottom"] - 5) # below main
self.assertAlmostEqual(mini_rect["right"], portal_rect["right"], delta=10) # flush right
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_carte_blanche_equip_and_multi_slot_gatekeeper(self):
# 1. Log in, navigate directly to gameboard
self.create_pre_authenticated_session("blanche@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit"))
# 2. Hover over Coin-on-a-String, Free Token — no mini tooltip (not equippable)
for token_id in ("id_kit_coin_on_a_string", "id_kit_free_token"):
el = self.browser.find_element(By.ID, token_id)
ActionChains(self.browser).move_to_element(el).perform()
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
self.assertFalse(
self.browser.find_element(By.ID, "id_mini_tooltip_portal").is_displayed()
)
# 3. Hover Carte Blanche — main tooltip present; mini tooltip shows "Equip Trinket?"
carte_el = self.browser.find_element(By.ID, "id_kit_carte_blanche")
self.browser.execute_script(
"arguments[0].scrollIntoView({block: 'center'})", carte_el
)
ActionChains(self.browser).move_to_element(carte_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("Carte Blanche", portal.text)
self.assertIn("Admit up to +6", portal.text)
self.assertIn("taking over from here", 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()))
equip_btn = mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn")
self.assertEqual(equip_btn.text, "Equip Trinket?")
# 4. Click "Equip Trinket?" — DB switches; both portals close
equip_btn.click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed()
)
)
# 5. JS-side optimistic update: data-equipped-id reflects Carte immediately
game_kit = self.browser.find_element(By.ID, "id_game_kit")
self.wait_for(
lambda: self.assertEqual(
game_kit.get_attribute("data-equipped-id"),
str(self.carte.pk),
)
)
# NOTE: re-hovering carte_el here to assert "Equipped" in mini is unreliable in
# headless GeckoDriver — move_to_element uses a different scroll-into-view algorithm
# than scrollIntoView({block:'center'}), so the computed element centre can match the
# cursor's current position and no mousemove fires. The equip round-trip is validated
# implicitly by the DB-side check below (step 6: Pass now shows "Equip Trinket?").
# 6. Hover Backstage Pass — mini tooltip shows "Equip Trinket?" (Pass no longer equipped)
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.assertIn(
"Backstage Pass",
self.browser.find_element(By.ID, "id_tooltip_portal").text,
)
)
mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal")
self.wait_for(lambda: self.assertTrue(mini.is_displayed()))
self.assertTrue(mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn").is_displayed())
# ── GATEKEEPER PHASE ─────────────────────────────────────────────────
# 8. Create a new game room via the New Game applet
self.browser.find_element(By.ID, "id_new_game_name").send_keys("The Long Room")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(
lambda: self.assertIn("/gameboard/room/", self.browser.current_url)
)
self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url)
)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
)
def open_kit_and_select_carte():
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.CARTE}"]',
)
)
self.browser.find_element(
By.CSS_SELECTOR,
f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]',
).click()
def deposit_carte():
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed")
)
# 9. Open kit bag, select Carte Blanche, deposit via rails btn
open_kit_and_select_carte()
deposit_carte()
# Helper: always fetches a fresh gate-slot element (avoid stale refs after redirects)
def get_circle(i):
return self.browser.find_element(
By.CSS_SELECTOR, f".gate-slot[data-slot='{i + 1}']"
)
# 10. Return panel glows; circle 1 gains OK btn → click it → fills
return_panel = self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed")
self.assertTrue(return_panel.is_displayed())
self.wait_for(
lambda: get_circle(0).find_element(By.CSS_SELECTOR, ".drop-token-btn")
).click()
self.wait_for(
lambda: self.assertIn("filled", get_circle(0).get_attribute("class"))
)
# 11. Return panel still glows; circle 2 now has OK btn
self.assertTrue(
self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed").is_displayed()
)
self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn"))
# 12. Carte tooltip in kit bag shows room name (lease info)
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.CARTE}"]',
)
)
carte_in_bag = self.browser.find_element(
By.CSS_SELECTOR, f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]'
)
# Kit bag tooltips are CSS-hidden; read textContent (not .text) to avoid
# relying on hover visibility in headless Firefox.
self.assertIn(
"The Long Room",
carte_in_bag.find_element(By.CSS_SELECTOR, ".token-tooltip").get_attribute("textContent"),
)
# Close kit bag
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed()
)
)
# 13. Cold feet: click return panel → Carte returned, return panel gone
self.browser.find_element(By.CSS_SELECTOR, ".token-return-btn").click()
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".token-slot.claimed")), 0
)
)
# Lease info cleared from kit bag tooltip
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.CARTE}"]',
)
)
carte_in_bag = self.browser.find_element(
By.CSS_SELECTOR, f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]'
)
self.assertNotIn(
"The Long Room",
carte_in_bag.find_element(By.CSS_SELECTOR, ".token-tooltip").get_attribute("textContent"),
)
self.browser.find_element(By.ID, "id_kit_btn").click()
# ── COLD FEET RESOLVED: full six-slot run ────────────────────────────
# 14. Re-deposit Carte
open_kit_and_select_carte()
deposit_carte()
# 15. Click OK on circle 1 → fills; circle 1 loses its OK btn; circle 2 gains one
self.wait_for(
lambda: get_circle(0).find_element(By.CSS_SELECTOR, ".drop-token-btn")
).click()
self.wait_for(
lambda: self.assertIn("filled", get_circle(0).get_attribute("class"))
)
self.wait_for(
lambda: self.assertEqual(
len(get_circle(0).find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0
)
)
self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn"))
# 16. Click OK on circle 2 → turns to NVM; circle 3 gains OK
self.wait_for(
lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn")
).click()
self.wait_for(
lambda: self.assertEqual(
get_circle(1).find_element(By.CSS_SELECTOR, ".slot-release-btn").text.upper(),
"NVM",
)
)
self.wait_for(lambda: get_circle(2).find_element(By.CSS_SELECTOR, ".drop-token-btn"))
# 17. Click NVM on circle 2 → returns to OK; circle 3 still has OK
# Circle 1 still filled; return panel still glowing; lease name still present
self.wait_for(
lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".slot-release-btn")
).click()
self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn"))
self.assertIn("filled", get_circle(0).get_attribute("class"))
self.assertTrue(
self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed").is_displayed()
)
self.wait_for(lambda: get_circle(2).find_element(By.CSS_SELECTOR, ".drop-token-btn"))
# 18. Fill circles 2 → 6 sequentially; verify each subsequent OK appears
for i in range(1, 6):
self.wait_for(
lambda i=i: get_circle(i).find_element(By.CSS_SELECTOR, ".drop-token-btn")
).click()
self.wait_for(
lambda i=i: self.assertIn("filled", get_circle(i).get_attribute("class"))
)
if i < 5:
self.wait_for(
lambda i=i: get_circle(i + 1).find_element(
By.CSS_SELECTOR, ".drop-token-btn"
)
)
# 19. All six circles filled — launch btn appears
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn")
)