The role-select.js no-deck guard (added with e512e94) shows a warning instead of
opening #id_role_select when data-equipped-deck is empty. Three RoleSelectChannelsTest
setups didn't equip a deck for the founder, so the card-stack click never opened the
modal and all three failed with NoSuchElementException on #id_role_select.
Only the founder needs equipping — other gamers' roles are ORM-assigned, no browser click.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
910 lines
38 KiB
Python
910 lines
38 KiB
Python
import os
|
||
import unittest
|
||
|
||
from django.conf import settings as django_settings
|
||
from django.test import tag
|
||
from selenium import webdriver
|
||
from selenium.webdriver.common.by import By
|
||
|
||
from .base import FunctionalTest, ChannelsFunctionalTest
|
||
from .management.commands.create_session import create_pre_authenticated_session
|
||
from apps.applets.models import Applet
|
||
from apps.epic.models import DeckVariant, Room, GateSlot, TableSeat
|
||
from apps.lyric.models import User
|
||
|
||
|
||
def _equip_earthman_deck(user):
|
||
"""Equip the Earthman DeckVariant so the role-select no-deck guard passes."""
|
||
deck = DeckVariant.objects.filter(name__icontains="Earthman").first()
|
||
if deck:
|
||
user.equipped_deck = deck
|
||
user.save(update_fields=["equipped_deck"])
|
||
|
||
|
||
def _fill_room_via_orm(room, emails):
|
||
"""Fill all 6 gate slots and set gate_status=OPEN. Returns list of gamers."""
|
||
gamers = []
|
||
for i, email in enumerate(emails, start=1):
|
||
gamer, _ = User.objects.get_or_create(email=email)
|
||
slot = room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
gamers.append(gamer)
|
||
room.gate_status = Room.OPEN
|
||
room.save()
|
||
return gamers
|
||
|
||
|
||
class RoleSelectTest(FunctionalTest):
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
Applet.objects.get_or_create(
|
||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||
)
|
||
Applet.objects.get_or_create(
|
||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 1 — PICK ROLES dismisses gatekeeper and reveals the table #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_pick_roles_dismisses_gatekeeper_and_reveals_table(self):
|
||
# 1. Founder logs in, creates room via UI, fills remaining slots via ORM
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(self.live_server_url + "/gameboard/")
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_new_game_name")
|
||
).send_keys("Dragon's Den")
|
||
self.browser.find_element(By.ID, "id_create_game_btn").click()
|
||
self.wait_for(
|
||
lambda: self.assertIn("/gate/", self.browser.current_url)
|
||
)
|
||
room_url = self.browser.current_url
|
||
room = Room.objects.get(name="Dragon's Den")
|
||
|
||
# Fill founder's slot via UI (slot 1)
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
|
||
).click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
|
||
).click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
|
||
)
|
||
|
||
# Fill slots 2–6 via ORM
|
||
emails = ["amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io"]
|
||
for i, email in enumerate(emails, start=2):
|
||
gamer, _ = User.objects.get_or_create(email=email)
|
||
slot = room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
room.gate_status = Room.OPEN
|
||
room.save()
|
||
|
||
# 2. Browser sees the PICK ROLES button (gate is now open)
|
||
self.browser.refresh()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn")
|
||
).click()
|
||
|
||
# 3. Gatekeeper overlay is gone
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-overlay")), 0
|
||
)
|
||
)
|
||
|
||
# 4. Table is visible and prominent
|
||
table = self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_game_table")
|
||
)
|
||
self.assertTrue(table.is_displayed())
|
||
|
||
# 5. Card stack is present in the table centre
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
)
|
||
|
||
# 6. Six seat portraits are visible around the table
|
||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||
self.assertEqual(len(seats), 6)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 2 — Card stack signals eligibility to each gamer #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_card_stack_glows_for_first_gamer_only(self):
|
||
# Two browsers: founder (slot 1, eligible) and friend (slot 2, not yet)
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
friend, _ = User.objects.get_or_create(email="friend@test.io")
|
||
room = Room.objects.create(name="Signal Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "friend@test.io",
|
||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
# Founder's browser
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
stack = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
)
|
||
self.assertIn("eligible", stack.get_attribute("data-state"))
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, ".card-stack .fa-ban")), 0
|
||
)
|
||
|
||
# Friend's browser
|
||
options2 = webdriver.FirefoxOptions()
|
||
if os.environ.get("HEADLESS"):
|
||
options2.add_argument("--headless")
|
||
self.browser2 = webdriver.Firefox(options=options2)
|
||
try:
|
||
self.browser2.get(self.live_server_url + "/404_no_such_url/")
|
||
from django.conf import settings
|
||
session_key = __import__(
|
||
"functional_tests.management.commands.create_session",
|
||
fromlist=["create_pre_authenticated_session"]
|
||
).create_pre_authenticated_session("friend@test.io")
|
||
self.browser2.add_cookie(dict(
|
||
name=settings.SESSION_COOKIE_NAME, value=session_key, path="/"
|
||
))
|
||
self.browser2.get(room_url)
|
||
stack2 = self.wait_for(
|
||
lambda: self.browser2.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
)
|
||
self.assertIn("ineligible", stack2.get_attribute("data-state"))
|
||
self.wait_for(
|
||
lambda: self.browser2.find_element(
|
||
By.CSS_SELECTOR, ".card-stack .fa-ban"
|
||
)
|
||
)
|
||
finally:
|
||
self.browser2.quit()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 3 — Active gamer fans cards, inspects, selects a role #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_active_gamer_fans_cards_and_selects_role(self):
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Fan Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# 1. Click the card stack
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
).click()
|
||
|
||
# 2. Role Select modal opens with 6 cards
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_role_select")
|
||
)
|
||
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
|
||
self.assertEqual(len(cards), 6)
|
||
|
||
# 3. Blur backdrop is present
|
||
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop")
|
||
|
||
# 4. Hover over first card — it flips to reveal front
|
||
from selenium.webdriver.common.action_chains import ActionChains
|
||
ActionChains(self.browser).move_to_element(cards[0]).perform()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_role_select .card.flipped"
|
||
)
|
||
)
|
||
|
||
# 5. Click first card to select it
|
||
cards[0].click()
|
||
self.confirm_guard()
|
||
|
||
# 6. Modal closes
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||
)
|
||
)
|
||
|
||
# 7. Card stack returns to table centre
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 3b — Chosen role absent from next gamer's fan #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_chosen_role_absent_from_next_gamer_fan(self):
|
||
from apps.epic.models import TableSeat
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
friend, _ = User.objects.get_or_create(email="friend@test.io")
|
||
room = Room.objects.create(name="Pool Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "friend@test.io",
|
||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
|
||
# Simulate pick_roles: create a TableSeat per filled slot
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
|
||
# Slot 1 (founder) has already chosen PC
|
||
TableSeat.objects.filter(room=room, slot_number=1).update(role="PC")
|
||
|
||
# Slot 2 (friend) is now the active gamer
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
self.create_pre_authenticated_session("friend@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# Card stack is eligible for slot 2
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
)
|
||
).click()
|
||
|
||
# Fan opens — only 5 cards (PC is taken)
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_role_select")
|
||
)
|
||
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
|
||
self.assertEqual(len(cards), 5)
|
||
|
||
# Specifically, no PC card in the fan
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, "#id_role_select .card[data-role='PC']"
|
||
)),
|
||
0,
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 3c — Card stack stays eligible after re-entering mid-session #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_card_stack_remains_eligible_after_re_entering_mid_selection(self):
|
||
"""A gamer holding multiple slots should still see an eligible card
|
||
stack when they re-enter the room after having already chosen a role
|
||
for their earlier slot."""
|
||
from apps.epic.models import TableSeat
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Re-entry Test", owner=founder)
|
||
# Founder holds slots 1 and 2; others fill the rest
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "founder@test.io",
|
||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
# Founder's first slot has already chosen PC
|
||
TableSeat.objects.filter(room=room, slot_number=1).update(role="PC")
|
||
|
||
# Founder re-enters the room (simulating a page reload / re-navigation)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# Card stack must be eligible — slot 2 (also founder's) is the active seat
|
||
stack = self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
)
|
||
self.assertEqual(stack.get_attribute("data-state"), "eligible")
|
||
|
||
# Fan shows 5 cards — PC already taken
|
||
stack.click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
|
||
self.assertEqual(len(cards), 5)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 4 — Click-away dismisses fan without selecting #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_click_away_dismisses_card_fan_without_selecting(self):
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Dismiss Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# Open the fan
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
).click()
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.ID, "id_role_select")
|
||
)
|
||
|
||
# Click the backdrop (outside the fan)
|
||
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
|
||
|
||
# Modal closes; stack still present
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||
)
|
||
)
|
||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 4b — Stack locks out immediately after selection (no WS) #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_card_stack_ineligible_immediately_after_selection(self):
|
||
"""After clicking a role card the stack must flip to
|
||
data-state='ineligible' straight away — before any WS turn_changed
|
||
event could arrive. This test runs without a Channels server so
|
||
no WS event will fire; the fix must be entirely client-side."""
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Lockout Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
)
|
||
).click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# No WS — only the JS fix can make this transition happen
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack"
|
||
).get_attribute("data-state"),
|
||
"ineligible",
|
||
)
|
||
)
|
||
|
||
def test_card_stack_cannot_be_reopened_after_selection(self):
|
||
"""Clicking the card stack immediately after picking a role must
|
||
not open a second fan — the listener must have been removed."""
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="No-reopen Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# Open fan, pick a card
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
)
|
||
).click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# Wait for fan to close (selectRole closes it synchronously)
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||
)
|
||
)
|
||
|
||
# Attempt to reopen — must not work
|
||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||
self.wait_for(
|
||
lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||
)
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 8a — Hex seats carry role labels during role select #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_seats_around_hex_have_role_labels(self):
|
||
"""During role select the 6 .table-seat elements carry data-role
|
||
attributes matching the fixed slot→role mapping (PC at slot 1, etc.)."""
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Seat Label Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||
)
|
||
for slot_number, role_label in expected.items():
|
||
seat = self.browser.find_element(
|
||
By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']"
|
||
)
|
||
self.assertEqual(seat.get_attribute("data-role"), role_label)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 8b — Hex seats show .fa-ban when empty #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_seats_show_ban_icon_when_empty(self):
|
||
"""All 6 seats carry .fa-ban before any role has been chosen."""
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Seat Ban Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||
)
|
||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||
self.assertEqual(len(seats), 6)
|
||
for seat in seats:
|
||
self.assertTrue(
|
||
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
||
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 8c — Hex seat gets .fa-circle-check after role selected #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_seat_gets_check_after_role_selected(self):
|
||
"""After confirming a role pick the corresponding hex seat should
|
||
show .fa-circle-check and lose .fa-ban."""
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
room = Room.objects.create(name="Seat Check Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
|
||
# Open fan, pick first card (SC — Shepherd), confirm guard
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
)
|
||
).click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# Wait for tray animation to complete
|
||
self.wait_for(
|
||
lambda: self.assertFalse(
|
||
self.browser.execute_script("return Tray.isOpen()"),
|
||
"Tray should close after arc-in sequence",
|
||
)
|
||
)
|
||
|
||
# The SC seat (slot 1) now shows check, no ban
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".table-seat[data-role='SC'] .fa-circle-check"
|
||
)
|
||
)
|
||
self.assertEqual(
|
||
len(self.browser.find_elements(
|
||
By.CSS_SELECTOR, ".table-seat[data-role='SC'] .fa-ban"
|
||
)),
|
||
0,
|
||
)
|
||
|
||
|
||
class RoleSelectTrayTest(FunctionalTest):
|
||
"""After confirming a role pick, the role card enters the tray grid and
|
||
the tray opens to reveal it.
|
||
|
||
Portrait — card lands at the topmost grid square (first child, row 1 col 1).
|
||
Landscape — card lands at the leftmost grid square (first child, row 1 col 1).
|
||
"""
|
||
|
||
EMAILS = [
|
||
"slot1@test.io", "slot2@test.io", "slot3@test.io",
|
||
"slot4@test.io", "slot5@test.io", "slot6@test.io",
|
||
]
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
Applet.objects.get_or_create(
|
||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||
)
|
||
Applet.objects.get_or_create(
|
||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||
)
|
||
|
||
def _make_room(self):
|
||
"""Room in ROLE_SELECT with all 6 seats created, slot 1 eligible."""
|
||
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||
room = Room.objects.create(name="Tray Card Test", owner=founder)
|
||
_fill_room_via_orm(room, self.EMAILS)
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number
|
||
)
|
||
return room
|
||
|
||
def _select_role(self):
|
||
"""Open the fan, pick the first card, confirm the guard dialog."""
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
)
|
||
).click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# T1 — Portrait: role card marks first cell; tray opens then closes #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_portrait_role_card_enters_topmost_grid_square(self):
|
||
"""Portrait: after confirming a role the first .tray-cell gets
|
||
.tray-role-card; the grid still has exactly 8 cells; and the tray
|
||
opens briefly then closes once the arc-in animation completes."""
|
||
self.browser.set_window_size(390, 844)
|
||
room = self._make_room()
|
||
self.create_pre_authenticated_session("slot1@test.io")
|
||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||
self._select_role()
|
||
|
||
# First cell receives the role card class.
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||
)
|
||
)
|
||
|
||
result = self.browser.execute_script("""
|
||
var grid = document.getElementById('id_tray_grid');
|
||
var card = grid.querySelector('.tray-role-card');
|
||
return {
|
||
isFirst: card !== null && card === grid.firstElementChild,
|
||
count: grid.children.length,
|
||
role: card ? card.dataset.role : null
|
||
};
|
||
""")
|
||
self.assertTrue(result["isFirst"], "Role card should be the first cell")
|
||
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
|
||
self.assertTrue(result["role"], "First cell should carry data-role")
|
||
|
||
# Tray closes after the animation sequence.
|
||
self.wait_for(
|
||
lambda: self.assertFalse(
|
||
self.browser.execute_script("return Tray.isOpen()"),
|
||
"Tray should close after the arc-in sequence"
|
||
)
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# T2 — Landscape: same contract in landscape #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
@tag('two-browser')
|
||
def test_landscape_role_card_enters_leftmost_grid_square(self):
|
||
"""Landscape: the first .tray-cell gets .tray-role-card; grid has
|
||
8 cells; tray opens then closes."""
|
||
self.browser.set_window_size(844, 390)
|
||
room = self._make_room()
|
||
self.create_pre_authenticated_session("slot1@test.io")
|
||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||
self._select_role()
|
||
|
||
self.wait_for(
|
||
lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||
)
|
||
)
|
||
|
||
result = self.browser.execute_script("""
|
||
var grid = document.getElementById('id_tray_grid');
|
||
var card = grid.querySelector('.tray-role-card');
|
||
return {
|
||
isFirst: card !== null && card === grid.firstElementChild,
|
||
count: grid.children.length
|
||
};
|
||
""")
|
||
self.assertTrue(result["isFirst"], "Role card should be the first cell")
|
||
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
|
||
|
||
self.wait_for(
|
||
lambda: self.assertFalse(
|
||
self.browser.execute_script("return Tray.isOpen()"),
|
||
"Tray should close after the arc-in sequence"
|
||
)
|
||
)
|
||
|
||
|
||
@tag('channels')
|
||
class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
Applet.objects.get_or_create(
|
||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||
)
|
||
Applet.objects.get_or_create(
|
||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||
)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 6 — Observer sees seat arc move via WebSocket #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_observer_sees_seat_arc_during_selection(self):
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
_equip_earthman_deck(founder)
|
||
User.objects.get_or_create(email="watcher@test.io")
|
||
room = Room.objects.create(name="Arc Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "watcher@test.io",
|
||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
# 1. Watcher (slot 2) loads the room
|
||
self.create_pre_authenticated_session("watcher@test.io")
|
||
self.browser.get(room_url)
|
||
self.wait_for(lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
|
||
))
|
||
|
||
# 2. Founder picks a role in second browser
|
||
self.browser2 = self._make_browser2("founder@test.io")
|
||
try:
|
||
self.browser2.get(room_url)
|
||
self.wait_for(lambda: self.browser2.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
))
|
||
self.browser2.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||
self.wait_for(lambda: self.browser2.find_element(By.ID, "id_role_select"))
|
||
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard(browser=self.browser2)
|
||
|
||
# 3. Watcher's turn arrives via WS — card-stack becomes eligible
|
||
self.wait_for(lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
))
|
||
finally:
|
||
self.browser2.quit()
|
||
|
||
def _make_browser2(self, email):
|
||
"""Spin up a second Firefox, authenticate email, return the browser."""
|
||
session_key = create_pre_authenticated_session(email)
|
||
options = webdriver.FirefoxOptions()
|
||
if os.environ.get("HEADLESS"):
|
||
options.add_argument("--headless")
|
||
b = webdriver.Firefox(options=options)
|
||
b.get(self.live_server_url + "/404_no_such_url/")
|
||
b.add_cookie(dict(
|
||
name=django_settings.SESSION_COOKIE_NAME,
|
||
value=session_key,
|
||
path="/",
|
||
))
|
||
return b
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 5 — Tray closes on turn advance (portrait) #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def _make_turn_test_room(self):
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
_equip_earthman_deck(founder)
|
||
User.objects.get_or_create(email="friend@test.io")
|
||
room = Room.objects.create(name="Turn Test", owner=founder)
|
||
_fill_room_via_orm(room, [
|
||
"founder@test.io", "friend@test.io",
|
||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
||
])
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||
)
|
||
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
|
||
def test_portrait_tray_closes_on_turn_advance(self):
|
||
"""Portrait: after selecting a role the tray opens and the role card lands
|
||
in the topmost grid square. When turn_changed arrives via WS, the tray
|
||
force-closes so the next player's card-stack is not obscured."""
|
||
self.browser.set_window_size(390, 844)
|
||
room_url = self._make_turn_test_room()
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
self.wait_for(lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
))
|
||
|
||
# Select a role — card lands in topmost grid square.
|
||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# Wait for fetch .then() — card must be first child of grid.
|
||
self.wait_for(lambda: self.assertTrue(self.browser.execute_script("""
|
||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||
return card !== null && card === card.parentElement.firstElementChild;
|
||
""")))
|
||
|
||
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||
self.wait_for(lambda: self.assertFalse(
|
||
self.browser.execute_script("return Tray.isOpen()"),
|
||
"Tray should be closed after turn advances"
|
||
))
|
||
|
||
def test_landscape_tray_closes_on_turn_advance(self):
|
||
"""Landscape: role card at leftmost grid square; tray closes when
|
||
turn_changed arrives via WS."""
|
||
self.browser.set_window_size(844, 390)
|
||
room_url = self._make_turn_test_room()
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
self.wait_for(lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
))
|
||
|
||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# Wait for fetch .then() — card must be first child of grid.
|
||
self.wait_for(lambda: self.assertTrue(self.browser.execute_script("""
|
||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||
return card !== null && card === card.parentElement.firstElementChild;
|
||
""")))
|
||
|
||
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||
self.wait_for(lambda: self.assertFalse(
|
||
self.browser.execute_script("return Tray.isOpen()"),
|
||
"Tray should be closed after turn advances"
|
||
))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Test 7 — PICK SIGS appears + card stack removed on last role #
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def test_pick_sigs_appears_and_card_stack_removed_on_last_role(self):
|
||
"""When the sixth and final role is confirmed, the all_roles_filled
|
||
WS event makes the PICK SIGS button visible and removes the card
|
||
stack from the DOM entirely."""
|
||
emails = [
|
||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||
]
|
||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||
_equip_earthman_deck(founder)
|
||
room = Room.objects.create(name="Last Role Test", owner=founder)
|
||
_fill_room_via_orm(room, emails)
|
||
room.table_status = Room.ROLE_SELECT
|
||
room.save()
|
||
# Pre-assign 5 roles (slots 2–6); founder (slot 1) is the final picker.
|
||
pre_assigned = {2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||
for slot in room.gate_slots.order_by("slot_number"):
|
||
TableSeat.objects.create(
|
||
room=room,
|
||
gamer=slot.gamer,
|
||
slot_number=slot.slot_number,
|
||
role=pre_assigned.get(slot.slot_number),
|
||
)
|
||
|
||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||
self.create_pre_authenticated_session("founder@test.io")
|
||
self.browser.get(room_url)
|
||
self.wait_for(lambda: self.browser.find_element(
|
||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||
))
|
||
|
||
# Founder picks the last remaining role (PC — the only card in the fan).
|
||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||
self.confirm_guard()
|
||
|
||
# PICK SIGS wrap must become visible via the all_roles_filled WS event.
|
||
self.wait_for(lambda: self.assertFalse(
|
||
self.browser.find_element(By.ID, "id_pick_sigs_wrap").get_attribute("style"),
|
||
))
|
||
# Card stack must be removed from the DOM entirely.
|
||
self.wait_for(lambda: self.assertEqual(
|
||
len(self.browser.find_elements(By.CSS_SELECTOR, ".card-stack")), 0,
|
||
))
|
||
|
||
|