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;
|
||||
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 + '"]');
|
||||
if (seatedPos) {
|
||||
seatedPos.classList.remove('active');
|
||||
var ban = seatedPos.querySelector('.fa-ban');
|
||||
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
||||
}
|
||||
if (_pendingTurnChange) {
|
||||
var ev = _pendingTurnChange;
|
||||
_pendingTurnChange = null;
|
||||
handleTurnChanged(ev);
|
||||
}
|
||||
});
|
||||
setTimeout(function () {
|
||||
Tray.placeCard(roleCode, function () {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user