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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user