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

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

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

View File

@@ -8,6 +8,15 @@ var RoleSelect = (function () {
var _animationPending = false;
var _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;
},
};
}());

View File

@@ -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 26 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):

View File

@@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
def _gate_positions(room):
"""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