diff --git a/src/functional_tests/test_trinket_backstage_pass.py b/src/functional_tests/test_trinket_backstage_pass.py new file mode 100644 index 0000000..2260d13 --- /dev/null +++ b/src/functional_tests/test_trinket_backstage_pass.py @@ -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. diff --git a/src/functional_tests/test_trinket_coin_on_a_string.py b/src/functional_tests/test_trinket_coin_on_a_string.py new file mode 100644 index 0000000..7779b32 --- /dev/null +++ b/src/functional_tests/test_trinket_coin_on_a_string.py @@ -0,0 +1,181 @@ +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: 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 Trinkets section is now empty (COIN unequipped on deposit). + 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" + ) + ) + + # ── 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: ' 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')}", + ) diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index 08f3b80..934e485 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -37,11 +37,11 @@ {% endif %} {% if coin %} -
+
- {% if coin.pk == equipped_trinket_id %}{% else %}{% endif %} + {% if coin.current_room %}{% elif coin.pk == equipped_trinket_id %}{% else %}{% endif %}

{{ coin.tooltip_name }}

{{ coin.tooltip_description }}