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
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

.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>
This commit is contained in:
Disco DeDisco
2026-05-12 20:06:25 -04:00
parent af1a90e76b
commit f9c05a3eba
27 changed files with 713 additions and 625 deletions

View File

@@ -0,0 +1,754 @@
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()))