Files
python-tdd/src/functional_tests/test_room_sig_select.py
Disco DeDisco 759ce8d3e4
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
fix CI FT regressions: deck contribution, ROLE SELECT no-deck guard, sig qualifiers, Carte Blanche multi-slot
- test_deck_contribution: get_or_create _equip_earthman + unlocked_decks.add; slot_number=2 on
  _setup_in_use_deck seat; navigate to /gameboard/ (not gate — game-kit panel absent there);
  drop #id_kit_card_deck click ({% empty %} placeholder; deck renders in loop when present);
  use textContent for CSS-hidden tooltip; drop stale .deck-micro-status assertion (now mini-portal)
- ROLE SELECT FTs (RoleSelectTest + RoleSelectTrayTest): equip Earthman deck for active-slot
  user in each test that opens the fan — fixes no-deck JS guard blocking #id_role_select
- test_room_sig_select: seed The Nomad/Schizo w. correct Earthman slugs/names + Enlightened/
  Engraven qualifiers; grant super-nomad + super-schizo Notes to all gamers so Major Arcana
  appear in overlay; seed Middle Arcana w. Elevated/Graven qualifiers; rename test methods
- test_game_kit: drop stale assertIn("active", text) — availability moved to In-Use mini-portal
- Carte Blanche: CB stays equipped after multi-slot deposit (revert drop_token unequip);
  select_role existing-seat query gains order_by("slot_number") for deterministic primary seat;
  multi-slot FT: kit bag shows placeholder after first deposit (CB unequipped); cold-feet
  verifies DON via hover→portal; re-equip via portal DON before re-deposit; new
  test_carte_in_use_game_kit_shows_room_attribution checks Game Kit tooltip after deposit

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:29:51 -04:00

809 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import os
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.drama.models import Note
from apps.epic.models import DeckVariant, Room, TableSeat, TarotCard
from apps.lyric.models import User
from .test_room_role_select import _fill_room_via_orm
# ── Significator Selection ────────────────────────────────────────────────────
#
# After all 6 roles are revealed the room enters SIG_SELECT. Two parallel
# 18-card overlays appear (levity: PC/NC/SC; gravity: BC/EC/AC). Each polarity
# group picks simultaneously — no sequential turn order.
#
# ─────────────────────────────────────────────────────────────────────────────
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
def _assign_all_roles(room, role_order=None):
"""Assign roles to all slots, reveal them, and advance to SIG_SELECT.
Also ensures all gamers have an equipped_deck (required for sig_deck_cards)."""
if role_order is None:
role_order = SIG_SEAT_ORDER[:]
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
# Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs).
# _sig_unique_cards() filters arcana=MIDDLE, suits BRANDS/CROWNS/BLADES/GRAILS (Earthman).
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
for number in (11, 12, 13, 14):
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}",
"levity_qualifier": "Elevated",
"gravity_qualifier": "Graven"},
)
# Numbers 01 are the sig deck's Major Arcana (unlocked via Note).
# Seed them with correct Earthman names and qualifiers, then unlock for all gamers.
from django.utils import timezone
for number, name, slug in [
(0, "The Nomad", "the-nomad"),
(1, "The Schizo", "the-schizo"),
]:
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=slug,
defaults={"arcana": "MAJOR", "number": number, "name": name,
"levity_qualifier": "Enlightened",
"gravity_qualifier": "Engraven"},
)
for slot in room.gate_slots.order_by("slot_number"):
if slot.gamer:
Note.objects.get_or_create(
user=slot.gamer, slug="super-nomad",
defaults={"earned_at": timezone.now()},
)
Note.objects.get_or_create(
user=slot.gamer, slug="super-schizo",
defaults={"earned_at": timezone.now()},
)
for slot in room.gate_slots.order_by("slot_number"):
if slot.gamer and not slot.gamer.equipped_deck:
slot.gamer.equipped_deck = earthman
slot.gamer.save(update_fields=["equipped_deck"])
TableSeat.objects.update_or_create(
room=room,
slot_number=slot.slot_number,
defaults={
"gamer": slot.gamer,
"role": role_order[slot.slot_number - 1],
"role_revealed": True,
},
)
room.table_status = Room.SIG_SELECT
room.save()
class SigSelectTest(FunctionalTest):
"""Significator Selection — non-WebSocket tests."""
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 S1 — Seats reorder to canonical role sequence at SIG_SELECT #
# ------------------------------------------------------------------ #
def test_seats_display_in_pc_nc_ec_sc_ac_bc_order_after_reveal(self):
"""Slots were filled in arbitrary token-drop order; after roles are
revealed the seat portraits must appear in PC→NC→EC→SC→AC→BC order."""
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Seat Order Test", owner=founder)
# Assign roles in reverse of canonical order so the reordering is visible
_fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room, role_order=["BC", "AC", "SC", "EC", "NC", "PC"])
self.create_pre_authenticated_session("founder@test.io")
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sig_deck"))
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat[data-role]")
self.assertEqual(len(seats), 6)
roles_in_order = [s.get_attribute("data-role") for s in seats]
self.assertEqual(roles_in_order, SIG_SEAT_ORDER)
@tag("channels")
class SigSelectChannelsTest(ChannelsFunctionalTest):
"""Significator Selection — WebSocket tests."""
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_browser2(self, email):
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
def _setup_sig_select_room(self):
"""Create a full SIG_SELECT room; return (room, [user_pc, user_nc, ...])."""
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=emails[0])
room = Room.objects.create(name="Cursor Colour Test", owner=founder)
gamers = _fill_room_via_orm(room, emails)
_assign_all_roles(room)
return room, gamers
# ── SC1: NC hover → PC sees mid cursor active, coloured --priYl ────────── #
@tag('channels')
def test_nc_hover_activates_mid_cursor_in_pc_browser(self):
"""
When NC (levity mid) hovers a card, PC (levity left) must see the
--mid cursor become active, coloured --priYl (rgb 255 207 52).
Verifies: WS broadcast pipeline + JS applyHover + CSS role colouring.
"""
room, gamers = self._setup_sig_select_room()
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
# ── Browser 1: PC (founder) ───────────────────────────────────────────
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
browser2 = self._make_browser2("amigo@test.io")
try:
browser2.get(room_url)
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
# Grab the first card ID visible in browser2's deck
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
card_id = first_card.get_attribute("data-card-id")
# Hover over it — triggers sendHover() → WS broadcast
from selenium.webdriver.common.action_chains import ActionChains
ActionChains(browser2).move_to_element(first_card).perform()
# ── Browser 1 should see --mid cursor go active (anchor carries class) ─
mid_cursor_sel = f'.sig-card[data-card-id="{card_id}"] .sig-cursor--mid'
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, mid_cursor_sel + ".active"
)
)
# CSS colour check: portal float has data-role="NC" → --priYl = 255, 207, 52
portal_sel = '.sig-cursor-float[data-role="NC"]'
portal_cursor = self.browser.find_element(By.CSS_SELECTOR, portal_sel)
color = self.browser.execute_script(
"return window.getComputedStyle(arguments[0]).color",
portal_cursor,
)
self.assertEqual(color, "rgb(255, 207, 52)", f"Expected --priYl colour for NC cursor, got {color}")
# ── Mouse-off: anchor class removed, portal float gone ────────────
ActionChains(browser2).move_to_element(
browser2.find_element(By.CSS_SELECTOR, ".sig-stage")
).perform()
self.wait_for(
lambda: not self.browser.find_elements(
By.CSS_SELECTOR, mid_cursor_sel + ".active"
)
)
finally:
browser2.quit()
# ── SC2: NC reserves → PC sees card border coloured --priYl ──────────── #
@tag('channels')
def test_nc_reservation_glows_priYl_in_pc_browser(self):
"""
When NC (levity mid) clicks OK on a card, PC must see that card's border
coloured --priYl (rgb 255 207 52) via the data-reserved-by CSS selector.
Verifies: sig_reserve view → WS broadcast → applyReservation → CSS glow.
"""
room, gamers = self._setup_sig_select_room()
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
# ── Browser 1: PC (founder) ───────────────────────────────────────────
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
browser2 = self._make_browser2("amigo@test.io")
try:
browser2.get(room_url)
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
# Get first card in B2's deck
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
card_id = first_card.get_attribute("data-card-id")
# Click card body → .sig-focused → OK button appears
from selenium.webdriver.common.action_chains import ActionChains
ActionChains(browser2).move_to_element(first_card).perform()
first_card.click()
ok_btn = self.wait_for(
lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-focused .sig-ok-btn")
)
ok_btn.click()
# ── B1 should see the card's border turn --priYl ──────────────────
reserved_card_sel = f'.sig-card[data-card-id="{card_id}"]'
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, reserved_card_sel + '[data-reserved-by="NC"]'
)
)
reserved_card = self.browser.find_element(By.CSS_SELECTOR, reserved_card_sel)
box_shadow = self.browser.execute_script(
"return window.getComputedStyle(arguments[0]).boxShadow",
reserved_card,
)
self.assertIn(
"255, 207, 52", box_shadow,
f"Expected --priYl (255,207,52) in box-shadow for NC reservation, got {box_shadow}",
)
finally:
browser2.quit()
# ── Polarity theming: qualifier text + no correspondence ─────────────────────
class SigSelectThemeTest(FunctionalTest):
"""Polarity-qualifier display (Graven/Leavened) and correspondence suppression.
No WebSocket needed — stage updates are local; uses plain FunctionalTest."""
EMAILS = [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
]
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
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 _setup_sig_room(self):
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
room = Room.objects.create(name="Theme Test", owner=founder)
_fill_room_via_orm(room, self.EMAILS)
_assign_all_roles(room)
return room
def _hover_card(self, css):
from selenium.webdriver.common.action_chains import ActionChains
card = self.browser.find_element(By.CSS_SELECTOR, css)
ActionChains(self.browser).move_to_element(card).perform()
return card
# ── ST1: Levity (Leavened) qualifier ──────────────────────────────────── #
def test_levity_non_major_card_shows_elevated_above(self):
"""Hovering a non-major card in the levity overlay shows 'Elevated' in
qualifier-above and nothing in qualifier-below."""
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io") # PC = levity
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
above = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
)
self.assertEqual(above.text, "Elevated")
below = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
self.assertEqual(below.text, "")
def test_levity_major_card_shows_enlightened_below(self):
"""Hovering a major arcana card in the levity overlay shows 'Enlightened' in
qualifier-below and nothing in qualifier-above."""
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io") # PC = levity
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._hover_card('.sig-card[data-arcana="Major Arcana"]')
below = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
)
self.assertEqual(below.text, "Enlightened")
above = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
self.assertEqual(above.text, "")
# ── ST2: Gravity (Graven) qualifier ───────────────────────────────────── #
def test_gravity_non_major_card_shows_graven_above(self):
"""EC (bud) sees the gravity overlay; hovering a non-major card shows 'Graven'."""
room = self._setup_sig_room()
self.create_pre_authenticated_session("bud@test.io") # EC = gravity
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
above = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
)
self.assertEqual(above.text, "Graven")
# ── ST3: Correspondence not shown ─────────────────────────────────────── #
def test_correspondence_not_shown_in_sig_select(self):
"""The Minchiate-equivalence field must always be blank on the stage card."""
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
# Hover any card — correspondence should remain empty regardless
self._hover_card(".sig-card")
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sig-stage-card"
))
corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence")
self.assertEqual(corr.text, "")
# ── TAKE SIG / WAIT NVM — ready gate ──────────────────────────────────────────
#
# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card
# stage preview once a gamer has clicked OK on a card (SigReservation exists).
# Clicking it sets the gamer's status to ready and changes the btn to WAIT NVM.
# WAIT NVM cancels the ready status and reverts back to TAKE SIG.
#
# When all three gamers in a polarity WS room are ready, a 12-second countdown
# starts. Any WAIT NVM during the countdown cancels it; the saved remaining time
# is resumed when all three are ready again. When the countdown completes
# (client POSTs sig_confirm) the polarity group returns to the table hex.
# When both polarity groups have confirmed, PICK SKY btn appears in the hex
# center for all six gamers.
#
# ─────────────────────────────────────────────────────────────────────────────
class SigReadyGateTest(FunctionalTest):
"""Single-browser tests for TAKE SIG / WAIT NVM btn."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
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 _setup_sig_room(self):
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=emails[0])
room = Room.objects.create(name="Ready Gate Test", owner=founder)
_fill_room_via_orm(room, emails)
_assign_all_roles(room)
return room
def _click_ok_on_any_card(self):
"""Click the first sig card to stage it, then click OK."""
card = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card")
)
card.click()
ok_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-ok-btn")
)
ok_btn.click()
# ── SRG1: TAKE SIG btn not visible before OK ──────────────────────── #
def test_take_sig_btn_not_visible_before_ok_click(self):
"""TAKE SIG must be absent until the gamer has OK'd a card."""
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
take_sig_btns = self.browser.find_elements(By.ID, "id_take_sig_btn")
self.assertEqual(len(take_sig_btns), 0)
# ── SRG2: TAKE SIG btn appears after OK ──────────────────────────── #
def test_take_sig_btn_appears_after_ok_click(self):
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._click_ok_on_any_card()
take_sig_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
)
self.assertIn("TAKE SIG", take_sig_btn.text.upper())
# ── SRG3: TAKE SIG → WAIT NVM ─────────────────────────────────────── #
def test_take_sig_btn_becomes_wait_no_after_click(self):
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._click_ok_on_any_card()
take_sig_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
)
take_sig_btn.click()
wait_no_btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
)
self.assertIn("WAIT NVM", wait_no_btn.text.upper())
# WAIT NVM pulses a --terOr glow: btn-cancel class appears within one tick
self.wait_for(
lambda: "btn-cancel" in self.browser.find_element(
By.ID, "id_take_sig_btn"
).get_attribute("class")
)
# ── SRG4: WAIT NVM → TAKE SIG ─────────────────────────────────────── #
def test_wait_no_reverts_to_take_sig(self):
room = self._setup_sig_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
self._click_ok_on_any_card()
btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
)
btn.click() # → WAIT NVM
self.wait_for(lambda: "WAIT NVM" in self.browser.find_element(
By.ID, "id_take_sig_btn").text.upper()
)
btn = self.browser.find_element(By.ID, "id_take_sig_btn")
btn.click() # → TAKE SIG again
self.wait_for(
lambda: self.assertIn(
"TAKE SIG",
self.browser.find_element(By.ID, "id_take_sig_btn").text.upper(),
)
)
@tag("channels")
class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
"""Multi-browser WebSocket tests for the polarity-room countdown and PICK SKY."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
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_browser_for(self, email):
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.set_window_size(800, 1200)
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
def _ok_card_in_browser(self, b):
"""Reserve any available sig-card, then JS-click its OK button to trigger
the page's applyReservation() and reveal #id_take_sig_btn.
Iterates through cards until one succeeds — multiple browsers in the same
polarity group would otherwise all try to reserve the same first card and
get 409 conflicts."""
self.wait_for(
lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b
)
result = b.execute_async_script("""
var cb = arguments[arguments.length - 1];
var overlay = document.querySelector('.sig-overlay');
var cards = document.querySelectorAll('.sig-card');
if (!overlay || !cards.length) { cb({error: 'no overlay or cards'}); return; }
var reserveUrl = overlay.dataset.reserveUrl;
var csrfM = document.cookie.match(/csrftoken=([^;]+)/);
var csrf = csrfM ? csrfM[1] : '';
function tryCard(idx) {
if (idx >= cards.length) { cb({error: 'all cards taken'}); return; }
var cardId = cards[idx].dataset.cardId;
fetch(reserveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrf,
},
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
}).then(function (res) {
if (res.status === 409) { tryCard(idx + 1); return; }
cb({status: res.status, ok: res.ok, cardId: cardId});
}).catch(function (e) { cb({error: e.message}); });
}
tryCard(0);
""")
if not (result and result.get('ok')):
raise AssertionError(f"sig_reserve fetch failed: {result}")
# Fetch confirmed 200 — JS-click the *correct* card's OK button so
# applyReservation() runs in page context and reveals #id_take_sig_btn.
# (Idempotent re-reserve of the same card → 200, safe.)
card_id = result['cardId']
ok_btn = b.find_element(
By.CSS_SELECTOR, f'.sig-card[data-card-id="{card_id}"] .sig-ok-btn'
)
b.execute_script("arguments[0].click()", ok_btn)
def _setup_sig_select_room(self):
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=emails[0])
room = Room.objects.create(name="Countdown Test", owner=founder)
_fill_room_via_orm(room, emails)
_assign_all_roles(room)
return room, emails
# ── SRG5: countdown appears when all three polarity ready ─────────── #
@tag("channels")
def test_countdown_element_appears_when_all_three_levity_gamers_ready(self):
"""When PC, NC, and SC each click TAKE SIG the countdown becomes visible."""
room, emails = self._setup_sig_select_room()
levity_emails = [emails[0], emails[1], emails[3]] # PC, NC, SC
browsers = []
try:
for email in levity_emails:
b = self._make_browser_for(email)
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b)
# Each levity gamer OK's a card then clicks TAKE SIG
for b in browsers:
self._ok_card_in_browser(b)
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
)
b.find_element(By.ID, "id_take_sig_btn").click()
# All three browsers should now see the countdown button (numeral text)
for b in browsers:
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(),
browser=b,
)
finally:
for b in browsers:
b.quit()
# ── SRG6: countdown disappears when WAIT NVM clicked ──────────────── #
@tag("channels")
def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self):
"""Any WAIT NVM during the countdown cancels it for all three browsers."""
room, emails = self._setup_sig_select_room()
levity_emails = [emails[0], emails[1], emails[3]]
browsers = []
try:
for email in levity_emails:
b = self._make_browser_for(email)
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b)
# All go ready
for b in browsers:
self._ok_card_in_browser(b)
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
)
b.find_element(By.ID, "id_take_sig_btn").click()
# Confirm countdown started for all (button text is a numeral)
for b in browsers:
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(),
browser=b,
)
# PC clicks the countdown button to cancel
browsers[0].find_element(By.ID, "id_take_sig_btn").click()
# Countdown should cancel for all three (button back to WAIT NVM)
for b in browsers:
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn").text == "WAIT NVM",
browser=b,
)
finally:
for b in browsers:
b.quit()
# ── SRG7: PICK SKY btn appears after both polarity groups confirm ─── #
@tag("channels")
def test_pick_sky_btn_appears_in_hex_after_both_groups_confirm(self):
"""Once both levity and gravity countdowns complete, all six browsers
see the PICK SKY btn in the table hex center."""
# This test drives the full flow end-to-end but uses ORM shortcuts
# to set all-ready state for one polarity, letting the other complete
# via the UI, to keep execution time manageable.
room, emails = self._setup_sig_select_room()
# Pre-confirm gravity via ORM: set significators on EC/AC/BC seats
from apps.epic.models import TarotCard, DeckVariant
earthman = DeckVariant.objects.get(slug="earthman")
grav_roles = ["EC", "AC", "BC"]
grav_suits = ["GRAILS", "BLADES", "CROWNS"]
for role, suit in zip(grav_roles, grav_suits):
card = TarotCard.objects.get(
deck_variant=earthman, arcana="MIDDLE", suit=suit, number=11
)
seat = room.table_seats.get(role=role)
seat.significator = card
seat.save()
levity_emails = [emails[0], emails[1], emails[3]]
browsers = []
try:
for email in levity_emails:
b = self._make_browser_for(email)
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b)
# All levity gamers OK and TAKE SIG
for b in browsers:
self._ok_card_in_browser(b)
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
)
b.find_element(By.ID, "id_take_sig_btn").click()
# Wait for countdown to expire or be confirmed; PICK SKY appears in hex
# countdown is 12 s so use wait_for_slow (MAX_WAIT=10 is not enough)
for b in browsers:
self.wait_for_slow(
lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b
)
finally:
for b in browsers:
b.quit()
# ── SRG8: first-done group sees waiting message ───────────────────── #
@tag("channels")
def test_first_done_polarity_sees_other_group_settling_message(self):
"""After levity confirms but gravity hasn't yet, levity gamers see
'Gravity settling . . .' on the dormant hex."""
room, emails = self._setup_sig_select_room()
levity_emails = [emails[0], emails[1], emails[3]]
browsers = []
try:
for email in levity_emails:
b = self._make_browser_for(email)
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
browsers.append(b)
for b in browsers:
self._ok_card_in_browser(b)
self.wait_for(
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
)
b.find_element(By.ID, "id_take_sig_btn").click()
# Wait for levity confirm → hex revealed, waiting message visible
# (countdown is 12 s, so wait_for's 10 s MAX_WAIT is not enough)
for b in browsers:
self.wait_for_slow(
lambda: "settling" in b.find_element(
By.ID, "id_hex_waiting_msg"
).text.lower(),
browser=b,
)
finally:
for b in browsers:
b.quit()
# ── SKY OVERLAY (natal wheel) — DEFERRED / PENDING PYSWISS ──────────────────
#
# These FTs outline the sky overlay behavior but are left as stubs.
# The sky overlay will be built after the PySwiss microservice (step 18)
# and the D3 natal wheel implementation. A prototype already exists and
# will be reviewed before these tests are filled in.
#
# class PickSkyTrayFlowTest(FunctionalTest):
#
# def test_pick_sky_btn_opens_tray_with_sig_card_in_slot_2(self):
# """Clicking PICK SKY opens #id_tray; tray cell 2 shows the gamer's
# sig card icon (Blank.svg placeholder until card-specific icons land)."""
# ...
#
# def test_tray_close_dismisses_sig_overlay_and_reveals_hex(self):
# """After tray closes the sig select modal is gone and the table hex
# is visible again."""
# ...
#
# def test_sky_overlay_appears_over_hex_after_tray_closes(self):
# """The sky overlay (#id_sky_overlay) appears over the hex once the
# tray animation completes."""
# ...
#
# def test_sky_overlay_prompts_for_input_date(self):
# """The sky overlay contains a date input field for natal wheel
# calculation via the PySwiss microservice API."""
# ...
#
# def test_sky_overlay_renders_natal_wheel_for_given_date(self):
# """Submitting a date triggers a D3-drawn natal wheel (pyswisseph
# data). Each house/planet is individually navigable."""
# ...
#
# def test_sky_overlay_accessible_during_play_for_timeframe_changes(self):
# """During IN_GAME phase a gamer can reopen the sky overlay to change
# the timeframe or check aspects presiding over the current scene."""
# ...
#
# ─────────────────────────────────────────────────────────────────────────────