755 lines
35 KiB
Python
755 lines
35 KiB
Python
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()))
|