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

@@ -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();
});
});