import time import unittest from django.test import tag from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from .base import FunctionalTest from .room_page import _assign_all_roles, _fill_room_via_orm from apps.applets.models import Applet from apps.epic.models import DeckVariant, Room from apps.lyric.models import User # ── Seat Tray ──────────────────────────────────────────────────────────────── # # The Tray is a per-seat, per-room slide-out panel anchored to the right edge # of the viewport. #id_tray_btn is a drawer-handle-shaped button: a circle # with an icon (the "ivory centre") with decorative lines curving from its top # and bottom to the right edge of the screen. # # Behaviour: # - Closed by default; tray panel (#id_tray) is not visible. # - Clicking the button while closed: wobbles the handle (adds "wobble" # class) but does NOT open the tray. # - Dragging the button leftward: reveals the tray. # - Clicking the button while open: slides the tray closed. # - On page reload: tray always starts closed (JS in-memory only). # # Contents (populated in later sprints): Role card, Significator, Celtic Cross # draw, sky wheel, committed dice/cards for this table. # # ───────────────────────────────────────────────────────────────────────────── class TrayTest(FunctionalTest): def setUp(self): # Portrait viewport for T1–T5 (768×1024). Use _make_browser so # headless CI gets --width/--height args and the CSS orientation # media query is correct from first paint. self.browser = self._make_browser(768, 1024) self.test_server = None def _switch_to_landscape(self): """Recreate the browser, navigate to about:blank, then resize to 900×500 and wait until window.innerWidth > window.innerHeight confirms the CSS orientation media query will fire correctly on the next page.""" self.browser.quit() self.browser = self._make_browser(900, 500) self.browser.get('about:blank') self.browser.set_window_size(900, 500) time.sleep(0.5) # allow Firefox to flush the resize before navigating self.wait_for(lambda: self.assertTrue( self.browser.execute_script( 'return window.innerWidth > window.innerHeight' ) )) def _simulate_drag(self, btn, offset_x): """Dispatch JS pointer events directly — more reliable than GeckoDriver drag.""" start_x = btn.rect['x'] + btn.rect['width'] / 2 end_x = start_x + offset_x self.browser.execute_script(""" var btn = arguments[0], startX = arguments[1], endX = arguments[2]; btn.dispatchEvent(new PointerEvent("pointerdown", {clientX: startX, bubbles: true})); document.dispatchEvent(new PointerEvent("pointermove", {clientX: endX, bubbles: true})); document.dispatchEvent(new PointerEvent("pointerup", {clientX: endX, bubbles: true})); """, btn, start_x, end_x) def _simulate_drag_y(self, btn, offset_y): """Dispatch JS pointer events on the Y axis for landscape drag tests.""" start_y = btn.rect['y'] + btn.rect['height'] / 2 end_y = start_y + offset_y self.browser.execute_script(""" var btn = arguments[0], startY = arguments[1], endY = arguments[2]; btn.dispatchEvent(new PointerEvent("pointerdown", {clientY: startY, clientX: 0, bubbles: true})); document.dispatchEvent(new PointerEvent("pointermove", {clientY: endY, clientX: 0, bubbles: true})); document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); """, btn, start_y, end_y) def _make_role_select_room(self, founder_email="founder@test.io"): from apps.epic.models import TableSeat founder, _ = User.objects.get_or_create(email=founder_email) room = Room.objects.create(name="Tray Test Room", owner=founder) emails = [founder_email, "nc@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io"] _fill_room_via_orm(room, emails) room.table_status = Room.ROLE_SELECT room.save() for i, email in enumerate(emails, start=1): gamer, _ = User.objects.get_or_create(email=email) TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i) return room def _make_sig_select_room(self, founder_email="founder@test.io"): founder, _ = User.objects.get_or_create(email=founder_email) room = Room.objects.create(name="Tray Test Room", owner=founder) _fill_room_via_orm(room, [ founder_email, "nc@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", ]) _assign_all_roles(room) return room def _room_url(self, room): return f"{self.live_server_url}/gameboard/room/{room.id}/gate/" # ------------------------------------------------------------------ # # Test T1 — tray button is present and anchored to the right edge # # ------------------------------------------------------------------ # def test_tray_btn_is_present_on_room_page(self): room = self._make_sig_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for( lambda: self.browser.find_element(By.ID, "id_tray_btn") ) self.assertTrue(btn.is_displayed()) # Button should be anchored near the right edge of the viewport vp_width = self.browser.execute_script("return window.innerWidth") btn_right = btn.location["x"] + btn.size["width"] self.assertGreater(btn_right, vp_width * 0.8) # ------------------------------------------------------------------ # # Test T2 — tray is closed by default; clicking wobbles the handle # # ------------------------------------------------------------------ # def test_tray_is_closed_by_default_and_click_wobbles(self): room = self._make_sig_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) # Tray panel not visible when closed tray = self.browser.find_element(By.ID, "id_tray") self.assertFalse(tray.is_displayed()) # Clicking the closed btn adds a wobble class to the wrap. # Use a MutationObserver to capture the transient class change — in CI # headless Firefox the 0.45s animation may complete before the first # wait_for poll (0.5s), causing a false miss. self.browser.execute_script(""" window._trayWobbled = false; var wrap = document.getElementById('id_tray_wrap'); var obs = new MutationObserver(function(muts) { muts.forEach(function(m) { if (m.type === 'attributes' && m.attributeName === 'class') { if (m.target.classList.contains('wobble')) { window._trayWobbled = true; obs.disconnect(); } } }); }); obs.observe(wrap, {attributes: true, attributeFilter: ['class']}); """) self.browser.find_element(By.ID, "id_tray_btn").click() self.wait_for( lambda: self.assertTrue( self.browser.execute_script("return window._trayWobbled;") ) ) # Tray still not visible — a click alone must not open it self.assertFalse(tray.is_displayed()) # ------------------------------------------------------------------ # # Test T3 — dragging tray btn leftward opens the tray # # ------------------------------------------------------------------ # def test_dragging_tray_btn_left_opens_tray(self): room = self._make_sig_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) tray = self.browser.find_element(By.ID, "id_tray") self.assertFalse(tray.is_displayed()) self._simulate_drag(btn, -300) self.wait_for(lambda: self.assertTrue(tray.is_displayed())) # ------------------------------------------------------------------ # # Test T4 — clicking btn while tray is open slides it closed # # ------------------------------------------------------------------ # def test_clicking_open_tray_btn_closes_tray(self): room = self._make_sig_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) self._simulate_drag(btn, -300) tray = self.browser.find_element(By.ID, "id_tray") self.wait_for(lambda: self.assertTrue(tray.is_displayed())) self.browser.find_element(By.ID, "id_tray_btn").click() self.wait_for(lambda: self.assertFalse(tray.is_displayed())) # ------------------------------------------------------------------ # # Test T5 — tray reverts to closed on page reload # # ------------------------------------------------------------------ # def test_tray_reverts_to_closed_on_reload(self): room = self._make_sig_select_room() self.create_pre_authenticated_session("founder@test.io") room_url = self._room_url(room) self.browser.get(room_url) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) self._simulate_drag(btn, -300) tray = self.browser.find_element(By.ID, "id_tray") self.wait_for(lambda: self.assertTrue(tray.is_displayed())) # Reload — tray must start closed regardless of previous state self.browser.get(room_url) self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) tray = self.browser.find_element(By.ID, "id_tray") self.assertFalse(tray.is_displayed()) # ------------------------------------------------------------------ # # Test T6 — landscape: tray btn is near the top edge of the viewport # # ------------------------------------------------------------------ # @tag('two-browser') def test_tray_btn_anchored_near_top_in_landscape(self): room = self._make_sig_select_room() self._switch_to_landscape() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for( lambda: self.browser.find_element(By.ID, "id_tray_btn") ) self.assertTrue(btn.is_displayed()) # In landscape the handle sits at the top of the content area; # btn bottom should be within the top 40% of the viewport. vh = self.browser.execute_script("return window.innerHeight") btn_bottom = btn.location["y"] + btn.size["height"] self.assertLess(btn_bottom, vh * 0.4) # ------------------------------------------------------------------ # # Test T7 — landscape: dragging btn downward opens the tray # # ------------------------------------------------------------------ # @tag('two-browser') def test_dragging_tray_btn_down_opens_tray_in_landscape(self): room = self._make_sig_select_room() self._switch_to_landscape() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) # In landscape, #id_tray is always display:block; position controls visibility. # Use Tray.isOpen() to check logical state. self.assertFalse(self.browser.execute_script("return Tray.isOpen()")) self._simulate_drag_y(btn, 300) self.wait_for( lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) ) # ------------------------------------------------------------------ # # Test T8 — portrait: 1 column × 8 rows of square cells # # ------------------------------------------------------------------ # @unittest.skip("portrait grid layout flaky in CI headless Firefox — revisit") @tag('two-browser') def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): room = self._make_role_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) self._simulate_drag(btn, -300) self.wait_for( lambda: self.assertTrue( self.browser.find_element(By.ID, "id_tray").is_displayed() ) ) cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") self.assertEqual(len(cells), 8) # 8 explicit rows set via grid-template-rows row_count = self.browser.execute_script(""" var s = getComputedStyle(document.getElementById('id_tray_grid')); return s.gridTemplateRows.trim().split(/\\s+/).length; """) self.assertEqual(row_count, 8) # All 8 cells share the same x position — one column only xs = {round(c.location['x']) for c in cells} self.assertEqual(len(xs), 1) # Cells are square cell = cells[0] self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2) # ------------------------------------------------------------------ # # Test T9 — landscape: 8 columns × 1 row of square cells # # ------------------------------------------------------------------ # # T9a — column/row count (structure) @unittest.skip("landscape grid layout flaky in CI headless Firefox — revisit") @tag('two-browser') def test_tray_grid_is_8_columns_by_1_row_in_landscape(self): room = self._make_sig_select_room() self._switch_to_landscape() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) self._simulate_drag_y(btn, 300) self.wait_for( lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) ) cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") self.assertEqual(len(cells), 8) # 8 explicit columns set via grid-template-columns col_count = self.browser.execute_script(""" var s = getComputedStyle(document.getElementById('id_tray_grid')); return s.gridTemplateColumns.trim().split(/\\s+/).length; """) self.assertEqual(col_count, 8) # All 8 cells share the same y position — one row only ys = {round(c.location['y']) for c in cells} self.assertEqual(len(ys), 1) # Cells are square cell = cells[0] self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2) # ------------------------------------------------------------------ # # Test T9b — landscape: all 8 cells visible within the tray interior # # ------------------------------------------------------------------ # @unittest.skip("landscape cell bounds flaky in CI headless Firefox — revisit with T9a") @tag('two-browser') def test_landscape_tray_all_8_cells_visible(self): room = self._make_sig_select_room() self._switch_to_landscape() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn")) self._simulate_drag_y(btn, 300) self.wait_for( lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()")) ) tray = self.browser.find_element(By.ID, "id_tray") cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell") self.assertEqual(len(cells), 8) tray_right = tray.location['x'] + tray.size['width'] tray_bottom = tray.location['y'] + tray.size['height'] # Each cell must fit within the tray interior (2px rounding slack) for cell in cells: self.assertLessEqual( cell.location['x'] + cell.size['width'], tray_right + 2, msg="Cell overflows tray right edge" ) self.assertLessEqual( cell.location['y'] + cell.size['height'], tray_bottom + 2, msg="Cell overflows tray bottom edge" ) # ───────────────────────────────────────────────────────────────────────────── # # Tarot deck + Game Kit FTs — migrated from the legacy # test_component_cards_tarot.py (2026-05-12). These exercise the in-room tarot # deck page (Celtic Cross deal), the Game Kit deck-variant selection w. hover # tooltips + Equip/Equipped state, and the dedicated game-kit page w. its # four applet rows + tarot fan modal. The admin-side tarot browse FT split # off into test_admin_tarot.py at the same time. # # ───────────────────────────────────────────────────────────────────────────── class TarotDeckTest(FunctionalTest): """A room founder can view the tarot deck page and deal a Celtic Cross spread.""" def setUp(self): super().setUp() # DeckVariant + TarotCard rows are flushed by TransactionTestCase — recreate from apps.epic.models import TarotCard self.earthman, _ = DeckVariant.objects.get_or_create( slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) # Seed 8 major cards — enough for a 6-card cross deal (with buffer) major_stubs = [ (0, "The Schiz", "the-schiz-ft"), (1, "Pope I: President", "pope-i-president-ft"), (2, "Pope II: Tsar", "pope-ii-tsar-ft"), (3, "Pope III: Chairman","pope-iii-chairman-ft"), (4, "Pope IV: Emperor", "pope-iv-emperor-ft"), (5, "Pope V: Chancellor","pope-v-chancellor-ft"), (10, "Wheel of Fortune", "wheel-of-fortune-em-ft"), (11, "The Junkboat", "the-junkboat-ft"), ] for number, name, slug in major_stubs: TarotCard.objects.get_or_create( deck_variant=self.earthman, slug=slug, defaults={"name": name, "arcana": "MAJOR", "number": number}, ) self.founder = User.objects.create(email="founder@test.io") # Signal sets equipped_deck to Earthman (now it exists) self.founder.refresh_from_db() self.room = Room.objects.create(name="Whispering Pines", owner=self.founder) # ------------------------------------------------------------------ # # Test 2 — tarot deck page reports 108 cards (Earthman default) # # ------------------------------------------------------------------ # def test_founder_can_reach_room_tarot_page_and_sees_full_deck(self): self.create_pre_authenticated_session("founder@test.io") self.browser.get( self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" ) # Browser tab title confirms we're on the tarot page self.wait_for( lambda: self.assertIn("Tarot", self.browser.title) ) # Deck status shows all 108 Earthman cards remaining status = self.browser.find_element(By.CSS_SELECTOR, "[data-tarot-remaining]") self.assertEqual(status.get_attribute("data-tarot-remaining"), "108") # ------------------------------------------------------------------ # # Test 3 — dealing a Celtic Cross spread shows 10 positioned cards # # ------------------------------------------------------------------ # def test_dealing_celtic_cross_spread_shows_ten_unique_cards(self): self.create_pre_authenticated_session("founder@test.io") self.browser.get( self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" ) # Click the "Deal Celtic Cross" button self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-deal-spread]") ).click() # Six cross positions appear in the spread (staff positions filled via gameplay) positions = self.wait_for( lambda: self.browser.find_elements(By.CSS_SELECTOR, ".tarot-position") ) self.assertEqual(len(positions), 6) # Each position shows a card name and an orientation label names = set() for pos in positions: name = pos.find_element(By.CSS_SELECTOR, ".tarot-card-name").text orientation = pos.find_element(By.CSS_SELECTOR, ".tarot-card-orientation").text self.assertTrue(len(name) > 0, "Card name should not be empty") self.assertIn(orientation, ["Upright", "Reversed"]) names.add(name) # All 6 cards are unique self.assertEqual(len(names), 6, "All 6 drawn cards must be unique") # ------------------------------------------------------------------ # # Test 4 — deck count decreases after the spread is dealt # # ------------------------------------------------------------------ # def test_remaining_count_decreases_after_dealing_spread(self): self.create_pre_authenticated_session("founder@test.io") self.browser.get( self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" ) self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-deal-spread]") ).click() # After dealing 6 cross cards from the 108-card Earthman deck, 102 remain remaining = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-tarot-remaining]") ) self.assertEqual(remaining.get_attribute("data-tarot-remaining"), "102") class GameKitDeckSelectionTest(FunctionalTest): """ Game Kit applet on gameboard shows available deck variants with hover tooltips and an equip/equipped state — following the same mini-tooltip pattern as trinket selection. Test scenario: the gamer's active deck is explicitly set to Fiorentine (non-default) in setUp, so we can exercise switching back to Earthman. Once DeckVariant model exists, replace the TODO stubs with real ORM calls. """ 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", }, ) # DeckVariant rows are flushed by TransactionTestCase — recreate before # creating the user so the post_save signal can set equipped_deck = earthman. self.earthman, _ = DeckVariant.objects.get_or_create( slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) self.rws, _ = DeckVariant.objects.get_or_create( slug="tarot-rider-waite-smith", defaults={"name": "Tarot (Rider-Waite-Smith)", "card_count": 78, "is_default": False}, ) self.gamer = User.objects.create(email="gamer@deck.io") # Signal sets equipped_deck = earthman and unlocked_decks = [earthman]. # Explicitly grant fiorentine too, then switch equipped_deck to it so # the test can exercise switching back to Earthman. self.gamer.refresh_from_db() self.gamer.unlocked_decks.add(self.rws) self.gamer.equipped_deck = self.rws self.gamer.save(update_fields=["equipped_deck"]) # ------------------------------------------------------------------ # # Test 5 — Game Kit shows deck cards with correct equip/equipped state # # ------------------------------------------------------------------ # def test_game_kit_deck_cards_show_equip_state_and_switching_works(self): """ Gamer (currently on Fiorentine) visits gameboard, hovers over the Earthman deck — sees it is NOT equipped. Hovers to Fiorentine — sees it IS equipped. Hovers back to Earthman and clicks Equip. """ self.create_pre_authenticated_session("gamer@deck.io") self.browser.get(self.live_server_url + "/gameboard/") self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) # ── Hover over Earthman deck ────────────────────────────────────── earthman_el = self.wait_for( lambda: self.browser.find_element(By.ID, "id_kit_earthman_deck") ) self.browser.execute_script( "arguments[0].scrollIntoView({block: 'center'})", earthman_el ) ActionChains(self.browser).move_to_element(earthman_el).perform() # Main tooltip shows deck name and card count 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("Earthman", portal.text) self.assertIn("108", portal.text) # Mini shows "Not Equipped"; DON button is active in the main portal mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") self.wait_for(lambda: self.assertTrue(mini.is_displayed())) self.assertIn("Not Equipped", mini.text) don = portal.find_element(By.CSS_SELECTOR, ".btn-equip") self.assertNotIn("btn-disabled", don.get_attribute("class")) # ── Hover over Tarot (Rider-Waite-Smith) deck ──────────────────── rws_el = self.browser.find_element(By.ID, "id_kit_tarot_deck") self.browser.execute_script( "arguments[0].scrollIntoView({block: 'center'})", rws_el ) ActionChains(self.browser).move_to_element(rws_el).perform() self.wait_for( lambda: self.assertIn( "Rider-Waite-Smith", self.browser.find_element(By.ID, "id_tooltip_portal").text, ) ) portal = self.browser.find_element(By.ID, "id_tooltip_portal") self.assertIn("78", portal.text) # Mini tooltip shows "Equipped" — RWS is the active deck 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) # ── Hover back to Earthman and click DON ───────────────────────── ActionChains(self.browser).move_to_element(earthman_el).perform() self.wait_for( lambda: self.assertIn( "Earthman", self.browser.find_element(By.ID, "id_tooltip_portal").text, ) ) portal = self.browser.find_element(By.ID, "id_tooltip_portal") mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") self.wait_for(lambda: self.assertTrue(mini.is_displayed())) portal.find_element(By.CSS_SELECTOR, ".btn-equip").click() # DON becomes disabled; mini updates to "Equipped"; data attr set optimistically self.wait_for( lambda: self.assertIn( "btn-disabled", portal.find_element(By.CSS_SELECTOR, ".btn-equip").get_attribute("class"), ) ) self.assertIn("Equipped", self.browser.find_element(By.ID, "id_mini_tooltip_portal").text) game_kit = self.browser.find_element(By.ID, "id_game_kit") self.assertNotEqual(game_kit.get_attribute("data-equipped-deck-id"), "") # ------------------------------------------------------------------ # # Test 6 — new user's Game Kit shows only the default Earthman deck # # ------------------------------------------------------------------ # def test_new_user_game_kit_shows_only_earthman_deck(self): """A fresh user's game kit contains only the Earthman deck card; the Fiorentine deck is not visible because it has not been unlocked.""" newcomer = User.objects.create(email="newcomer@deck.io") newcomer.unlocked_decks.add(self.earthman) self.create_pre_authenticated_session("newcomer@deck.io") self.browser.get(self.live_server_url + "/gameboard/") self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) deck_cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_game_kit .deck-variant") self.assertEqual(len(deck_cards), 1) self.browser.find_element(By.ID, "id_kit_earthman_deck") rws_cards = self.browser.find_elements(By.ID, "id_kit_tarot_deck") self.assertEqual(len(rws_cards), 0) class GameKitPageTest(FunctionalTest): """ User navigates from gameboard to the dedicated game-kit page. The page shows four rows: trinkets, tokens, card decks, dice placeholder. Clicking a deck card opens a tarot fan modal with coverflow navigation. """ def setUp(self): super().setUp() from apps.epic.models import TarotCard 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"}, ) for slug, name in [ ("gk-trinkets", "Trinkets"), ("gk-tokens", "Tokens"), ("gk-decks", "Card Decks"), ("gk-dice", "Dice Sets"), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "grid_cols": 3, "grid_rows": 3, "context": "game-kit"}, ) self.earthman, _ = DeckVariant.objects.get_or_create( slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) # Seed 10 cards — enough to demonstrate full 7-card coverflow for i in range(10): TarotCard.objects.get_or_create( deck_variant=self.earthman, slug=f"gkp-card-{i}", defaults={"name": f"Card {i}", "arcana": "MAJOR", "number": i}, ) # Create user after decks so signal sets equipped_deck + unlocked_decks self.gamer = User.objects.create(email="gamer@kit.io") self.gamer.refresh_from_db() self.create_pre_authenticated_session("gamer@kit.io") # ------------------------------------------------------------------ # # Test 7 — gameboard Game Kit heading links to dedicated page # # ------------------------------------------------------------------ # def test_gameboard_game_kit_heading_links_to_game_kit_page(self): self.browser.get(self.live_server_url + "/gameboard/") link = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_game_kit h2 a") ) link.click() self.wait_for(lambda: self.assertIn("/gameboard/game-kit/", self.browser.current_url)) # ------------------------------------------------------------------ # # Test 8 — game-kit page shows four rows # # ------------------------------------------------------------------ # def test_game_kit_page_shows_four_rows(self): self.browser.get(self.live_server_url + "/gameboard/game-kit/") self.wait_for(lambda: self.browser.find_element(By.ID, "id_gk_trinkets")) self.browser.find_element(By.ID, "id_gk_tokens") self.browser.find_element(By.ID, "id_gk_decks") self.browser.find_element(By.ID, "id_gk_dice") # ------------------------------------------------------------------ # # Test 9 — clicking a deck card opens the tarot fan modal # # ------------------------------------------------------------------ # def test_clicking_deck_opens_tarot_fan_modal(self): self.browser.get(self.live_server_url + "/gameboard/game-kit/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") ).click() dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog") self.wait_for(lambda: self.assertTrue(dialog.is_displayed())) # ------------------------------------------------------------------ # # Test 10 — fan shows active center card plus receding cards # # ------------------------------------------------------------------ # def test_fan_shows_active_card_and_receding_cards(self): self.browser.get(self.live_server_url + "/gameboard/game-kit/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") ).click() self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")) visible = self.browser.find_elements( By.CSS_SELECTOR, "#id_fan_content .fan-card:not([style*='display: none'])" ) self.assertGreater(len(visible), 1) # ------------------------------------------------------------------ # # Test 11 — clicking outside the modal closes it # # ------------------------------------------------------------------ # def test_pressing_escape_closes_fan_modal(self): from selenium.webdriver.common.keys import Keys self.browser.get(self.live_server_url + "/gameboard/game-kit/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") ).click() dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog") self.wait_for(lambda: self.assertTrue(dialog.is_displayed())) dialog.send_keys(Keys.ESCAPE) self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))