demo'd old inventory area in room.html to make way for new content (hex table now centered in view); old test suite now targets Role card in #id_tray cells where appropriate, or skips Sig card select until aforementioned new feature deployed; new scripts & jasmine tests too; removed one irrelevant test case from apps.epic.tests.ITs.test_views.SelectRoleViewTest

This commit is contained in:
Disco DeDisco
2026-03-30 16:42:23 -04:00
parent 299a806862
commit 8b006be138
12 changed files with 553 additions and 374 deletions

View File

@@ -3,6 +3,11 @@ var RoleSelect = (function () {
// ahead of the fetch response doesn't get overridden by Tray.open().
var _turnChangedBeforeFetch = false;
// Set to true while placeCard animation is running. handleTurnChanged
// defers its work until the animation completes.
var _animationPending = false;
var _pendingTurnChange = null;
var ROLES = [
{ code: "PC", name: "Player", element: "Fire" },
{ code: "BC", name: "Builder", element: "Stone" },
@@ -27,18 +32,10 @@ var RoleSelect = (function () {
if (backdrop) backdrop.remove();
}
function selectRole(roleCode, cardEl) {
function selectRole(roleCode) {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
var invCard = cardEl.cloneNode(true);
invCard.classList.add("flipped");
// strip old event listeners from the clone by replacing with a clean copy
var clean = invCard.cloneNode(true);
closeFan();
var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.appendChild(clean);
// Immediately lock the stack — do not wait for WS turn_changed
var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack) {
@@ -60,24 +57,27 @@ var RoleSelect = (function () {
}).then(function (response) {
if (!response.ok) {
// Server rejected (role already taken) — undo optimistic update
if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean);
if (stack) {
stack.dataset.starterRoles = stack.dataset.starterRoles
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
}
openFan();
} else {
// Place role card in tray grid and open the tray
var grid = document.getElementById("id_tray_grid");
if (grid) {
var trayCard = document.createElement("div");
trayCard.className = "tray-cell tray-role-card";
trayCard.dataset.role = roleCode;
grid.insertBefore(trayCard, grid.firstChild);
}
// Only open if turn_changed hasn't already arrived and closed it.
if (typeof Tray !== "undefined" && !_turnChangedBeforeFetch) {
Tray.open();
// Always animate the role card into the tray, even if turn_changed
// already arrived. placeCard opens the tray, arcs the card in,
// then force-closes — so the user always sees their role card land.
// If turn_changed arrived before the fetch, handleTurnChanged already
// ran; _pendingTurnChange will be null and onComplete is a no-op.
if (typeof Tray !== "undefined") {
_animationPending = true;
Tray.placeCard(roleCode, function () {
_animationPending = false;
if (_pendingTurnChange) {
var ev = _pendingTurnChange;
_pendingTurnChange = null;
handleTurnChanged(ev);
}
});
}
}
});
@@ -135,7 +135,7 @@ var RoleSelect = (function () {
"Start round 1 as<br>" + role.name + " (" + role.code + ") …?",
function () { // confirm
card.classList.remove("guard-active");
selectRole(role.code, card);
selectRole(role.code);
},
function () { // dismiss (NVM / outside click)
card.classList.remove("guard-active");
@@ -165,9 +165,13 @@ var RoleSelect = (function () {
}
function handleTurnChanged(event) {
// If a placeCard animation is running, defer until it completes.
if (_animationPending) {
_pendingTurnChange = event;
return;
}
var active = String(event.detail.active_slot);
var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.innerHTML = "";
// Force-close tray instantly so it never obscures the next player's card-stack.
// Also set the race flag so the fetch .then() doesn't re-open if it arrives late.
@@ -220,8 +224,13 @@ var RoleSelect = (function () {
}
return {
openFan: openFan,
closeFan: closeFan,
setReload: function (fn) { _reload = fn; },
openFan: openFan,
closeFan: closeFan,
setReload: function (fn) { _reload = fn; },
// Testing hook — resets animation-pause state between Jasmine specs
_testReset: function () {
_animationPending = false;
_pendingTurnChange = null;
},
};
}());

View File

@@ -224,6 +224,37 @@ var Tray = (function () {
});
}
// _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete.
function _arcIn(cardEl, onComplete) {
cardEl.classList.add('arc-in');
cardEl.addEventListener('animationend', function handler() {
cardEl.removeEventListener('animationend', handler);
cardEl.classList.remove('arc-in');
if (onComplete) onComplete();
});
}
// placeCard(roleCode, onComplete) — mark the first tray cell with the role,
// open the tray, arc-in the cell, then force-close. Calls onComplete after.
// The grid always contains exactly 8 .tray-cell elements (from the template);
// the first one receives .tray-role-card and data-role instead of a new element
// being inserted, so the cell count never changes.
function placeCard(roleCode, onComplete) {
if (!_grid) { if (onComplete) onComplete(); return; }
var firstCell = _grid.querySelector('.tray-cell');
if (!firstCell) { if (onComplete) onComplete(); return; }
firstCell.classList.add('tray-role-card');
firstCell.dataset.role = roleCode;
firstCell.textContent = roleCode;
open();
_arcIn(firstCell, function () {
forceClose();
if (onComplete) onComplete();
});
}
function _startDrag(clientX, clientY) {
_dragHandled = false;
if (_wrap) _wrap.classList.add('tray-dragging');
@@ -428,6 +459,14 @@ var Tray = (function () {
_onBtnClick = null;
}
_cancelPendingHide();
// Clear any role-card state from tray cells (Jasmine afterEach)
if (_grid) {
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
el.classList.remove('tray-role-card', 'arc-in');
el.textContent = '';
delete el.dataset.role;
});
}
_wrap = null;
_btn = null;
_tray = null;
@@ -446,6 +485,7 @@ var Tray = (function () {
close: close,
forceClose: forceClose,
isOpen: isOpen,
placeCard: placeCard,
reset: reset,
_testSetLandscape: function (v) { _landscapeOverride = v; },
};

View File

@@ -655,43 +655,6 @@ class SelectRoleViewTest(TestCase):
)
class RevealPhaseRenderingTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.founder)
gamers = [self.founder]
for i in range(2, 7):
gamers.append(User.objects.create(email=f"g{i}@test.io"))
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
TableSeat.objects.create(
room=self.room, gamer=gamer, slot_number=i,
role=role, role_revealed=True,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.SIG_SELECT
self.room.save()
self.client.force_login(self.founder)
def test_face_up_role_cards_rendered_when_sig_select(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "face-up")
def test_inv_role_card_slot_present(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "id_inv_role_card")
def test_partner_indicator_present_when_sig_select(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "partner-indicator")
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")

View File

@@ -216,14 +216,7 @@ class RoleSelectTest(FunctionalTest):
)
)
# 7. Role card appears in inventory
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_inv_role_card .card"
)
)
# 8. Card stack returns to table centre
# 7. Card stack returns to table centre
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
)
@@ -323,46 +316,6 @@ class RoleSelectTest(FunctionalTest):
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
self.assertEqual(len(cards), 5)
# ------------------------------------------------------------------ #
# Test 3d — Previously selected roles appear in inventory on re-entry#
# ------------------------------------------------------------------ #
def test_previously_selected_roles_shown_in_inventory_on_re_entry(self):
"""A multi-slot gamer who already chose some roles should see those
role cards pre-populated in the inventory when they re-enter the room."""
from apps.epic.models import TableSeat
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Inventory Re-entry Test", owner=founder)
_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 BC
TableSeat.objects.filter(room=room, slot_number=1).update(role="BC")
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)
# Inventory should contain exactly one pre-rendered card for BC
inv_cards = self.wait_for(
lambda: self.browser.find_elements(
By.CSS_SELECTOR, "#id_inv_role_card .card"
)
)
self.assertEqual(len(inv_cards), 1)
self.assertIn(
"BUILDER",
inv_cards[0].text.upper(),
)
# ------------------------------------------------------------------ #
# Test 4 — Click-away dismisses fan without selecting #
# ------------------------------------------------------------------ #
@@ -392,17 +345,13 @@ class RoleSelectTest(FunctionalTest):
# Click the backdrop (outside the fan)
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
# Modal closes; stack still present; inventory still empty
# 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")
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_inv_role_card .card")),
0
)
# ------------------------------------------------------------------ #
@@ -529,17 +478,9 @@ class RoleSelectTest(FunctionalTest):
self.browser.refresh()
# All role cards in inventory are face-up
face_up_cards = self.wait_for(
lambda: self.browser.find_elements(
By.CSS_SELECTOR, "#id_inv_role_card .card.face-up"
)
)
self.assertGreater(len(face_up_cards), 0)
# Partner indicator is visible
# Sig deck is present (page has transitioned to SIG_SELECT)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator")
lambda: self.browser.find_element(By.ID, "id_sig_deck")
)
@@ -590,12 +531,13 @@ class RoleSelectTrayTest(FunctionalTest):
self.confirm_guard()
# ------------------------------------------------------------------ #
# T1 — Portrait: role card at topmost grid square, tray opens #
# 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, a .tray-role-card is the first child
of #id_tray_grid (topmost cell) and the tray is open."""
"""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")
@@ -604,34 +546,42 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role()
# Card appears in the grid.
# First cell receives the role card class.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
)
)
# It is the first child — topmost in portrait.
is_first = self.browser.execute_script("""
var card = document.querySelector('#id_tray_grid .tray-role-card');
return card !== null && card === card.parentElement.firstElementChild;
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(is_first, "Role card should be the first child of #id_tray_grid")
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 is open.
self.assertTrue(
self.browser.execute_script("return Tray.isOpen()"),
"Tray should be open after role selection"
# 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: role card at leftmost grid square, tray opens #
# T2 — Landscape: same contract in landscape #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_landscape_role_card_enters_leftmost_grid_square(self):
"""Landscape: after confirming a role, a .tray-role-card is the first child
of #id_tray_grid (leftmost cell) and the tray is open."""
"""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")
@@ -640,24 +590,28 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role()
# Card appears in the grid.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
)
)
# It is the first child — leftmost in landscape.
is_first = self.browser.execute_script("""
var card = document.querySelector('#id_tray_grid .tray-role-card');
return card !== null && card === card.parentElement.firstElementChild;
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(is_first, "Role card should be the first child of #id_tray_grid")
self.assertTrue(result["isFirst"], "Role card should be the first cell")
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
# Tray is open.
self.assertTrue(
self.browser.execute_script("return Tray.isOpen()"),
"Tray should be open after role selection"
self.wait_for(
lambda: self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"),
"Tray should close after the arc-in sequence"
)
)

View File

@@ -189,12 +189,9 @@ class SigSelectTest(FunctionalTest):
)
)
# Founder's significator appears in their inventory
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_inv_sig_card .card"
)
)
# TODO: sig card should appear in the tray (tray.placeCard for sig phase)
# once sig-select.js is updated to call Tray.placeCard instead of
# appending to the removed #id_inv_sig_card inventory element.
# Active seat advances to NC
self.wait_for(

View File

@@ -7,7 +7,6 @@ describe("RoleSelect", () => {
<div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role">
</div>
<div id="id_inv_role_card"></div>
`;
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +101,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
it("clicking a card appends a .card to #id_inv_role_card", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
@@ -117,11 +110,6 @@ describe("RoleSelect", () => {
);
});
it("clicking a card results in exactly one card in inventory", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1);
});
});
// ------------------------------------------------------------------ //
@@ -135,11 +123,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
it("does not add a card to inventory", () => {
RoleSelect.openFan();
document.querySelector(".role-select-backdrop").click();
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
});
// ------------------------------------------------------------------ //
@@ -259,20 +242,13 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => {
let grid, guardConfirm;
let guardConfirm;
beforeEach(() => {
// Minimal tray grid matching room.html structure
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
testDiv.appendChild(grid);
spyOn(Tray, "open");
// Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb();
});
// Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +259,128 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click();
});
it("prepends a .tray-role-card to #id_tray_grid on success", async () => {
it("calls Tray.placeCard() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).not.toBeNull();
expect(Tray.placeCard).toHaveBeenCalled();
});
it("tray-role-card is the first child of #id_tray_grid", async () => {
it("passes the role code string to Tray.placeCard", async () => {
guardConfirm();
await Promise.resolve();
expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true);
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0);
});
it("tray-role-card carries the selected role as data-role", async () => {
guardConfirm();
await Promise.resolve();
const trayCard = grid.querySelector(".tray-role-card");
expect(trayCard.dataset.role).toBeTruthy();
});
it("calls Tray.open() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(Tray.open).toHaveBeenCalled();
});
it("does not prepend a tray-role-card on server rejection", async () => {
it("does not call Tray.placeCard() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
);
guardConfirm();
await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).toBeNull();
expect(Tray.placeCard).not.toHaveBeenCalled();
});
});
it("does not call Tray.open() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
// ------------------------------------------------------------------ //
// WS turn_changed pause during animation //
// ------------------------------------------------------------------ //
describe("WS turn_changed pause during placeCard animation", () => {
let stack, guardConfirm;
beforeEach(() => {
// Six table seats, slot 1 starts active
for (let i = 1; i <= 6; i++) {
const seat = document.createElement("div");
seat.className = "table-seat" + (i === 1 ? " active" : "");
seat.dataset.slot = String(i);
testDiv.appendChild(seat);
}
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "ineligible";
stack.dataset.userSlots = "1";
stack.dataset.starterRoles = "";
testDiv.appendChild(stack);
const grid = document.createElement("div");
grid.id = "id_tray_grid";
testDiv.appendChild(grid);
// placeCard spy that holds the onComplete callback
let heldCallback = null;
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
heldCallback = cb; // don't call immediately — simulate animation in-flight
});
spyOn(Tray, "forceClose");
// Expose heldCallback so tests can fire it
Tray._testFirePlaceCardComplete = () => {
if (heldCallback) heldCallback();
};
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
);
guardConfirm();
await Promise.resolve();
expect(Tray.open).not.toHaveBeenCalled();
});
it("grid grows by exactly 1 on success", async () => {
const before = grid.children.length;
afterEach(() => {
delete Tray._testFirePlaceCardComplete;
RoleSelect._testReset();
});
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).not.toHaveBeenCalled();
});
it("turn_changed during animation does not immediately move the active seat", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
expect(grid.children.length).toBe(before + 1);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
});
it("deferred turn_changed is processed when animation completes", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
// Fire onComplete — deferred turn_changed should now run
Tray._testFirePlaceCardComplete();
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
it("turn_changed after animation completes is processed immediately", () => {
// No animation in flight — turn_changed should run right away
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).toHaveBeenCalled();
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
});
@@ -433,9 +483,6 @@ describe("RoleSelect", () => {
);
});
it("appends a .card to #id_inv_role_card", () => {
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
});
describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +510,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
it("does not add a card to inventory", () => {
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave"));

View File

@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false);
});
});
// ---------------------------------------------------------------------- //
// placeCard() //
// ---------------------------------------------------------------------- //
//
// placeCard(roleCode, onComplete):
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
// 2. Opens the tray.
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
// 4. forceClose() — tray closes instantly.
// 5. Calls onComplete.
//
// The grid always has exactly 8 .tray-cell elements (from the template);
// no new elements are inserted.
//
// ---------------------------------------------------------------------- //
describe("placeCard()", () => {
let grid, firstCell;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
document.body.appendChild(grid);
// Re-init so _grid is set (reset() in outer afterEach clears it)
Tray.init();
firstCell = grid.querySelector(".tray-cell");
});
afterEach(() => {
grid.remove();
});
it("adds .tray-role-card to the first .tray-cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
});
it("sets data-role on the first cell", () => {
Tray.placeCard("NC", null);
expect(firstCell.dataset.role).toBe("NC");
});
it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8);
});
it("opens the tray", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
});
it("adds .arc-in to the first cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("arc-in")).toBe(true);
});
it("removes .arc-in and force-closes after animationend", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
firstCell.dispatchEvent(new Event("animationend"));
expect(firstCell.classList.contains("arc-in")).toBe(false);
expect(Tray.isOpen()).toBe(false);
});
it("calls onComplete after the tray closes", () => {
let called = false;
Tray.placeCard("PC", () => { called = true; });
firstCell.dispatchEvent(new Event("animationend"));
expect(called).toBe(true);
});
it("landscape: same behaviour — first cell gets role card", () => {
Tray._testSetLandscape(true);
Tray.init();
Tray.placeCard("EC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
expect(firstCell.dataset.role).toBe("EC");
});
it("reset() removes .tray-role-card and data-role from cells", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined();
});
});
});

View File

@@ -419,17 +419,6 @@ $seat-r-y: round($seat-r * 0.5); // 65px
justify-content: center;
}
.room-inventory {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(var(--terUser), 0.3) transparent;
}
.table-seat {
position: absolute;
display: flex;
@@ -617,56 +606,6 @@ $card-h: 120px;
}
}
// ─── Inventory role card hand ───────────────────────────────────────────────
//
// Cards are stacked vertically: only a $strip-height peek of each card below
// the first is visible by default, showing the role name at the top of the
// card face. Hovering any card slides it right to pop it clear of the stack.
$inv-card-w: 100px;
$inv-card-h: 150px;
$inv-strip: 30px; // visible height of each stacked card after the first
#id_inv_role_card {
display: flex;
flex-direction: column;
.card {
width: $inv-card-w;
height: $inv-card-h;
position: relative;
z-index: 1;
flex-shrink: 0;
transition: transform 0.2s ease;
// Every card after the first overlaps the one above it
& + .card {
margin-top: -($inv-card-h - $inv-strip);
}
// Role name pinned to the top of the face so it reads in the strip
.card-front {
justify-content: flex-start;
padding-top: 0.4rem;
}
// Pop the hovered card to the right, above siblings
&:hover {
transform: translateX(1.5rem);
z-index: 10;
}
}
}
// ─── Partner indicator ─────────────────────────────────────────────────────
.partner-indicator {
margin-top: 0.5rem;
font-size: 0.75rem;
opacity: 0.6;
text-align: center;
}
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) and (max-width: 1440px) {
.gate-modal {

View File

@@ -117,6 +117,27 @@ $handle-r: 1rem;
&::before { border-color: rgba(var(--quaUser), 1); }
}
// ─── Role card: arc-in animation (portrait) ─────────────────────────────────
@keyframes tray-role-arc-in {
from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); }
to { opacity: 1; transform: scale(1) translate(0, 0); }
}
.tray-role-card {
background: rgba(var(--quaUser), 0.25);
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0.2em;
font-size: 0.65rem;
color: rgba(var(--quaUser), 1);
font-weight: 600;
&.arc-in {
animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
}
@keyframes tray-wobble {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
@@ -276,6 +297,16 @@ $handle-r: 1rem;
border-bottom: none;
}
// Role card arc-in for landscape
@keyframes tray-role-arc-in-landscape {
from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); }
to { opacity: 1; transform: scale(1) translate(0, 0); }
}
.tray-role-card.arc-in {
animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes tray-wobble-landscape {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-8px); }

View File

@@ -7,7 +7,6 @@ describe("RoleSelect", () => {
<div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role">
</div>
<div id="id_inv_role_card"></div>
`;
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +101,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
it("clicking a card appends a .card to #id_inv_role_card", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
@@ -117,11 +110,6 @@ describe("RoleSelect", () => {
);
});
it("clicking a card results in exactly one card in inventory", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1);
});
});
// ------------------------------------------------------------------ //
@@ -135,11 +123,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
it("does not add a card to inventory", () => {
RoleSelect.openFan();
document.querySelector(".role-select-backdrop").click();
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
});
// ------------------------------------------------------------------ //
@@ -259,20 +242,13 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => {
let grid, guardConfirm;
let guardConfirm;
beforeEach(() => {
// Minimal tray grid matching room.html structure
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
testDiv.appendChild(grid);
spyOn(Tray, "open");
// Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb();
});
// Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +259,128 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click();
});
it("prepends a .tray-role-card to #id_tray_grid on success", async () => {
it("calls Tray.placeCard() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).not.toBeNull();
expect(Tray.placeCard).toHaveBeenCalled();
});
it("tray-role-card is the first child of #id_tray_grid", async () => {
it("passes the role code string to Tray.placeCard", async () => {
guardConfirm();
await Promise.resolve();
expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true);
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0);
});
it("tray-role-card carries the selected role as data-role", async () => {
guardConfirm();
await Promise.resolve();
const trayCard = grid.querySelector(".tray-role-card");
expect(trayCard.dataset.role).toBeTruthy();
});
it("calls Tray.open() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(Tray.open).toHaveBeenCalled();
});
it("does not prepend a tray-role-card on server rejection", async () => {
it("does not call Tray.placeCard() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
);
guardConfirm();
await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).toBeNull();
expect(Tray.placeCard).not.toHaveBeenCalled();
});
});
it("does not call Tray.open() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
// ------------------------------------------------------------------ //
// WS turn_changed pause during animation //
// ------------------------------------------------------------------ //
describe("WS turn_changed pause during placeCard animation", () => {
let stack, guardConfirm;
beforeEach(() => {
// Six table seats, slot 1 starts active
for (let i = 1; i <= 6; i++) {
const seat = document.createElement("div");
seat.className = "table-seat" + (i === 1 ? " active" : "");
seat.dataset.slot = String(i);
testDiv.appendChild(seat);
}
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "ineligible";
stack.dataset.userSlots = "1";
stack.dataset.starterRoles = "";
testDiv.appendChild(stack);
const grid = document.createElement("div");
grid.id = "id_tray_grid";
testDiv.appendChild(grid);
// placeCard spy that holds the onComplete callback
let heldCallback = null;
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
heldCallback = cb; // don't call immediately — simulate animation in-flight
});
spyOn(Tray, "forceClose");
// Expose heldCallback so tests can fire it
Tray._testFirePlaceCardComplete = () => {
if (heldCallback) heldCallback();
};
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
);
guardConfirm();
await Promise.resolve();
expect(Tray.open).not.toHaveBeenCalled();
});
it("grid grows by exactly 1 on success", async () => {
const before = grid.children.length;
afterEach(() => {
delete Tray._testFirePlaceCardComplete;
RoleSelect._testReset();
});
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).not.toHaveBeenCalled();
});
it("turn_changed during animation does not immediately move the active seat", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
expect(grid.children.length).toBe(before + 1);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
});
it("deferred turn_changed is processed when animation completes", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
// Fire onComplete — deferred turn_changed should now run
Tray._testFirePlaceCardComplete();
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
it("turn_changed after animation completes is processed immediately", () => {
// No animation in flight — turn_changed should run right away
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).toHaveBeenCalled();
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
});
@@ -433,9 +483,6 @@ describe("RoleSelect", () => {
);
});
it("appends a .card to #id_inv_role_card", () => {
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
});
describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +510,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
it("does not add a card to inventory", () => {
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave"));

View File

@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false);
});
});
// ---------------------------------------------------------------------- //
// placeCard() //
// ---------------------------------------------------------------------- //
//
// placeCard(roleCode, onComplete):
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
// 2. Opens the tray.
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
// 4. forceClose() — tray closes instantly.
// 5. Calls onComplete.
//
// The grid always has exactly 8 .tray-cell elements (from the template);
// no new elements are inserted.
//
// ---------------------------------------------------------------------- //
describe("placeCard()", () => {
let grid, firstCell;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
document.body.appendChild(grid);
// Re-init so _grid is set (reset() in outer afterEach clears it)
Tray.init();
firstCell = grid.querySelector(".tray-cell");
});
afterEach(() => {
grid.remove();
});
it("adds .tray-role-card to the first .tray-cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
});
it("sets data-role on the first cell", () => {
Tray.placeCard("NC", null);
expect(firstCell.dataset.role).toBe("NC");
});
it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8);
});
it("opens the tray", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
});
it("adds .arc-in to the first cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("arc-in")).toBe(true);
});
it("removes .arc-in and force-closes after animationend", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
firstCell.dispatchEvent(new Event("animationend"));
expect(firstCell.classList.contains("arc-in")).toBe(false);
expect(Tray.isOpen()).toBe(false);
});
it("calls onComplete after the tray closes", () => {
let called = false;
Tray.placeCard("PC", () => { called = true; });
firstCell.dispatchEvent(new Event("animationend"));
expect(called).toBe(true);
});
it("landscape: same behaviour — first cell gets role card", () => {
Tray._testSetLandscape(true);
Tray.init();
Tray.placeCard("EC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
expect(firstCell.dataset.role).toBe("EC");
});
it("reset() removes .tray-role-card and data-role from cells", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined();
});
});
});

View File

@@ -47,32 +47,6 @@
{% endfor %}
{% endif %}
</div>
<div id="id_inventory" class="room-inventory">
<div id="id_inv_sig_card"></div>
<div id="id_inv_role_card">
{% if room.table_status == "ROLE_SELECT" %}
{% for seat in assigned_seats %}
<div class="card flipped">
<div class="card-back">?</div>
<div class="card-front">
<div class="card-role-name">{{ seat.get_role_display }}</div>
</div>
</div>
{% endfor %}
{% elif room.table_status == "SIG_SELECT" and user_seat %}
<div class="card face-up">
<div class="card-front">
<div class="card-role-name">{{ user_seat.get_role_display }}</div>
</div>
</div>
{% if partner_seat %}
<div class="partner-indicator">
Partner: {{ partner_seat.get_role_display }}
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% if room.table_status == "SIG_SELECT" and sig_cards %}