role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -8,6 +8,15 @@ var RoleSelect = (function () {
|
||||
var _animationPending = false;
|
||||
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 = [
|
||||
{ code: "PC", name: "Player", element: "Fire" },
|
||||
{ code: "BC", name: "Builder", element: "Stone" },
|
||||
@@ -36,6 +45,10 @@ var RoleSelect = (function () {
|
||||
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
||||
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
|
||||
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||
if (stack) {
|
||||
@@ -45,12 +58,24 @@ var RoleSelect = (function () {
|
||||
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||
}
|
||||
|
||||
// Mark position as actively being seated (glow state)
|
||||
var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
||||
// Mark seat as actively being claimed (glow state)
|
||||
var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||
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();
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -61,34 +86,41 @@ var RoleSelect = (function () {
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
// Server rejected (role already taken) — undo optimistic update
|
||||
_animationPending = false;
|
||||
if (stack) {
|
||||
stack.dataset.starterRoles = stack.dataset.starterRoles
|
||||
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
||||
}
|
||||
openFan();
|
||||
} else {
|
||||
// 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.
|
||||
// Animate the role card into the tray: open, arc-in, force-close.
|
||||
// Any turn_changed that arrived while the fetch was in-flight is
|
||||
// queued in _pendingTurnChange and will run after onComplete.
|
||||
if (typeof Tray !== "undefined") {
|
||||
_animationPending = true;
|
||||
setTimeout(function () {
|
||||
Tray.placeCard(roleCode, function () {
|
||||
_animationPending = false;
|
||||
// Swap ban → check and clear glow on the seated position
|
||||
var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
||||
// Swap ban → check, clear glow, mark seat as confirmed
|
||||
var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||
if (seatedPos) {
|
||||
seatedPos.classList.remove('active');
|
||||
seatedPos.classList.add('role-confirmed');
|
||||
var ban = seatedPos.querySelector('.fa-ban');
|
||||
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) {
|
||||
var ev = _pendingTurnChange;
|
||||
_pendingTurnChange = null;
|
||||
handleTurnChanged(ev);
|
||||
}
|
||||
}, _postTrayDelay);
|
||||
});
|
||||
}, _placeCardDelay);
|
||||
} else {
|
||||
_animationPending = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -189,11 +221,39 @@ var RoleSelect = (function () {
|
||||
_turnChangedBeforeFetch = true;
|
||||
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||
|
||||
// Clear any stale .active glow from position indicators
|
||||
document.querySelectorAll('.table-position.active').forEach(function (p) {
|
||||
// Hide tray handle until the next player confirms their pick
|
||||
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');
|
||||
});
|
||||
|
||||
// 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]");
|
||||
if (stack) {
|
||||
// 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) {
|
||||
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);
|
||||
@@ -247,6 +305,8 @@ var RoleSelect = (function () {
|
||||
_testReset: function () {
|
||||
_animationPending = false;
|
||||
_pendingTurnChange = null;
|
||||
_placeCardDelay = 0;
|
||||
_postTrayDelay = 0;
|
||||
},
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -402,7 +402,9 @@ class RoleSelectRenderingTest(TestCase):
|
||||
response = self.client.get(
|
||||
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):
|
||||
self.client.force_login(self.founder)
|
||||
@@ -411,6 +413,21 @@ class RoleSelectRenderingTest(TestCase):
|
||||
)
|
||||
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):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
@@ -418,20 +435,66 @@ class RoleSelectRenderingTest(TestCase):
|
||||
)
|
||||
self.assertContains(response, "table-seat", count=6)
|
||||
|
||||
def test_active_table_seat_has_active_class(self):
|
||||
self.client.force_login(self.founder) # slot 1 is active
|
||||
response = self.client.get(
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'class="table-seat active"')
|
||||
|
||||
def test_inactive_table_seat_lacks_active_class(self):
|
||||
def test_table_seats_never_active_on_load(self):
|
||||
# Seat glow is JS-only (during tray animation); never server-rendered
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
self.url
|
||||
)
|
||||
# Slots 2–6 are not active, so at least one plain table-seat exists
|
||||
self.assertContains(response, 'class="table-seat"')
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, 'class="table-seat active"')
|
||||
|
||||
def test_assigned_seat_renders_role_confirmed_class(self):
|
||||
# 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):
|
||||
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"')
|
||||
|
||||
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):
|
||||
def setUp(self):
|
||||
|
||||
@@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
|
||||
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 [
|
||||
{"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")
|
||||
]
|
||||
|
||||
@@ -147,6 +154,7 @@ def _gate_context(room, user):
|
||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||
"carte_next_slot_number": carte_next_slot_number,
|
||||
"gate_positions": _gate_positions(room),
|
||||
"starter_roles": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +207,7 @@ def _role_select_context(room, user):
|
||||
) if user.is_authenticated else [],
|
||||
"active_slot": active_slot,
|
||||
"gate_positions": _gate_positions(room),
|
||||
"slots": room.gate_slots.order_by("slot_number"),
|
||||
}
|
||||
if room.table_status == Room.SIG_SELECT:
|
||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||
|
||||
@@ -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):
|
||||
self.browser.get(self.gate_url)
|
||||
# Gatekeeper modal is open
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
||||
)
|
||||
# Six .table-position elements are rendered outside the modal
|
||||
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position")
|
||||
self.assertEqual(len(positions), 6)
|
||||
for pos in positions:
|
||||
self.assertTrue(pos.is_displayed())
|
||||
# Six .gate-slot elements are rendered in .position-strip, outside modal
|
||||
strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||
slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot")
|
||||
self.assertEqual(len(slots), 6)
|
||||
for slot in slots:
|
||||
self.assertTrue(slot.is_displayed())
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test P2 — URL drops /gate/ after pick_roles #
|
||||
@@ -631,63 +631,57 @@ class PositionIndicatorsTest(FunctionalTest):
|
||||
"founder@test.io", "amigo@test.io", "bud@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.save()
|
||||
|
||||
# Navigating to the /gate/ URL should redirect to the plain room URL
|
||||
self.browser.get(self.gate_url)
|
||||
expected_url = (
|
||||
f"{self.live_server_url}/gameboard/room/{self.room.id}/"
|
||||
)
|
||||
expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/"
|
||||
self.wait_for(
|
||||
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):
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
def test_position_circles_outside_gatekeeper_modal(self):
|
||||
"""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.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():
|
||||
pos = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".table-position[data-slot='{slot_number}']"
|
||||
# No .gate-slot inside the modal
|
||||
modal_slots = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".gate-modal .gate-slot"
|
||||
)
|
||||
# Chair icon present
|
||||
self.assertTrue(pos.find_elements(By.CSS_SELECTOR, ".fa-chair"))
|
||||
# Role label attribute and visible text
|
||||
self.assertEqual(pos.get_attribute("data-role-label"), role_label)
|
||||
label_el = pos.find_element(By.CSS_SELECTOR, ".position-role-label")
|
||||
self.assertEqual(label_el.text.strip(), role_label)
|
||||
self.assertEqual(len(modal_slots), 0)
|
||||
# All 6 live in .position-strip
|
||||
strip_slots = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".position-strip .gate-slot"
|
||||
)
|
||||
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.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
|
||||
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position")
|
||||
for pos in positions:
|
||||
self.assertTrue(
|
||||
pos.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
||||
f"Expected .fa-ban on slot {pos.get_attribute('data-slot')}",
|
||||
for n in range(1, 7):
|
||||
slot_el = self.browser.find_element(
|
||||
By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']"
|
||||
)
|
||||
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):
|
||||
# Slot 1 is filled via ORM
|
||||
def test_filled_slot_shown_in_strip(self):
|
||||
from apps.epic.models import GateSlot
|
||||
slot = self.room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = self.founder
|
||||
@@ -696,10 +690,13 @@ class PositionIndicatorsTest(FunctionalTest):
|
||||
|
||||
self.browser.get(self.gate_url)
|
||||
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(
|
||||
By.CSS_SELECTOR, ".table-position[data-slot='1']"
|
||||
slot1 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']"
|
||||
)
|
||||
self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check"))
|
||||
self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban"))
|
||||
self.assertIn("filled", slot1.get_attribute("class"))
|
||||
slot2 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']"
|
||||
)
|
||||
self.assertIn("empty", slot2.get_attribute("class"))
|
||||
|
||||
@@ -445,12 +445,14 @@ class RoleSelectTest(FunctionalTest):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 7 — All roles revealed simultaneously after all gamers select #
|
||||
# Test 8a — Hex 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")
|
||||
room = Room.objects.create(name="Reveal Test", owner=founder)
|
||||
room = Room.objects.create(name="Seat Label Test", owner=founder)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
@@ -462,38 +464,24 @@ class RoleSelectTest(FunctionalTest):
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
|
||||
# Assign all roles via ORM (simulating all gamers having chosen)
|
||||
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)
|
||||
expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
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 8a — Position glows while role card is being placed #
|
||||
# Test 8b — Hex seats show .fa-ban when empty #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_position_glows_when_role_card_confirmed(self):
|
||||
"""Immediately after confirming a role pick, the matching
|
||||
.table-position should receive .active (the glow state) before
|
||||
the tray animation completes."""
|
||||
def test_seats_show_ban_icon_when_empty(self):
|
||||
"""All 6 seats carry .fa-ban before any role has been chosen."""
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
room = Room.objects.create(name="Position Glow Test", owner=founder)
|
||||
room = Room.objects.create(name="Seat Ban Test", owner=founder)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
@@ -509,32 +497,26 @@ class RoleSelectTest(FunctionalTest):
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
|
||||
# Open fan, click first card (PC), confirm guard
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||
)
|
||||
).click()
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||
self.confirm_guard()
|
||||
|
||||
# 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"
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||||
)
|
||||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||||
self.assertEqual(len(seats), 6)
|
||||
for seat in seats:
|
||||
self.assertTrue(
|
||||
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
||||
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 8b — Position shows check icon after tray sequence ends #
|
||||
# Test 8c — Hex seat gets .fa-circle-check after role selected #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_position_gets_check_when_tray_sequence_ends(self):
|
||||
"""After the tray arc-in animation completes and the tray closes,
|
||||
the PC .table-position should show .fa-circle-check and no .fa-ban."""
|
||||
def test_seat_gets_check_after_role_selected(self):
|
||||
"""After confirming a role pick the corresponding hex seat should
|
||||
show .fa-circle-check and lose .fa-ban."""
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
room = Room.objects.create(name="Position Check Test", owner=founder)
|
||||
room = Room.objects.create(name="Seat Check Test", owner=founder)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
@@ -550,7 +532,7 @@ class RoleSelectTest(FunctionalTest):
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
|
||||
# Open fan, pick PC card, confirm guard
|
||||
# Open fan, pick first card (PC), confirm guard
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
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.confirm_guard()
|
||||
|
||||
# Wait for tray animation to complete (tray closes)
|
||||
# Wait for tray animation to complete
|
||||
self.wait_for(
|
||||
lambda: self.assertFalse(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should close after arc-in sequence"
|
||||
"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(
|
||||
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(
|
||||
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,
|
||||
)
|
||||
@@ -745,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
)
|
||||
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.browser.get(room_url)
|
||||
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
|
||||
@@ -764,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||
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(
|
||||
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:
|
||||
self.browser2.quit()
|
||||
|
||||
@@ -836,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")))
|
||||
|
||||
# Turn advances via WS — seat 2 becomes active.
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
||||
))
|
||||
|
||||
# Tray must be closed: forceClose() fires in handleTurnChanged.
|
||||
self.assertFalse(
|
||||
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||
self.wait_for(lambda: self.assertFalse(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be closed after turn advances"
|
||||
)
|
||||
))
|
||||
|
||||
def test_landscape_tray_closes_on_turn_advance(self):
|
||||
"""Landscape: role card at leftmost grid square; tray closes when
|
||||
@@ -869,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")))
|
||||
|
||||
# Turn advances via WS — seat 2 becomes active.
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
||||
))
|
||||
|
||||
# Tray must be closed.
|
||||
self.assertFalse(
|
||||
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||
self.wait_for(lambda: self.assertFalse(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be closed after turn advances"
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,20 @@ class TrayTest(FunctionalTest):
|
||||
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
|
||||
""", 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"):
|
||||
founder, _ = User.objects.get_or_create(email=founder_email)
|
||||
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
||||
@@ -262,7 +276,7 @@ class TrayTest(FunctionalTest):
|
||||
|
||||
@tag('two-browser')
|
||||
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.browser.get(self._room_url(room))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
|
||||
let testDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div class="room-page"
|
||||
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("room:turn_changed event", () => {
|
||||
let stack;
|
||||
let stack, trayWrap;
|
||||
|
||||
beforeEach(() => {
|
||||
// Six table seats, slot 1 starts active
|
||||
@@ -169,6 +170,11 @@ describe("RoleSelect", () => {
|
||||
stack.dataset.userSlots = "1";
|
||||
stack.dataset.starterRoles = "";
|
||||
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", () => {
|
||||
@@ -179,13 +185,19 @@ describe("RoleSelect", () => {
|
||||
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", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(
|
||||
testDiv.querySelector(".table-seat.active").dataset.slot
|
||||
).toBe("2");
|
||||
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -231,6 +243,119 @@ describe("RoleSelect", () => {
|
||||
stack.click();
|
||||
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", () => {
|
||||
let guardConfirm;
|
||||
let guardConfirm, trayWrap;
|
||||
|
||||
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.
|
||||
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||
if (cb) cb();
|
||||
@@ -261,13 +391,15 @@ describe("RoleSelect", () => {
|
||||
|
||||
it("calls Tray.placeCard() on success", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve(); // flush fetch .then()
|
||||
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||
expect(Tray.placeCard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the role code string to Tray.placeCard", async () => {
|
||||
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];
|
||||
expect(typeof roleCode).toBe("string");
|
||||
expect(roleCode.length).toBeGreaterThan(0);
|
||||
@@ -281,6 +413,27 @@ describe("RoleSelect", () => {
|
||||
await Promise.resolve();
|
||||
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();
|
||||
document.querySelector("#id_role_select .card").click();
|
||||
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", {
|
||||
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();
|
||||
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||
|
||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
||||
// Seat glow is JS-only (tray animation window); after deferred
|
||||
// handleTurnChanged runs, all seat glows are cleared.
|
||||
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||
});
|
||||
|
||||
it("turn_changed after animation completes is processed immediately", () => {
|
||||
@@ -379,8 +535,8 @@ describe("RoleSelect", () => {
|
||||
detail: { active_slot: 2, starter_roles: [] }
|
||||
}));
|
||||
expect(Tray.forceClose).toHaveBeenCalled();
|
||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
||||
// Seats are not persistently glowed; all active cleared
|
||||
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -317,24 +255,6 @@ html:has(.gate-backdrop) {
|
||||
}
|
||||
|
||||
.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-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.
|
||||
$pos-d: 110px;
|
||||
$pos-d-x: round($pos-d * 0.5); // 55px
|
||||
$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;
|
||||
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;
|
||||
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;
|
||||
flex-direction: column;
|
||||
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)
|
||||
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
|
||||
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
|
||||
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
||||
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
||||
&.role-assigned {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fa-chair {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(var(--secUser), 0.4);
|
||||
&:has(.drop-token-btn) {
|
||||
background: rgba(var(--terUser), 1);
|
||||
border-color: rgba(var(--ninUser), 0.5);
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.position-role-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(var(--secUser), 0.5);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.position-strip {
|
||||
gap: round($gate-gap * 0.3);
|
||||
|
||||
.position-status-icon {
|
||||
font-size: 0.65rem;
|
||||
&.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));
|
||||
.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 {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 0.25rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
// Centre the element on its anchor point
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
|
||||
// Clockwise from top — slot drop order during ROLE_SELECT
|
||||
&[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); }
|
||||
&[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
|
||||
&[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
|
||||
&[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); }
|
||||
&[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
|
||||
&[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
|
||||
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
|
||||
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
|
||||
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
|
||||
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-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 {
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
h3 { font-size: 0.85rem; margin: 0.5rem 0; }
|
||||
|
||||
@@ -24,6 +24,10 @@ $handle-rect-h: 72px;
|
||||
$handle-exposed: 48px;
|
||||
$handle-r: 1rem;
|
||||
|
||||
#id_tray_wrap.role-select-phase {
|
||||
#id_tray_handle { visibility: hidden; pointer-events: none; }
|
||||
}
|
||||
|
||||
#id_tray_wrap {
|
||||
position: fixed;
|
||||
// left set by JS: closed = vw - handleW; open = vw - wrapW
|
||||
@@ -37,11 +41,11 @@ $handle-r: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
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; }
|
||||
&.wobble { animation: tray-wobble 0.45s ease; }
|
||||
&.snap { animation: tray-snap 0.30s ease; }
|
||||
&.wobble { animation: tray-wobble .45s ease; }
|
||||
&.snap { animation: tray-snap 1.0s ease; }
|
||||
}
|
||||
|
||||
#id_tray_handle {
|
||||
@@ -134,7 +138,7 @@ $handle-r: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.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;
|
||||
top: auto; // JS controls style.top for the Y-axis slide
|
||||
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; }
|
||||
&.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 {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
|
||||
let testDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div class="room-page"
|
||||
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("room:turn_changed event", () => {
|
||||
let stack;
|
||||
let stack, trayWrap;
|
||||
|
||||
beforeEach(() => {
|
||||
// Six table seats, slot 1 starts active
|
||||
@@ -169,6 +170,12 @@ describe("RoleSelect", () => {
|
||||
stack.dataset.userSlots = "1";
|
||||
stack.dataset.starterRoles = "";
|
||||
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", () => {
|
||||
@@ -179,13 +186,19 @@ describe("RoleSelect", () => {
|
||||
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", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(
|
||||
testDiv.querySelector(".table-seat.active").dataset.slot
|
||||
).toBe("2");
|
||||
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -231,6 +244,119 @@ describe("RoleSelect", () => {
|
||||
stack.click();
|
||||
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", () => {
|
||||
let guardConfirm;
|
||||
let guardConfirm, trayWrap;
|
||||
|
||||
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.
|
||||
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||
if (cb) cb();
|
||||
@@ -261,13 +392,15 @@ describe("RoleSelect", () => {
|
||||
|
||||
it("calls Tray.placeCard() on success", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve(); // flush fetch .then()
|
||||
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||
expect(Tray.placeCard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the role code string to Tray.placeCard", async () => {
|
||||
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];
|
||||
expect(typeof roleCode).toBe("string");
|
||||
expect(roleCode.length).toBeGreaterThan(0);
|
||||
@@ -281,6 +414,27 @@ describe("RoleSelect", () => {
|
||||
await Promise.resolve();
|
||||
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();
|
||||
document.querySelector("#id_role_select .card").click();
|
||||
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", {
|
||||
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();
|
||||
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||
|
||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
||||
// Seat glow is JS-only (tray animation window); after deferred
|
||||
// handleTurnChanged runs, all seat glows are cleared.
|
||||
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||
});
|
||||
|
||||
it("turn_changed after animation completes is processed immediately", () => {
|
||||
@@ -379,8 +536,8 @@ describe("RoleSelect", () => {
|
||||
detail: { active_slot: 2, starter_roles: [] }
|
||||
}));
|
||||
expect(Tray.forceClose).toHaveBeenCalled();
|
||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
||||
// Seats are not persistently glowed; all active cleared
|
||||
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -44,43 +44,6 @@
|
||||
{% endif %}
|
||||
</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' %}
|
||||
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
<div class="position-strip">
|
||||
{% for pos in gate_positions %}
|
||||
<div class="table-position" data-slot="{{ pos.slot.slot_number }}" data-role-label="{{ pos.role_label }}">
|
||||
<div class="position-body">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="position-role-label">{{ pos.role_label }}</span>
|
||||
<div class="token-tooltip">
|
||||
<h4>{% if pos.slot.gamer %}@{{ pos.slot.gamer.username|default:pos.slot.gamer.email }}{% else %}Empty Seat{% endif %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<i class="position-status-icon fa-solid {% if pos.slot.gamer %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
||||
<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 %}"
|
||||
data-slot="{{ pos.slot.slot_number }}">
|
||||
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
||||
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
|
||||
<div class="card-stack" data-state="{{ card_stack_state }}"
|
||||
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" %}
|
||||
<i class="fa-solid fa-ban"></i>
|
||||
{% endif %}
|
||||
@@ -35,18 +36,19 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for slot in room.gate_slots.all %}
|
||||
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}"
|
||||
data-slot="{{ slot.slot_number }}">
|
||||
<div class="seat-portrait">{{ slot.slot_number }}</div>
|
||||
<div class="seat-card-arc"></div>
|
||||
<span class="seat-label">
|
||||
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %}
|
||||
</span>
|
||||
{% for pos in gate_positions %}
|
||||
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
|
||||
data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-role-label">{{ pos.role_label }}</span>
|
||||
{% if pos.role_label in starter_roles %}
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
{% else %}
|
||||
<i class="position-status-icon fa-solid fa-ban"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_table_positions.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,10 +76,14 @@
|
||||
</div>
|
||||
{% 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" %}
|
||||
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||
{% 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_grip"></div>
|
||||
<button id="id_tray_btn" aria-label="Open seat tray">
|
||||
@@ -86,6 +92,7 @@
|
||||
</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>
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user