offloaded Significator FTs into FTs.test_room_sig_select; new sig-select.js imported into room.html; new apps.epic.consumers & .views, ITs to confirm functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Disco DeDisco
2026-03-25 11:03:53 -04:00
parent 0494710ce0
commit f0f419ff7e
7 changed files with 433 additions and 316 deletions

View File

@@ -25,3 +25,6 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def roles_revealed(self, event): async def roles_revealed(self, event):
await self.send_json(event) await self.send_json(event)
async def sig_selected(self, event):
await self.send_json(event)

View File

@@ -0,0 +1,96 @@
var SigSelect = (function () {
var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC'];
var sigDeck, selectUrl, userRole;
function getActiveRole() {
for (var i = 0; i < SIG_ORDER.length; i++) {
var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]');
if (seat && !seat.dataset.sigDone) return SIG_ORDER[i];
}
return null;
}
function isEligible() {
return !!(userRole && userRole === getActiveRole());
}
function getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function applySelection(cardId, role, deckType) {
// Remove only the specific pile copy (levity or gravity) of this card
var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]';
sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); });
// Mark this seat done, remove active
var seat = document.querySelector('.table-seat[data-role="' + role + '"]');
if (seat) {
seat.classList.remove('active');
seat.dataset.sigDone = '1';
}
// Advance active to next seat
var nextRole = getActiveRole();
if (nextRole) {
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]');
if (nextSeat) nextSeat.classList.add('active');
}
// Place a card placeholder in inventory
var invSlot = document.getElementById('id_inv_sig_card');
if (invSlot) {
var card = document.createElement('div');
card.className = 'card';
invSlot.appendChild(card);
}
}
function init() {
sigDeck = document.getElementById('id_sig_deck');
if (!sigDeck) return;
selectUrl = sigDeck.dataset.selectSigUrl;
userRole = sigDeck.dataset.userRole;
sigDeck.addEventListener('click', function (e) {
var card = e.target.closest('.sig-card');
if (!card) return;
if (!isEligible()) return;
var activeRole = getActiveRole();
var cardId = card.dataset.cardId;
var deckType = card.dataset.deck;
window.showGuard(card, 'Select this significator?', function () {
fetch(selectUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCsrf(),
},
body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType),
}).then(function (response) {
if (response.ok) {
applySelection(cardId, activeRole, deckType);
}
});
});
});
}
window.addEventListener('room:sig_selected', function (e) {
if (!sigDeck) return;
var cardId = String(e.detail.card_id);
var role = e.detail.role;
var deckType = e.detail.deck_type;
// Idempotent — skip if this copy already removed (local selector already did it)
if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return;
applySelection(cardId, role, deckType);
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());

View File

@@ -902,7 +902,7 @@ class SelectSigCardViewTest(TestCase):
def test_select_sig_notifies_ws(self): def test_select_sig_notifies_ws(self):
with patch("apps.epic.views._notify_sig_selected") as mock_notify: with patch("apps.epic.views._notify_sig_selected") as mock_notify:
self._post() self._post()
mock_notify.assert_called_once_with(self.room.id) mock_notify.assert_called_once()
def test_select_sig_requires_login(self): def test_select_sig_requires_login(self):
self.client.logout() self.client.logout()

View File

@@ -64,10 +64,10 @@ def _notify_role_select_start(room_id):
) )
def _notify_sig_selected(room_id): def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
async_to_sync(get_channel_layer().group_send)( async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}', f'room_{room_id}',
{'type': 'sig_selected'}, {'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
) )
@@ -194,7 +194,9 @@ def _role_select_context(room, user):
ctx["user_seat"] = user_seat ctx["user_seat"] = user_seat
ctx["partner_seat"] = partner_seat ctx["partner_seat"] = partner_seat
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number") ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
ctx["sig_cards"] = sig_deck_cards(room) raw_sig_cards = sig_deck_cards(room)
half = len(raw_sig_cards) // 2
ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]]
ctx["sig_seats"] = sig_seat_order(room) ctx["sig_seats"] = sig_seat_order(room)
ctx["sig_active_seat"] = active_sig_seat(room) ctx["sig_active_seat"] = active_sig_seat(room)
return ctx return ctx
@@ -500,7 +502,8 @@ def select_sig(request, room_id):
return HttpResponse(status=409) return HttpResponse(status=409)
active_seat.significator = card active_seat.significator = card
active_seat.save() active_seat.save()
_notify_sig_selected(room_id) deck_type = request.POST.get('deck_type', 'levity')
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
return HttpResponse(status=200) return HttpResponse(status=200)

View File

@@ -1,5 +1,4 @@
import os import os
import unittest
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.test import tag from django.test import tag
@@ -9,7 +8,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest, ChannelsFunctionalTest from .base import FunctionalTest, ChannelsFunctionalTest
from .management.commands.create_session import create_pre_authenticated_session from .management.commands.create_session import create_pre_authenticated_session
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.epic.models import DeckVariant, Room, GateSlot, TableSeat, TarotCard from apps.epic.models import Room, GateSlot, TableSeat
from apps.lyric.models import User from apps.lyric.models import User
@@ -684,309 +683,3 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
finally: finally:
self.browser2.quit() self.browser2.quit()
# ── Significator Selection ────────────────────────────────────────────────────
#
# After all 6 roles are revealed the room enters SIG_SELECT. A 36-card
# Significator deck appears at the table centre; gamers pick in seat order
# (PC → NC → EC → SC → AC → BC). Selected cards are removed from the shared
# pile in real time via WebSocket, exactly as role selection works.
#
# Deck composition (18 unique cards × 2 — one from levity, one from gravity):
# SC / AC (Shepherd / Alchemist) → M/J/Q/K of Swords & Cups (16 cards)
# PC / BC (Player / Builder) → M/J/Q/K of Wands & Pentacles (16 cards)
# NC / EC (Narrator / Economist) → The Schiz (0) + Chancellor (1) ( 4 cards)
#
# Levity pile: SC, PC, NC contributions. Gravity pile: AC, BC, EC contributions.
# Cards retain the contributor's deck card-back — up to 6 distinct backs active.
#
# ─────────────────────────────────────────────────────────────────────────────
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)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
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": "MINOR", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}"},
)
for number, name, slug in [
(0, "The Schiz", "the-schiz-em"),
(1, "Pope 1: Chancellor", "pope-1-chancellor-em"),
]:
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=slug,
defaults={"arcana": "MAJOR", "number": number, "name": name},
)
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 — Significator deck of 36 cards appears at table centre #
# ------------------------------------------------------------------ #
def test_sig_deck_appears_with_36_cards_after_all_roles_revealed(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Sig Deck 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",
])
_assign_all_roles(room)
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)
# Significator deck is visible at the table centre
sig_deck = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sig_deck")
)
self.assertTrue(sig_deck.is_displayed())
# It contains exactly 36 cards
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
self.assertEqual(len(cards), 36)
# ------------------------------------------------------------------ #
# Test S2 — 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)
# ------------------------------------------------------------------ #
# Test S3 — First seat (PC) can select a significator; deck shrinks #
# ------------------------------------------------------------------ #
@unittest.skip("requires sig-select.js — pending styling sprint")
def test_first_seat_pc_can_select_significator_and_deck_shrinks(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="PC Select Test", owner=founder)
# Founder is assigned PC (slot 1 → first in canonical order → active)
_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=["PC", "NC", "EC", "SC", "AC", "BC"])
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)
# 36-card sig deck is present and the founder's seat is active
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
)
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
)
)
# Click the first card in the significator deck to select it
first_card = self.browser.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)
first_card.click()
self.confirm_guard()
# Deck now has 35 cards — selected card removed
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
35,
)
)
# Founder's significator appears in their inventory
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_inv_sig_card .card"
)
)
# Active seat advances to NC
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
)
)
# ------------------------------------------------------------------ #
# Test S4 — Ineligible seat cannot interact with sig deck #
# ------------------------------------------------------------------ #
@unittest.skip("requires sig-select.js — pending styling sprint")
def test_non_active_seat_cannot_select_significator(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Ineligible Sig Test", owner=founder)
# Founder is NC (second in canonical order) — not first
_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=["NC", "PC", "EC", "SC", "AC", "BC"])
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"))
# Click a sig card — it must not trigger a selection (deck stays at 36)
self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card").click()
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
@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
# ------------------------------------------------------------------ #
# Test S5 — Selected sig card disappears for watching gamer (WS) #
# ------------------------------------------------------------------ #
@unittest.skip("requires sig-select.js — pending styling sprint")
def test_selected_sig_card_removed_from_deck_for_other_gamers(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
User.objects.get_or_create(email="watcher@test.io")
room = Room.objects.create(name="Sig WS 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",
])
# Founder is PC (active first); watcher is NC (second)
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# Watcher loads room, sees 36 cards
self.create_pre_authenticated_session("watcher@test.io")
self.browser.get(room_url)
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
# Founder picks a significator 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, ".table-seat.active[data-role='PC']"
))
self.browser2.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
).click()
self.confirm_guard(browser=self.browser2)
# Watcher's deck shrinks to 35 without a page reload
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)),
35,
)
)
# Active seat advances to NC in both browsers
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
self.wait_for(lambda: self.browser2.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
finally:
self.browser2.quit()

View File

@@ -0,0 +1,318 @@
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.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. A 36-card
# Significator deck appears at the table centre; gamers pick in seat order
# (PC → NC → EC → SC → AC → BC). Selected cards are removed from the shared
# pile in real time via WebSocket, exactly as role selection works.
#
# Deck composition (18 unique cards × 2 — one from levity, one from gravity):
# SC / AC (Shepherd / Alchemist) → M/J/Q/K of Swords & Cups (16 cards)
# PC / BC (Player / Builder) → M/J/Q/K of Wands & Pentacles (16 cards)
# NC / EC (Narrator / Economist) → The Schiz (0) + Chancellor (1) ( 4 cards)
#
# Levity pile: SC, PC, NC contributions. Gravity pile: AC, BC, EC contributions.
# Cards retain the contributor's deck card-back — up to 6 distinct backs active.
#
# ─────────────────────────────────────────────────────────────────────────────
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)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
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": "MINOR", "suit": suit, "number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}"},
)
for number, name, slug in [
(0, "The Schiz", "the-schiz-em"),
(1, "Pope 1: Chancellor", "pope-1-chancellor-em"),
]:
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=slug,
defaults={"arcana": "MAJOR", "number": number, "name": name},
)
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 — Significator deck of 36 cards appears at table centre #
# ------------------------------------------------------------------ #
def test_sig_deck_appears_with_36_cards_after_all_roles_revealed(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Sig Deck 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",
])
_assign_all_roles(room)
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)
# Significator deck is visible at the table centre
sig_deck = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sig_deck")
)
self.assertTrue(sig_deck.is_displayed())
# It contains exactly 36 cards
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
self.assertEqual(len(cards), 36)
# ------------------------------------------------------------------ #
# Test S2 — 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)
# ------------------------------------------------------------------ #
# Test S3 — First seat (PC) can select a significator; deck shrinks #
# ------------------------------------------------------------------ #
def test_first_seat_pc_can_select_significator_and_deck_shrinks(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="PC Select Test", owner=founder)
# Founder is assigned PC (slot 1 → first in canonical order → active)
_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=["PC", "NC", "EC", "SC", "AC", "BC"])
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)
# 36-card sig deck is present and the founder's seat is active
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
)
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
)
)
# Click the first card in the significator deck to select it
first_card = self.browser.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)
first_card.click()
self.confirm_guard()
# Deck now has 35 cards — one pile copy of the selected card removed
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
35,
)
)
# Founder's significator appears in their inventory
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_inv_sig_card .card"
)
)
# Active seat advances to NC
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
)
)
# ------------------------------------------------------------------ #
# Test S4 — Ineligible seat cannot interact with sig deck #
# ------------------------------------------------------------------ #
def test_non_active_seat_cannot_select_significator(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Ineligible Sig Test", owner=founder)
# Founder is NC (second in canonical order) — not first
_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=["NC", "PC", "EC", "SC", "AC", "BC"])
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"))
# Click a sig card — it must not trigger a selection (deck stays at 36)
self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card").click()
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
@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
# ------------------------------------------------------------------ #
# Test S5 — Selected sig card disappears for watching gamer (WS) #
# ------------------------------------------------------------------ #
def test_selected_sig_card_removed_from_deck_for_other_gamers(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
User.objects.get_or_create(email="watcher@test.io")
room = Room.objects.create(name="Sig WS 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",
])
# Founder is PC (active first); watcher is NC (second)
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# Watcher loads room, sees 36 cards
self.create_pre_authenticated_session("watcher@test.io")
self.browser.get(room_url)
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
# Founder picks a significator 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, ".table-seat.active[data-role='PC']"
))
self.browser2.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
).click()
self.confirm_guard(browser=self.browser2)
# Watcher's deck shrinks to 35 without a page reload
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)),
35,
)
)
# Active seat advances to NC in both browsers
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
self.wait_for(lambda: self.browser2.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
finally:
self.browser2.quit()

View File

@@ -46,6 +46,7 @@
{% endif %} {% endif %}
</div> </div>
<div id="id_inventory" class="room-inventory"> <div id="id_inventory" class="room-inventory">
<div id="id_inv_sig_card"></div>
<div id="id_inv_role_card"> <div id="id_inv_role_card">
{% if room.table_status == "ROLE_SELECT" %} {% if room.table_status == "ROLE_SELECT" %}
{% for seat in assigned_seats %} {% for seat in assigned_seats %}
@@ -73,9 +74,11 @@
</div> </div>
{% if room.table_status == "SIG_SELECT" and sig_cards %} {% if room.table_status == "SIG_SELECT" and sig_cards %}
<div id="id_sig_deck"> <div id="id_sig_deck"
{% for card in sig_cards %} data-select-sig-url="{% url 'epic:select_sig' room.id %}"
<div class="sig-card" data-card-id="{{ card.id }}">{{ card.name }}</div> data-user-role="{{ user_seat.role|default:'' }}">
{% for card, deck_type in sig_cards %}
<div class="sig-card {{ deck_type }}-deck" data-card-id="{{ card.id }}" data-deck="{{ deck_type }}">{{ card.name }}</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -91,4 +94,5 @@
<script src="{% static 'apps/epic/room.js' %}"></script> <script src="{% static 'apps/epic/room.js' %}"></script>
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script> <script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script> <script src="{% static 'apps/epic/role-select.js' %}"></script>
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}