Files
python-tdd/src/functional_tests/test_game_room_tray.py
Disco DeDisco f9c05a3eba
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
functional_tests + CI: rename pass + structural consolidations + parallel test-FTs split — every FT file now starts with one of 6 prefixes (test_admin_* / test_bill_* / test_core_* / test_dash_* / test_game_room_* / test_trinket_*) plus the 4 page-roots test_billboard / test_dashboard / test_gameboard / test_jasmine, so the partition is unambiguous and stable for tooling (the previous mix of test_applet_*, test_room_*, test_component_*, ad-hoc names had no consistent grouping); session-side: merged test_gatekeeper_bud_btn.py into test_bud_btn.py (then user renamed to test_core_bud_btn.py) — both files drove the same #id_bud_btn UI in two contexts (post-share + gatekeeper invite) and shared the bud-btn.js skeleton, so consolidation was overdue; split test_component_cards_tarot.py into test_admin_tarot.py (just TarotAdminTest, sitting next to test_admin / test_admin_post_readonly) + 3 classes (TarotDeckTest / GameKitDeckSelectionTest / GameKitPageTest) appended to test_game_room_tray.py; updated stale test_bud_btn.py references in the test_core_bud_btn.py docstring + test_admin_post_readonly.py comment to point at the new filename; user-driven renames (22 files): test_applet_my_notes/posts → test_bill_my_*, test_applet_new_post[_line_validation] → test_bill_new_post[_line_validation], test_applet_my_sky → test_dash_my_sky, test_applet_palette → test_dash_palette, test_wallet → test_dash_wallet, test_login → test_core_login, test_navbar → test_core_navbar, test_sharing → test_core_sharing, test_layout_and_styling → test_core_styling, test_my_buds → test_bill_my_buds, test_bud_btn → test_core_bud_btn, test_deck_contribution → test_game_room_deck_contrib, test_game_invite → test_game_room_invite, test_room_gatekeeper → test_game_room_gatekeeper, test_room_role_select → test_game_room_select_role, test_room_sea_select → test_game_room_select_sea, test_room_sig_select → test_game_room_select_sig, test_room_sky_select → test_game_room_select_sky, test_room_tray → test_game_room_tray, test_component_tray_tooltip → test_game_room_tray_tooltip; the post_page.py / room_page.py helper modules from the May-12 sprint absorbed the cross-file FT imports that would otherwise have cascade-broken on these renames
.woodpecker/main.yaml — CI test-FTs step splits into parallel siblings test-FTs-non-room (22 files via `ls functional_tests/test_*.py | grep -v 'test_game_room_'`) + test-FTs-room (9 files via `ls functional_tests/test_game_room_*.py`); room cluster is the heaviest (~70% of the pre-split ~40-min wall-clock) and now runs concurrently w. the rest instead of in series; DAG explicit via depends_on on every step (Woodpecker mixes default-sequential w. depends_on awkwardly, so each step pins its prerequisite); collectstatic stays in test-two-browser-FTs only — the shared workspace propagates assets to both parallel FT steps, no race + no duplication; screendumps + build-and-push fan back in (depends_on both parallel steps); deploy-staging + deploy-prod depend on build-and-push

smoke-import: 31/31 FT modules green after the rename pass

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:06:25 -04:00

755 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 T1T5 (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.fiorentine, _ = DeckVariant.objects.get_or_create(
slug="fiorentine-minchiate",
defaults={"name": "Fiorentine Minchiate", "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.fiorentine)
self.gamer.equipped_deck = self.fiorentine
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 Fiorentine Minchiate deck ─────────────────────────
fiorentine_el = self.browser.find_element(By.ID, "id_kit_fiorentine_deck")
self.browser.execute_script(
"arguments[0].scrollIntoView({block: 'center'})", fiorentine_el
)
ActionChains(self.browser).move_to_element(fiorentine_el).perform()
self.wait_for(
lambda: self.assertIn(
"Fiorentine",
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" — Fiorentine 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")
fiorentine_cards = self.browser.find_elements(By.ID, "id_kit_fiorentine_deck")
self.assertEqual(len(fiorentine_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()))