Compare commits
3 Commits
79706e817a
...
df9cf1eee8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9cf1eee8 | ||
|
|
5e5bc5a6af | ||
|
|
d2491c5e1b |
@@ -107,10 +107,11 @@ steps:
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
# Every FT file EXCEPT test_game_room_* — that cluster runs in
|
||||
# test-FTs-room. Channels + two-browser tags already covered upstream.
|
||||
# `ls | grep -v | sed` enumerates module dotted-paths from filenames.
|
||||
- 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||')
|
||||
# Every FT file EXCEPT test_game_room_* and test_trinket_* — both
|
||||
# clusters run in test-FTs-room. Channels + two-browser tags already
|
||||
# covered upstream. `ls | grep -v | sed` enumerates module dotted-paths
|
||||
# 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:
|
||||
- event: push
|
||||
path:
|
||||
@@ -137,11 +138,14 @@ steps:
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
# Heavy Selenium room flows — 9 files (deck_contrib, gatekeeper,
|
||||
# invite, select_role/sea/sig/sky, tray, tray_tooltip) isolated
|
||||
# into their own sub-step. Runs in parallel w. test-FTs-non-room
|
||||
# Heavy Selenium room flows — test_game_room_* (deck_contrib,
|
||||
# gatekeeper, invite, select_role/sea/sig/sky, tray, tray_tooltip)
|
||||
# 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).
|
||||
- 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:
|
||||
- event: push
|
||||
path:
|
||||
|
||||
@@ -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:
|
||||
|
||||
116
src/functional_tests/test_trinket_backstage_pass.py
Normal file
116
src/functional_tests/test_trinket_backstage_pass.py
Normal 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.
|
||||
186
src/functional_tests/test_trinket_coin_on_a_string.py
Normal file
186
src/functional_tests/test_trinket_coin_on_a_string.py
Normal 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')}",
|
||||
)
|
||||
@@ -37,11 +37,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<div class="tt">
|
||||
<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>
|
||||
<h4 class="tt-title">{{ coin.tooltip_name }}</h4>
|
||||
<p class="tt-description">{{ coin.tooltip_description }}</p>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kit-bag-section">
|
||||
<div class="kit-bag-section kit-bag-section--trinket">
|
||||
<span class="kit-bag-label">Trinket</span>
|
||||
<div class="kit-bag-row">
|
||||
{% if equipped_trinket %}
|
||||
|
||||
Reference in New Issue
Block a user