role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-03-31 00:01:04 -04:00
parent a8592aeaec
commit 736b59b5c0
13 changed files with 833 additions and 400 deletions

View File

@@ -8,6 +8,15 @@ var RoleSelect = (function () {
var _animationPending = false; var _animationPending = false;
var _pendingTurnChange = null; var _pendingTurnChange = null;
// Delay before the tray animation begins (ms). Gives the gamer a moment
// to see their pick confirmed before the tray slides in. Set to 0 by
// _testReset() so Jasmine tests don't need jasmine.clock().
var _placeCardDelay = 3000;
// Delay after the tray closes before advancing to the next turn (ms).
// Gives the gamer a moment to see their confirmed seat before the turn moves.
var _postTrayDelay = 3000;
var ROLES = [ var ROLES = [
{ code: "PC", name: "Player", element: "Fire" }, { code: "PC", name: "Player", element: "Fire" },
{ code: "BC", name: "Builder", element: "Stone" }, { code: "BC", name: "Builder", element: "Stone" },
@@ -36,6 +45,10 @@ var RoleSelect = (function () {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag _turnChangedBeforeFetch = false; // fresh selection, reset the race flag
closeFan(); closeFan();
// Show the tray handle — gamer confirmed a pick, tray animation about to run
var trayWrap = document.getElementById("id_tray_wrap");
if (trayWrap) trayWrap.classList.remove("role-select-phase");
// Immediately lock the stack — do not wait for WS turn_changed // Immediately lock the stack — do not wait for WS turn_changed
var stack = document.querySelector(".card-stack[data-starter-roles]"); var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack) { if (stack) {
@@ -45,12 +58,24 @@ var RoleSelect = (function () {
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
} }
// Mark position as actively being seated (glow state) // Mark seat as actively being claimed (glow state)
var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]'); var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
if (activePos) activePos.classList.add('active'); if (activePos) activePos.classList.add('active');
// Immediately fade out the gate-slot circle for the current turn's slot
var activeSlot = stack ? stack.dataset.activeSlot : null;
if (activeSlot) {
var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]');
if (slotCircle) slotCircle.classList.add('role-assigned');
}
var url = getSelectRoleUrl(); var url = getSelectRoleUrl();
if (!url) return; if (!url) return;
// Block handleTurnChanged immediately — WS turn_changed can arrive while
// the fetch is in-flight and must be deferred until our animation completes.
_animationPending = true;
fetch(url, { fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@@ -61,34 +86,41 @@ var RoleSelect = (function () {
}).then(function (response) { }).then(function (response) {
if (!response.ok) { if (!response.ok) {
// Server rejected (role already taken) — undo optimistic update // Server rejected (role already taken) — undo optimistic update
_animationPending = false;
if (stack) { if (stack) {
stack.dataset.starterRoles = stack.dataset.starterRoles stack.dataset.starterRoles = stack.dataset.starterRoles
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(","); .split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
} }
openFan(); openFan();
} else { } else {
// Always animate the role card into the tray, even if turn_changed // Animate the role card into the tray: open, arc-in, force-close.
// already arrived. placeCard opens the tray, arcs the card in, // Any turn_changed that arrived while the fetch was in-flight is
// then force-closes — so the user always sees their role card land. // queued in _pendingTurnChange and will run after onComplete.
// If turn_changed arrived before the fetch, handleTurnChanged already
// ran; _pendingTurnChange will be null and onComplete is a no-op.
if (typeof Tray !== "undefined") { if (typeof Tray !== "undefined") {
_animationPending = true; setTimeout(function () {
Tray.placeCard(roleCode, function () { Tray.placeCard(roleCode, function () {
_animationPending = false; // Swap ban → check, clear glow, mark seat as confirmed
// Swap ban → check and clear glow on the seated position var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
if (seatedPos) { if (seatedPos) {
seatedPos.classList.remove('active'); seatedPos.classList.remove('active');
seatedPos.classList.add('role-confirmed');
var ban = seatedPos.querySelector('.fa-ban'); var ban = seatedPos.querySelector('.fa-ban');
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); } if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
} }
// Hold _animationPending through the post-tray pause so any
// turn_changed WS event that arrives now is still deferred.
setTimeout(function () {
_animationPending = false;
if (_pendingTurnChange) { if (_pendingTurnChange) {
var ev = _pendingTurnChange; var ev = _pendingTurnChange;
_pendingTurnChange = null; _pendingTurnChange = null;
handleTurnChanged(ev); handleTurnChanged(ev);
} }
}, _postTrayDelay);
}); });
}, _placeCardDelay);
} else {
_animationPending = false;
} }
} }
}); });
@@ -189,11 +221,39 @@ var RoleSelect = (function () {
_turnChangedBeforeFetch = true; _turnChangedBeforeFetch = true;
if (typeof Tray !== "undefined") Tray.forceClose(); if (typeof Tray !== "undefined") Tray.forceClose();
// Clear any stale .active glow from position indicators // Hide tray handle until the next player confirms their pick
document.querySelectorAll('.table-position.active').forEach(function (p) { var trayWrap = document.getElementById("id_tray_wrap");
if (trayWrap) trayWrap.classList.add("role-select-phase");
// Clear any stale .active glow from hex seats
document.querySelectorAll('.table-seat.active').forEach(function (p) {
p.classList.remove('active'); p.classList.remove('active');
}); });
// Sync seat icons from starter_roles so state persists without a reload
if (event.detail.starter_roles) {
var assignedRoles = event.detail.starter_roles;
document.querySelectorAll(".table-seat").forEach(function (seat) {
var role = seat.dataset.role;
if (assignedRoles.indexOf(role) !== -1) {
seat.classList.add("role-confirmed");
var ban = seat.querySelector(".fa-ban");
if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); }
}
});
// Hide slot circles in turn order: slots 1..N done when N roles assigned
var assignedCount = assignedRoles.length;
document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) {
if (parseInt(circle.dataset.slot, 10) <= assignedCount) {
circle.classList.add("role-assigned");
}
});
}
// Update active slot on the card stack so selectRole() can read it
var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) stack.dataset.activeSlot = active;
var stack = document.querySelector(".card-stack[data-user-slots]"); var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) { if (stack) {
// Sync starter-roles from server so the fan reflects actual DB state // Sync starter-roles from server so the fan reflects actual DB state
@@ -221,12 +281,10 @@ var RoleSelect = (function () {
} }
} }
// Move .active to the newly active seat // Clear any stale seat glow (JS-only; glow is only during tray animation)
document.querySelectorAll(".table-seat.active").forEach(function (s) { document.querySelectorAll(".table-seat.active").forEach(function (s) {
s.classList.remove("active"); s.classList.remove("active");
}); });
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
if (activeSeat) activeSeat.classList.add("active");
} }
window.addEventListener("room:role_select_start", init); window.addEventListener("room:role_select_start", init);
@@ -247,6 +305,8 @@ var RoleSelect = (function () {
_testReset: function () { _testReset: function () {
_animationPending = false; _animationPending = false;
_pendingTurnChange = null; _pendingTurnChange = null;
_placeCardDelay = 0;
_postTrayDelay = 0;
}, },
}; };
}()); }());

View File

@@ -402,7 +402,9 @@ class RoleSelectRenderingTest(TestCase):
response = self.client.get( response = self.client.get(
self.url self.url
) )
self.assertNotContains(response, "fa-ban") # Seat ban icons carry "position-status-icon"; card-stack ban does not.
# Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
self.assertNotContains(response, 'class="fa-solid fa-ban"')
def test_gatekeeper_overlay_absent_when_role_select(self): def test_gatekeeper_overlay_absent_when_role_select(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
@@ -411,6 +413,21 @@ class RoleSelectRenderingTest(TestCase):
) )
self.assertNotContains(response, "gate-overlay") self.assertNotContains(response, "gate-overlay")
def test_tray_wrap_has_role_select_phase_class(self):
# Tray handle hidden until gamer confirms a role pick
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
def test_tray_absent_during_gatekeeper_phase(self):
# Tray must not render before the gamer occupies a seat
room = Room.objects.create(name="Gate Room", owner=self.founder)
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
)
self.assertNotContains(response, 'id="id_tray_wrap"')
def test_six_table_seats_rendered(self): def test_six_table_seats_rendered(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
@@ -418,20 +435,66 @@ class RoleSelectRenderingTest(TestCase):
) )
self.assertContains(response, "table-seat", count=6) self.assertContains(response, "table-seat", count=6)
def test_active_table_seat_has_active_class(self): def test_table_seats_never_active_on_load(self):
self.client.force_login(self.founder) # slot 1 is active # Seat glow is JS-only (during tray animation); never server-rendered
response = self.client.get(
self.url
)
self.assertContains(response, 'class="table-seat active"')
def test_inactive_table_seat_lacks_active_class(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(self.url)
self.url self.assertNotContains(response, 'class="table-seat active"')
)
# Slots 26 are not active, so at least one plain table-seat exists def test_assigned_seat_renders_role_confirmed_class(self):
self.assertContains(response, 'class="table-seat"') # A seat with a role already picked must load as role-confirmed (opaque chair)
self.gamers[0].refresh_from_db()
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'table-seat role-confirmed')
def test_unassigned_seat_lacks_role_confirmed_class(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertNotContains(response, 'table-seat role-confirmed')
def test_assigned_slot_circle_renders_role_assigned_class(self):
# Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'gate-slot filled role-assigned')
def test_slot_circle_hides_by_count_not_role_label(self):
# Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
seat = self.room.table_seats.get(slot_number=1)
seat.role = "NC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
import re
# Template renders class before data-slot; capture both orderings
circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
self.assertIn("role-assigned", slot1_classes)
self.assertNotIn("role-assigned", slot2_classes)
def test_unassigned_slot_circle_lacks_role_assigned_class(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertNotContains(response, 'role-assigned')
def test_position_strip_rendered_during_role_select(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, "position-strip")
def test_position_strip_has_six_gate_slots(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, "gate-slot", count=6)
def test_card_stack_has_data_user_slots_for_eligible_gamer(self): def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
self.client.force_login(self.founder) # founder is slot 1 only self.client.force_login(self.founder) # founder is slot 1 only
@@ -447,6 +510,29 @@ class RoleSelectRenderingTest(TestCase):
) )
self.assertContains(response, 'data-user-slots="2"') self.assertContains(response, 'data-user-slots="2"')
def test_assigned_seat_renders_check_icon(self):
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
# The PC seat should have fa-circle-check, not fa-ban
pc_seat_start = content.index('data-role="PC"')
pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
self.assertIn("fa-circle-check", pc_seat_chunk)
self.assertNotIn("fa-ban", pc_seat_chunk)
def test_unassigned_seat_renders_ban_icon(self):
# slot 2's role is still null
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
nc_seat_start = content.index('data-role="NC"')
nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
self.assertIn("fa-ban", nc_seat_chunk)
self.assertNotIn("fa-circle-check", nc_seat_chunk)
class PickRolesViewTest(TestCase): class PickRolesViewTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
def _gate_positions(room): def _gate_positions(room):
"""Return list of dicts [{slot, role_label}] for _table_positions.html.""" """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
# of which role each gamer chose — so use count, not role matching.
assigned_count = room.table_seats.exclude(role__isnull=True).count()
return [ return [
{"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")} {
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
}
for slot in room.gate_slots.order_by("slot_number") for slot in room.gate_slots.order_by("slot_number")
] ]
@@ -147,6 +154,7 @@ def _gate_context(room, user):
"carte_nvm_slot_number": carte_nvm_slot_number, "carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number, "carte_next_slot_number": carte_next_slot_number,
"gate_positions": _gate_positions(room), "gate_positions": _gate_positions(room),
"starter_roles": [],
} }
@@ -199,6 +207,7 @@ def _role_select_context(room, user):
) if user.is_authenticated else [], ) if user.is_authenticated else [],
"active_slot": active_slot, "active_slot": active_slot,
"gate_positions": _gate_positions(room), "gate_positions": _gate_positions(room),
"slots": room.gate_slots.order_by("slot_number"),
} }
if room.table_status == Room.SIG_SELECT: if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None

View File

@@ -607,20 +607,20 @@ class PositionIndicatorsTest(FunctionalTest):
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test P1 — 6 position indicators present while gatekeeper is open # # Test P1 — 6 position circles present in strip alongside gatekeeper #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_position_indicators_visible_alongside_gatekeeper(self): def test_position_indicators_visible_alongside_gatekeeper(self):
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
# Gatekeeper modal is open
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
) )
# Six .table-position elements are rendered outside the modal # Six .gate-slot elements are rendered in .position-strip, outside modal
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
self.assertEqual(len(positions), 6) slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot")
for pos in positions: self.assertEqual(len(slots), 6)
self.assertTrue(pos.is_displayed()) for slot in slots:
self.assertTrue(slot.is_displayed())
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test P2 — URL drops /gate/ after pick_roles # # Test P2 — URL drops /gate/ after pick_roles #
@@ -631,63 +631,57 @@ class PositionIndicatorsTest(FunctionalTest):
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
]) ])
# Simulate pick_roles having fired: room advances to ROLE_SELECT
self.room.table_status = Room.ROLE_SELECT self.room.table_status = Room.ROLE_SELECT
self.room.save() self.room.save()
# Navigating to the /gate/ URL should redirect to the plain room URL
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
expected_url = ( expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/"
f"{self.live_server_url}/gameboard/room/{self.room.id}/"
)
self.wait_for( self.wait_for(
lambda: self.assertEqual(self.browser.current_url, expected_url) lambda: self.assertEqual(self.browser.current_url, expected_url)
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test P3 — Each position has a chair icon and correct role label # # Test P3 — Gate-slot circles live outside the modal #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_position_shows_chair_icon_and_role_label(self): def test_position_circles_outside_gatekeeper_modal(self):
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} """The numbered position circles must NOT be descendants of .gate-modal —
they live in .position-strip which sits above the backdrop."""
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")
) )
for slot_number, role_label in SLOT_ROLE_LABELS.items(): # No .gate-slot inside the modal
pos = self.browser.find_element( modal_slots = self.browser.find_elements(
By.CSS_SELECTOR, f".table-position[data-slot='{slot_number}']" By.CSS_SELECTOR, ".gate-modal .gate-slot"
) )
# Chair icon present self.assertEqual(len(modal_slots), 0)
self.assertTrue(pos.find_elements(By.CSS_SELECTOR, ".fa-chair")) # All 6 live in .position-strip
# Role label attribute and visible text strip_slots = self.browser.find_elements(
self.assertEqual(pos.get_attribute("data-role-label"), role_label) By.CSS_SELECTOR, ".position-strip .gate-slot"
label_el = pos.find_element(By.CSS_SELECTOR, ".position-role-label") )
self.assertEqual(label_el.text.strip(), role_label) self.assertEqual(len(strip_slots), 6)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test P4 — Unoccupied position shows ban icon # # Test P4 — Each circle displays its slot number #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_unoccupied_position_shows_ban_icon(self): def test_position_circle_shows_slot_number(self):
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
) )
# All slots are empty — every position should have a ban icon for n in range(1, 7):
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") slot_el = self.browser.find_element(
for pos in positions: By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']"
self.assertTrue(
pos.find_elements(By.CSS_SELECTOR, ".fa-ban"),
f"Expected .fa-ban on slot {pos.get_attribute('data-slot')}",
) )
self.assertIn(str(n), slot_el.text)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test P5 — Occupied position shows check icon after token confirmed # # Test P5 — Filled slot carries .filled class in strip #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_occupied_position_shows_check_icon_after_token_confirmed(self): def test_filled_slot_shown_in_strip(self):
# Slot 1 is filled via ORM
from apps.epic.models import GateSlot from apps.epic.models import GateSlot
slot = self.room.gate_slots.get(slot_number=1) slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = self.founder slot.gamer = self.founder
@@ -696,10 +690,13 @@ class PositionIndicatorsTest(FunctionalTest):
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
) )
pos1 = self.browser.find_element( slot1 = self.browser.find_element(
By.CSS_SELECTOR, ".table-position[data-slot='1']" By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']"
) )
self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check")) self.assertIn("filled", slot1.get_attribute("class"))
self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban")) slot2 = self.browser.find_element(
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']"
)
self.assertIn("empty", slot2.get_attribute("class"))

View File

@@ -445,12 +445,14 @@ class RoleSelectTest(FunctionalTest):
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 7All roles revealed simultaneously after all gamers select # # Test 8aHex seats carry role labels during role select #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_roles_revealed_simultaneously_after_all_select(self): def test_seats_around_hex_have_role_labels(self):
"""During role select the 6 .table-seat elements carry data-role
attributes matching the fixed slot→role mapping (PC at slot 1, etc.)."""
founder, _ = User.objects.get_or_create(email="founder@test.io") founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Reveal Test", owner=founder) room = Room.objects.create(name="Seat Label Test", owner=founder)
_fill_room_via_orm(room, [ _fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
@@ -462,38 +464,24 @@ class RoleSelectTest(FunctionalTest):
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url) self.browser.get(room_url)
# Assign all roles via ORM (simulating all gamers having chosen) expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
from apps.epic.models import TableSeat
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
for i, slot in enumerate(room.gate_slots.order_by("slot_number")):
TableSeat.objects.create(
room=room,
gamer=slot.gamer,
slot_number=slot.slot_number,
role=roles[i],
role_revealed=True,
)
room.table_status = Room.SIG_SELECT
room.save()
self.browser.refresh()
# Sig deck is present (page has transitioned to SIG_SELECT)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sig_deck") lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
) )
for slot_number, role_label in expected.items():
seat = self.browser.find_element(
By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']"
)
self.assertEqual(seat.get_attribute("data-role"), role_label)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 8aPosition glows while role card is being placed # # Test 8bHex seats show .fa-ban when empty #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_position_glows_when_role_card_confirmed(self): def test_seats_show_ban_icon_when_empty(self):
"""Immediately after confirming a role pick, the matching """All 6 seats carry .fa-ban before any role has been chosen."""
.table-position should receive .active (the glow state) before
the tray animation completes."""
founder, _ = User.objects.get_or_create(email="founder@test.io") founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Position Glow Test", owner=founder) room = Room.objects.create(name="Seat Ban Test", owner=founder)
_fill_room_via_orm(room, [ _fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
@@ -509,32 +497,26 @@ class RoleSelectTest(FunctionalTest):
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url) self.browser.get(room_url)
# Open fan, click first card (PC), confirm guard
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
)
).click()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard()
# PC position gains .active immediately after confirmation
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-position[data-role-label='PC'].active"
) )
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
self.assertEqual(len(seats), 6)
for seat in seats:
self.assertTrue(
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 8bPosition shows check icon after tray sequence ends # # Test 8cHex seat gets .fa-circle-check after role selected #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_position_gets_check_when_tray_sequence_ends(self): def test_seat_gets_check_after_role_selected(self):
"""After the tray arc-in animation completes and the tray closes, """After confirming a role pick the corresponding hex seat should
the PC .table-position should show .fa-circle-check and no .fa-ban.""" show .fa-circle-check and lose .fa-ban."""
founder, _ = User.objects.get_or_create(email="founder@test.io") founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Position Check Test", owner=founder) room = Room.objects.create(name="Seat Check Test", owner=founder)
_fill_room_via_orm(room, [ _fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
@@ -550,7 +532,7 @@ class RoleSelectTest(FunctionalTest):
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url) self.browser.get(room_url)
# Open fan, pick PC card, confirm guard # Open fan, pick first card (PC), confirm guard
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, ".card-stack[data-state='eligible']" By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
@@ -560,23 +542,23 @@ class RoleSelectTest(FunctionalTest):
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard() self.confirm_guard()
# Wait for tray animation to complete (tray closes) # Wait for tray animation to complete
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should close after arc-in sequence" "Tray should close after arc-in sequence",
) )
) )
# PC position now shows check icon, ban icon gone # The PC seat (slot 1) now shows check, no ban
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-circle-check" By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-circle-check"
) )
) )
self.assertEqual( self.assertEqual(
len(self.browser.find_elements( len(self.browser.find_elements(
By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-ban" By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-ban"
)), )),
0, 0,
) )
@@ -745,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
) )
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# 1. Watcher loads the room — slot 1 is active on initial render # 1. Watcher (slot 2) loads the room
self.create_pre_authenticated_session("watcher@test.io") self.create_pre_authenticated_session("watcher@test.io")
self.browser.get(room_url) self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
)) ))
# 2. Founder picks a role in second browser # 2. Founder picks a role in second browser
@@ -764,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard(browser=self.browser2) self.confirm_guard(browser=self.browser2)
# 3. Watcher's seat arc moves to slot 2 — no page refresh # 3. Watcher's turn arrives via WS — card-stack becomes eligible
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
)) ))
self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
)),
0,
)
finally: finally:
self.browser2.quit() self.browser2.quit()
@@ -836,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
return card !== null && card === card.parentElement.firstElementChild; return card !== null && card === card.parentElement.firstElementChild;
"""))) """)))
# Turn advances via WS — seat 2 becomes active. # Turn advances via WS — tray must close (forceClose in handleTurnChanged).
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.assertFalse(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
))
# Tray must be closed: forceClose() fires in handleTurnChanged.
self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be closed after turn advances" "Tray should be closed after turn advances"
) ))
def test_landscape_tray_closes_on_turn_advance(self): def test_landscape_tray_closes_on_turn_advance(self):
"""Landscape: role card at leftmost grid square; tray closes when """Landscape: role card at leftmost grid square; tray closes when
@@ -869,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
return card !== null && card === card.parentElement.firstElementChild; return card !== null && card === card.parentElement.firstElementChild;
"""))) """)))
# Turn advances via WS — seat 2 becomes active. # Turn advances via WS — tray must close (forceClose in handleTurnChanged).
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.assertFalse(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
))
# Tray must be closed.
self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be closed after turn advances" "Tray should be closed after turn advances"
) ))

View File

@@ -80,6 +80,20 @@ class TrayTest(FunctionalTest):
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
""", btn, start_y, end_y) """, btn, start_y, end_y)
def _make_role_select_room(self, founder_email="founder@test.io"):
from apps.epic.models import TableSeat
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
emails = [founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"]
_fill_room_via_orm(room, emails)
room.table_status = Room.ROLE_SELECT
room.save()
for i, email in enumerate(emails, start=1):
gamer, _ = User.objects.get_or_create(email=email)
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
return room
def _make_sig_select_room(self, founder_email="founder@test.io"): def _make_sig_select_room(self, founder_email="founder@test.io"):
founder, _ = User.objects.get_or_create(email=founder_email) founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder) room = Room.objects.create(name="Tray Test Room", owner=founder)
@@ -262,7 +276,7 @@ class TrayTest(FunctionalTest):
@tag('two-browser') @tag('two-browser')
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
room = self._make_sig_select_room() room = self._make_role_select_room()
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room)) self.browser.get(self._room_url(room))

View File

@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
let testDiv; let testDiv;
beforeEach(() => { beforeEach(() => {
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="room-page" <div class="room-page"
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("room:turn_changed event", () => { describe("room:turn_changed event", () => {
let stack; let stack, trayWrap;
beforeEach(() => { beforeEach(() => {
// Six table seats, slot 1 starts active // Six table seats, slot 1 starts active
@@ -169,6 +170,11 @@ describe("RoleSelect", () => {
stack.dataset.userSlots = "1"; stack.dataset.userSlots = "1";
stack.dataset.starterRoles = ""; stack.dataset.starterRoles = "";
testDiv.appendChild(stack); testDiv.appendChild(stack);
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
}); });
it("calls Tray.forceClose() on turn change", () => { it("calls Tray.forceClose() on turn change", () => {
@@ -179,13 +185,19 @@ describe("RoleSelect", () => {
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
}); });
it("moves .active to the newly active seat", () => { it("clears .active from all seats on turn change", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 } detail: { active_slot: 2 }
})); }));
expect( expect(testDiv.querySelector(".table-seat.active")).toBeNull();
testDiv.querySelector(".table-seat.active").dataset.slot });
).toBe("2");
it("re-adds role-select-phase to tray wrap on turn change", () => {
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
}); });
it("removes .active from the previously active seat", () => { it("removes .active from the previously active seat", () => {
@@ -231,6 +243,119 @@ describe("RoleSelect", () => {
stack.click(); stack.click();
expect(document.querySelector(".role-select-backdrop")).toBeNull(); expect(document.querySelector(".role-select-backdrop")).toBeNull();
}); });
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).toBeNull();
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
});
it("adds role-confirmed to seat when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "NC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).not.toBeNull();
expect(seat.querySelector(".fa-circle-check")).toBeNull();
});
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(true);
});
it("leaves slot-2 circle visible when only 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "2";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(false);
});
it("updates data-active-slot on card stack to the new active slot", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(stack.dataset.activeSlot).toBe("2");
});
});
// ------------------------------------------------------------------ //
// selectRole slot-circle fade-out //
// ------------------------------------------------------------------ //
describe("selectRole() slot-circle behaviour", () => {
let circle, stack;
beforeEach(() => {
// Gate-slot circle for slot 1 (active turn)
circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
// Card stack with active-slot=1 so selectRole() knows which circle to hide
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
testDiv.appendChild(stack);
spyOn(Tray, "placeCard");
});
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
expect(circle.classList.contains("role-assigned")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -242,9 +367,14 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => { describe("tray card after successful role selection", () => {
let guardConfirm; let guardConfirm, trayWrap;
beforeEach(() => { beforeEach(() => {
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
// Spy on Tray.placeCard: call the onComplete callback immediately. // Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb(); if (cb) cb();
@@ -261,13 +391,15 @@ describe("RoleSelect", () => {
it("calls Tray.placeCard() on success", async () => { it("calls Tray.placeCard() on success", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
expect(Tray.placeCard).toHaveBeenCalled(); expect(Tray.placeCard).toHaveBeenCalled();
}); });
it("passes the role code string to Tray.placeCard", async () => { it("passes the role code string to Tray.placeCard", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
const roleCode = Tray.placeCard.calls.mostRecent().args[0]; const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string"); expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0); expect(roleCode.length).toBeGreaterThan(0);
@@ -281,6 +413,27 @@ describe("RoleSelect", () => {
await Promise.resolve(); await Promise.resolve();
expect(Tray.placeCard).not.toHaveBeenCalled(); expect(Tray.placeCard).not.toHaveBeenCalled();
}); });
it("removes role-select-phase from tray wrap on successful pick", async () => {
guardConfirm();
await Promise.resolve();
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
});
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
// Add a seat element matching the first available role (PC)
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
testDiv.appendChild(seat);
guardConfirm();
await Promise.resolve(); // fetch resolves + placeCard fires
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -360,17 +513,20 @@ describe("RoleSelect", () => {
RoleSelect.openFan(); RoleSelect.openFan();
document.querySelector("#id_role_select .card").click(); document.querySelector("#id_role_select .card").click();
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] } detail: { active_slot: 2, starter_roles: [] }
})); }));
// Fire onComplete — deferred turn_changed should now run // Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
Tray._testFirePlaceCardComplete(); Tray._testFirePlaceCardComplete();
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
const activeSeat = testDiv.querySelector(".table-seat.active"); // Seat glow is JS-only (tray animation window); after deferred
expect(activeSeat && activeSeat.dataset.slot).toBe("2"); // handleTurnChanged runs, all seat glows are cleared.
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
it("turn_changed after animation completes is processed immediately", () => { it("turn_changed after animation completes is processed immediately", () => {
@@ -379,8 +535,8 @@ describe("RoleSelect", () => {
detail: { active_slot: 2, starter_roles: [] } detail: { active_slot: 2, starter_roles: [] }
})); }));
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
const activeSeat = testDiv.querySelector(".table-seat.active"); // Seats are not persistently glowed; all active cleared
expect(activeSeat && activeSeat.dataset.slot).toBe("2"); expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
}); });

View File

@@ -239,68 +239,6 @@ html:has(.gate-backdrop) {
} }
} }
.gate-slots {
display: flex;
flex-direction: row;
align-items: center;
gap: $gate-gap;
.gate-slot {
position: relative;
width: $gate-node;
height: $gate-node;
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.filled,
&.reserved {
background: rgba(var(--terUser), 0.2);
}
&.filled:hover,
&.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
.slot-number {
font-size: 0.7em;
opacity: 0.5;
}
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
// CARTE drop-target circle — matches .reserved appearance
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 0.2);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
}
}
}
.form-container { .form-container {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -317,24 +255,6 @@ html:has(.gate-backdrop) {
} }
.token-slot { min-width: 150px; } .token-slot { min-width: 150px; }
.gate-slots {
display: grid;
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, 52px);
gap: 24px;
.gate-slot {
width: 52px;
height: 52px;
&:nth-child(1) { grid-column: 1; grid-row: 1; }
&:nth-child(2) { grid-column: 2; grid-row: 1; }
&:nth-child(3) { grid-column: 3; grid-row: 1; }
&:nth-child(4) { grid-column: 1; grid-row: 2; }
&:nth-child(5) { grid-column: 2; grid-row: 2; }
&:nth-child(6) { grid-column: 3; grid-row: 2; }
}
}
} }
} }
@@ -366,59 +286,104 @@ $seat-r: 130px;
$seat-r-x: round($seat-r * 0.866); // 113px $seat-r-x: round($seat-r * 0.866); // 113px
$seat-r-y: round($seat-r * 0.5); // 65px $seat-r-y: round($seat-r * 0.5); // 65px
// .table-position anchors at edge midpoints (pointy-top hex). // Seat edge-midpoint geometry (pointy-top hex).
// Apothem ≈ 80px + 30px clearance = 110px total push from centre. // Apothem ≈ 80px + 30px clearance = 110px total push from centre.
$pos-d: 110px; $pos-d: 110px;
$pos-d-x: round($pos-d * 0.5); // 55px $pos-d-x: round($pos-d * 0.5); // 55px
$pos-d-y: round($pos-d * 0.866); // 95px $pos-d-y: round($pos-d * 0.866); // 95px
.table-position { // ─── Position strip ────────────────────────────────────────────────────────
// Numbered gate-slot circles rendered above the backdrop (z 130 > overlay 120
// > backdrop 100). .room-page is position:relative with no z-index, so its
// absolute children share the root stacking context with the fixed overlays.
.position-strip {
position: absolute; position: absolute;
z-index: 110; top: 0.5rem;
left: 0;
right: 0;
z-index: 130;
display: flex;
justify-content: center;
gap: round($gate-gap * 0.6);
pointer-events: none; pointer-events: none;
transform: translate(-50%, -50%);
.gate-slot {
position: relative;
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 0.5);
background: rgba(var(--priUser), 1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.15rem; justify-content: center;
flex-shrink: 0;
pointer-events: auto;
font-size: 1.8rem;
transition: opacity 0.6s ease, transform 0.6s ease;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12)
;
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order) &.role-assigned {
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; } opacity: 0;
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } transform: scale(0.5);
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } pointer-events: none;
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; } box-shadow:
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } 0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12)
;
}
.position-body { &.filled, &.reserved {
background: rgba(var(--terUser), 0.9);
border-color: rgba(var(--terUser), 1);
color: rgba(var(--priUser), 1);
}
&.filled:hover, &.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
.slot-number { font-size: 0.7em; opacity: 0.5; }
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.1rem; justify-content: center;
} }
.fa-chair { &:has(.drop-token-btn) {
font-size: 1.1rem; background: rgba(var(--terUser), 1);
color: rgba(var(--secUser), 0.4); border-color: rgba(var(--ninUser), 0.5);
}
.position-role-label { &:hover {
font-size: 0.6rem; box-shadow:
font-weight: 600; -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
letter-spacing: 0.05em; -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
color: rgba(var(--secUser), 0.5); 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
} }
.position-status-icon {
font-size: 0.65rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
} }
}
}
&.active { @media (max-width: 700px) {
.fa-chair { .position-strip {
color: rgba(var(--terUser), 1); gap: round($gate-gap * 0.3);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
.gate-slot {
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
} }
} }
} }
@@ -482,20 +447,61 @@ $pos-d-y: round($pos-d * 0.866); // 95px
.table-seat { .table-seat {
position: absolute; position: absolute;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.25rem;
align-items: center; align-items: center;
gap: 0.25rem;
// Centre the element on its anchor point
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none;
// Clockwise from top — slot drop order during ROLE_SELECT // Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
&[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); } &[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
&[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } &[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } &[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); } &[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
&[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } &[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
&[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } &[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
// Chair: col 1, spans both rows
.fa-chair {
grid-column: 1;
grid-row: 1 / 3;
font-size: 1.6rem;
color: rgba(var(--secUser), 0.4);
transition: color 0.6s ease, filter 0.6s ease;
}
// Abbreviation: col 2, row 1
.seat-role-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
// Status icon: col 2, row 2, centred under the abbreviation
.position-status-icon {
grid-column: 2;
grid-row: 2;
justify-self: center;
font-size: 0.8rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
}
&.active .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
// After role confirmed: chair settles to full-opacity --secUser (no glow)
&.role-confirmed .fa-chair {
color: rgba(var(--secUser), 1);
filter: none;
}
.seat-portrait { .seat-portrait {
width: 36px; width: 36px;
@@ -690,17 +696,6 @@ $card-h: 120px;
} }
} }
.gate-slots {
gap: 14px;
.gate-slot {
width: 40px;
height: 40px;
.slot-number { font-size: 0.6em; }
}
}
.form-container { .form-container {
margin-top: 0.75rem; margin-top: 0.75rem;
h3 { font-size: 0.85rem; margin: 0.5rem 0; } h3 { font-size: 0.85rem; margin: 0.5rem 0; }

View File

@@ -24,6 +24,10 @@ $handle-rect-h: 72px;
$handle-exposed: 48px; $handle-exposed: 48px;
$handle-r: 1rem; $handle-r: 1rem;
#id_tray_wrap.role-select-phase {
#id_tray_handle { visibility: hidden; pointer-events: none; }
}
#id_tray_wrap { #id_tray_wrap {
position: fixed; position: fixed;
// left set by JS: closed = vw - handleW; open = vw - wrapW // left set by JS: closed = vw - handleW; open = vw - wrapW
@@ -37,11 +41,11 @@ $handle-r: 1rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: left 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; } &.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble 0.45s ease; } &.wobble { animation: tray-wobble .45s ease; }
&.snap { animation: tray-snap 0.30s ease; } &.snap { animation: tray-snap 1.0s ease; }
} }
#id_tray_handle { #id_tray_handle {
@@ -134,7 +138,7 @@ $handle-r: 1rem;
font-weight: 600; font-weight: 600;
&.arc-in { &.arc-in {
animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
} }
@@ -218,11 +222,11 @@ $handle-r: 1rem;
right: $sidebar-w; right: $sidebar-w;
top: auto; // JS controls style.top for the Y-axis slide top: auto; // JS controls style.top for the Y-axis slide
bottom: auto; bottom: auto;
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: top 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; } &.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble-landscape 0.45s ease; } &.wobble { animation: tray-wobble-landscape 0.45s ease; }
&.snap { animation: tray-snap-landscape 0.30s ease; } &.snap { animation: tray-snap-landscape 1.0s ease; }
} }
@@ -304,7 +308,7 @@ $handle-r: 1rem;
} }
.tray-role-card.arc-in { .tray-role-card.arc-in {
animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
} }
@keyframes tray-wobble-landscape { @keyframes tray-wobble-landscape {

View File

@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
let testDiv; let testDiv;
beforeEach(() => { beforeEach(() => {
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="room-page" <div class="room-page"
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("room:turn_changed event", () => { describe("room:turn_changed event", () => {
let stack; let stack, trayWrap;
beforeEach(() => { beforeEach(() => {
// Six table seats, slot 1 starts active // Six table seats, slot 1 starts active
@@ -169,6 +170,12 @@ describe("RoleSelect", () => {
stack.dataset.userSlots = "1"; stack.dataset.userSlots = "1";
stack.dataset.starterRoles = ""; stack.dataset.starterRoles = "";
testDiv.appendChild(stack); testDiv.appendChild(stack);
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
// Simulate server-side class during ROLE_SELECT
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
}); });
it("calls Tray.forceClose() on turn change", () => { it("calls Tray.forceClose() on turn change", () => {
@@ -179,13 +186,19 @@ describe("RoleSelect", () => {
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
}); });
it("moves .active to the newly active seat", () => { it("re-adds role-select-phase to tray wrap on turn change", () => {
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 } detail: { active_slot: 2 }
})); }));
expect( expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
testDiv.querySelector(".table-seat.active").dataset.slot });
).toBe("2");
it("clears .active from all seats on turn change", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
it("removes .active from the previously active seat", () => { it("removes .active from the previously active seat", () => {
@@ -231,6 +244,119 @@ describe("RoleSelect", () => {
stack.click(); stack.click();
expect(document.querySelector(".role-select-backdrop")).toBeNull(); expect(document.querySelector(".role-select-backdrop")).toBeNull();
}); });
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).toBeNull();
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
});
it("adds role-confirmed to seat when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "NC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).not.toBeNull();
expect(seat.querySelector(".fa-circle-check")).toBeNull();
});
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(true);
});
it("leaves slot-2 circle visible when only 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "2";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(false);
});
it("updates data-active-slot on card stack to the new active slot", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(stack.dataset.activeSlot).toBe("2");
});
});
// ------------------------------------------------------------------ //
// selectRole slot-circle fade-out //
// ------------------------------------------------------------------ //
describe("selectRole() slot-circle behaviour", () => {
let circle, stack;
beforeEach(() => {
// Gate-slot circle for slot 1 (active turn)
circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
// Card stack with active-slot=1 so selectRole() knows which circle to hide
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
testDiv.appendChild(stack);
spyOn(Tray, "placeCard");
});
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
expect(circle.classList.contains("role-assigned")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -242,9 +368,14 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => { describe("tray card after successful role selection", () => {
let guardConfirm; let guardConfirm, trayWrap;
beforeEach(() => { beforeEach(() => {
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
// Spy on Tray.placeCard: call the onComplete callback immediately. // Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb(); if (cb) cb();
@@ -261,13 +392,15 @@ describe("RoleSelect", () => {
it("calls Tray.placeCard() on success", async () => { it("calls Tray.placeCard() on success", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
expect(Tray.placeCard).toHaveBeenCalled(); expect(Tray.placeCard).toHaveBeenCalled();
}); });
it("passes the role code string to Tray.placeCard", async () => { it("passes the role code string to Tray.placeCard", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
const roleCode = Tray.placeCard.calls.mostRecent().args[0]; const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string"); expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0); expect(roleCode.length).toBeGreaterThan(0);
@@ -281,6 +414,27 @@ describe("RoleSelect", () => {
await Promise.resolve(); await Promise.resolve();
expect(Tray.placeCard).not.toHaveBeenCalled(); expect(Tray.placeCard).not.toHaveBeenCalled();
}); });
it("removes role-select-phase from tray wrap on successful pick", async () => {
guardConfirm();
await Promise.resolve();
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
});
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
// Add a seat element matching the first available role (PC)
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
testDiv.appendChild(seat);
guardConfirm();
await Promise.resolve(); // fetch resolves + placeCard fires
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -360,17 +514,20 @@ describe("RoleSelect", () => {
RoleSelect.openFan(); RoleSelect.openFan();
document.querySelector("#id_role_select .card").click(); document.querySelector("#id_role_select .card").click();
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] } detail: { active_slot: 2, starter_roles: [] }
})); }));
// Fire onComplete — deferred turn_changed should now run // Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
Tray._testFirePlaceCardComplete(); Tray._testFirePlaceCardComplete();
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
const activeSeat = testDiv.querySelector(".table-seat.active"); // Seat glow is JS-only (tray animation window); after deferred
expect(activeSeat && activeSeat.dataset.slot).toBe("2"); // handleTurnChanged runs, all seat glows are cleared.
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
it("turn_changed after animation completes is processed immediately", () => { it("turn_changed after animation completes is processed immediately", () => {
@@ -379,8 +536,8 @@ describe("RoleSelect", () => {
detail: { active_slot: 2, starter_roles: [] } detail: { active_slot: 2, starter_roles: [] }
})); }));
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
const activeSeat = testDiv.querySelector(".table-seat.active"); // Seats are not persistently glowed; all active cleared
expect(activeSeat && activeSeat.dataset.slot).toBe("2"); expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
}); });

View File

@@ -44,43 +44,6 @@
{% endif %} {% endif %}
</div> </div>
<div class="gate-slots row">
{% for slot in slots %}
<div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
data-slot="{{ slot.slot_number }}"
>
<span class="slot-number">{{ slot.slot_number }}</span>
{% if slot.gamer %}
<span class="slot-gamer">{{ slot.gamer.email }}</span>
{% else %}
<span class="slot-gamer">empty</span>
{% endif %}
{% if slot.status == 'RESERVED' and slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
{% if is_last_slot %}
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
{% else %}
<button type="submit" class="btn btn-confirm">OK</button>
{% endif %}
</form>
{% elif carte_active and slot.status == 'EMPTY' and slot.slot_number == carte_next_slot_number %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ slot.slot_number }}">OK</button>
</form>
{% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %}
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% if room.gate_status == 'OPEN' %} {% if room.gate_status == 'OPEN' %}
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents"> <form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
{% csrf_token %} {% csrf_token %}

View File

@@ -1,12 +1,31 @@
{% for pos in gate_positions %} <div class="position-strip">
<div class="table-position" data-slot="{{ pos.slot.slot_number }}" data-role-label="{{ pos.role_label }}"> {% for pos in gate_positions %}
<div class="position-body"> <div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
<i class="fa-solid fa-chair"></i> data-slot="{{ pos.slot.slot_number }}">
<span class="position-role-label">{{ pos.role_label }}</span> <span class="slot-number">{{ pos.slot.slot_number }}</span>
<div class="token-tooltip"> <span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
<h4>{% if pos.slot.gamer %}@{{ pos.slot.gamer.username|default:pos.slot.gamer.email }}{% else %}Empty Seat{% endif %}</h4> {% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
{% if is_last_slot %}
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
{% else %}
<button type="submit" class="btn btn-confirm">OK</button>
{% endif %}
</form>
{% elif carte_active and pos.slot.status == 'EMPTY' and pos.slot.slot_number == carte_next_slot_number %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ pos.slot.slot_number }}">OK</button>
</form>
{% elif carte_active and pos.slot.status == 'FILLED' and pos.slot.slot_number == carte_nvm_slot_number %}
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
</form>
{% endif %}
</div> </div>
</div> {% endfor %}
<i class="position-status-icon fa-solid {% if pos.slot.gamer %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
</div> </div>
{% endfor %}

View File

@@ -15,7 +15,8 @@
{% if room.table_status == "ROLE_SELECT" and card_stack_state %} {% if room.table_status == "ROLE_SELECT" and card_stack_state %}
<div class="card-stack" data-state="{{ card_stack_state }}" <div class="card-stack" data-state="{{ card_stack_state }}"
data-starter-roles="{{ starter_roles|join:',' }}" data-starter-roles="{{ starter_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}"> data-user-slots="{{ user_slots|join:',' }}"
data-active-slot="{{ active_slot }}">
{% if card_stack_state == "ineligible" %} {% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i> <i class="fa-solid fa-ban"></i>
{% endif %} {% endif %}
@@ -35,18 +36,19 @@
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for slot in room.gate_slots.all %} {% for pos in gate_positions %}
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}" <div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
data-slot="{{ slot.slot_number }}"> data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
<div class="seat-portrait">{{ slot.slot_number }}</div> <i class="fa-solid fa-chair"></i>
<div class="seat-card-arc"></div> <span class="seat-role-label">{{ pos.role_label }}</span>
<span class="seat-label"> {% if pos.role_label in starter_roles %}
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} <i class="position-status-icon fa-solid fa-circle-check"></i>
</span> {% else %}
<i class="position-status-icon fa-solid fa-ban"></i>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
</div> </div>
</div> </div>
@@ -74,10 +76,14 @@
</div> </div>
{% endif %} {% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %}
{% endif %} {% endif %}
<div id="id_tray_wrap"> {% if room.table_status %}
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" %} class="role-select-phase"{% endif %}>
<div id="id_tray_handle"> <div id="id_tray_handle">
<div id="id_tray_grip"></div> <div id="id_tray_grip"></div>
<button id="id_tray_btn" aria-label="Open seat tray"> <button id="id_tray_btn" aria-label="Open seat tray">
@@ -86,6 +92,7 @@
</div> </div>
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div> <div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div> </div>
{% endif %}
{% include "apps/gameboard/_partials/_room_gear.html" %} {% include "apps/gameboard/_partials/_room_gear.html" %}
</div> </div>
{% endblock content %} {% endblock content %}