2026-03-29 23:39:03 -04:00
|
|
|
describe("RoleSelect", () => {
|
|
|
|
|
let testDiv;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-03-31 00:01:04 -04:00
|
|
|
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
2026-03-29 23:39:03 -04:00
|
|
|
testDiv = document.createElement("div");
|
|
|
|
|
testDiv.innerHTML = `
|
|
|
|
|
<div class="room-page"
|
|
|
|
|
data-select-role-url="/epic/room/test-uuid/select-role">
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
document.body.appendChild(testDiv);
|
|
|
|
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
|
|
|
|
Promise.resolve({ ok: true })
|
|
|
|
|
);
|
|
|
|
|
// Default stub: auto-confirm so existing card-click tests pass unchanged.
|
|
|
|
|
// The click-guard integration describe overrides this with a capturing spy.
|
|
|
|
|
window.showGuard = (_anchor, _msg, onConfirm) => onConfirm && onConfirm();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
RoleSelect.closeFan();
|
|
|
|
|
testDiv.remove();
|
|
|
|
|
delete window.showGuard;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// openFan() //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("openFan()", () => {
|
|
|
|
|
it("creates .role-select-backdrop in the DOM", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("creates #id_role_select inside the backdrop", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
expect(document.getElementById("id_role_select")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders exactly 6 .card elements", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
const cards = document.querySelectorAll("#id_role_select .card");
|
|
|
|
|
expect(cards.length).toBe(6);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not open a second backdrop if already open", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
expect(document.querySelectorAll(".role-select-backdrop").length).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// closeFan() //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("closeFan()", () => {
|
|
|
|
|
it("removes .role-select-backdrop from the DOM", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
RoleSelect.closeFan();
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes #id_role_select from the DOM", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
RoleSelect.closeFan();
|
|
|
|
|
expect(document.getElementById("id_role_select")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not throw if no fan is open", () => {
|
|
|
|
|
expect(() => RoleSelect.closeFan()).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// Card interactions //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("card interactions", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("mouseenter adds .flipped to the card", () => {
|
|
|
|
|
const card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("mouseleave removes .flipped from the card", () => {
|
|
|
|
|
const card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking a card closes the fan", () => {
|
|
|
|
|
const card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.click();
|
|
|
|
|
expect(document.getElementById("id_role_select")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking a card POSTs to the select_role URL", () => {
|
|
|
|
|
const card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.click();
|
|
|
|
|
expect(window.fetch).toHaveBeenCalledWith(
|
|
|
|
|
"/epic/room/test-uuid/select-role",
|
|
|
|
|
jasmine.objectContaining({ method: "POST" })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// Backdrop click //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("backdrop click", () => {
|
|
|
|
|
it("closes the fan", () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
document.querySelector(".role-select-backdrop").click();
|
|
|
|
|
expect(document.getElementById("id_role_select")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
2026-04-04 14:54:54 -04:00
|
|
|
// room:all_roles_filled event //
|
2026-03-29 23:39:03 -04:00
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
2026-04-04 14:54:54 -04:00
|
|
|
describe("room:all_roles_filled event", () => {
|
|
|
|
|
let pickSigsWrap;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
pickSigsWrap = document.createElement("div");
|
|
|
|
|
pickSigsWrap.id = "id_pick_sigs_wrap";
|
|
|
|
|
pickSigsWrap.style.display = "none";
|
|
|
|
|
testDiv.appendChild(pickSigsWrap);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows #id_pick_sigs_wrap", () => {
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:all_roles_filled", { detail: {} }));
|
|
|
|
|
expect(pickSigsWrap.style.display).toBe("");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// room:sig_select_started event //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("room:sig_select_started event", () => {
|
2026-03-29 23:39:03 -04:00
|
|
|
let reloadCalled;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
reloadCalled = false;
|
|
|
|
|
RoleSelect.setReload(() => { reloadCalled = true; });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
RoleSelect.setReload(() => { window.location.reload(); });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("triggers a page reload", () => {
|
2026-04-04 14:54:54 -04:00
|
|
|
window.dispatchEvent(new CustomEvent("room:sig_select_started", { detail: {} }));
|
2026-03-29 23:39:03 -04:00
|
|
|
expect(reloadCalled).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// room:turn_changed event //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("room:turn_changed event", () => {
|
2026-03-31 00:01:04 -04:00
|
|
|
let stack, trayWrap;
|
2026-03-29 23:39:03 -04:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
// Six table seats, slot 1 starts active
|
|
|
|
|
for (let i = 1; i <= 6; i++) {
|
|
|
|
|
const seat = document.createElement("div");
|
|
|
|
|
seat.className = "table-seat" + (i === 1 ? " active" : "");
|
|
|
|
|
seat.dataset.slot = String(i);
|
|
|
|
|
seat.innerHTML = '<div class="seat-card-arc"></div>';
|
|
|
|
|
testDiv.appendChild(seat);
|
|
|
|
|
}
|
|
|
|
|
stack = document.createElement("div");
|
|
|
|
|
stack.className = "card-stack";
|
|
|
|
|
stack.dataset.state = "ineligible";
|
|
|
|
|
stack.dataset.userSlots = "1";
|
|
|
|
|
stack.dataset.starterRoles = "";
|
|
|
|
|
testDiv.appendChild(stack);
|
2026-03-31 00:01:04 -04:00
|
|
|
|
|
|
|
|
trayWrap = document.createElement("div");
|
|
|
|
|
trayWrap.id = "id_tray_wrap";
|
|
|
|
|
trayWrap.className = "role-select-phase";
|
|
|
|
|
testDiv.appendChild(trayWrap);
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls Tray.forceClose() on turn change", () => {
|
|
|
|
|
spyOn(Tray, "forceClose");
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2 }
|
|
|
|
|
}));
|
|
|
|
|
expect(Tray.forceClose).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-31 00:01:04 -04:00
|
|
|
it("clears .active from all seats on turn change", () => {
|
2026-03-29 23:39:03 -04:00
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2 }
|
|
|
|
|
}));
|
2026-03-31 00:01:04 -04:00
|
|
|
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);
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .active from the previously active seat", () => {
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2 }
|
|
|
|
|
}));
|
|
|
|
|
expect(
|
|
|
|
|
testDiv.querySelector(".table-seat[data-slot='1']").classList.contains("active")
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets data-state to eligible when active_slot matches user slot", () => {
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 1 }
|
|
|
|
|
}));
|
|
|
|
|
expect(stack.dataset.state).toBe("eligible");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets data-state to ineligible when active_slot does not match", () => {
|
|
|
|
|
stack.dataset.state = "eligible";
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2 }
|
|
|
|
|
}));
|
|
|
|
|
expect(stack.dataset.state).toBe("ineligible");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking stack opens fan when newly eligible", () => {
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 1 }
|
|
|
|
|
}));
|
|
|
|
|
stack.click();
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("clicking stack does not open fan when ineligible", () => {
|
|
|
|
|
// Make eligible first (adds listener), then flip back to ineligible
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 1 }
|
|
|
|
|
}));
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2 }
|
|
|
|
|
}));
|
|
|
|
|
stack.click();
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
|
|
|
|
});
|
2026-03-31 00:01:04 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// Tray card placement after successful role selection //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// The tray-role-card is created in the fetch .then() callback, so //
|
|
|
|
|
// these tests are async — await Promise.resolve() flushes the //
|
|
|
|
|
// microtask queue before asserting. //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("tray card after successful role selection", () => {
|
2026-03-31 00:01:04 -04:00
|
|
|
let guardConfirm, trayWrap;
|
2026-03-29 23:39:03 -04:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-03-31 00:01:04 -04:00
|
|
|
trayWrap = document.createElement("div");
|
|
|
|
|
trayWrap.id = "id_tray_wrap";
|
|
|
|
|
trayWrap.className = "role-select-phase";
|
|
|
|
|
testDiv.appendChild(trayWrap);
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
|
|
|
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
|
|
|
|
if (cb) cb();
|
|
|
|
|
});
|
2026-03-29 23:39:03 -04:00
|
|
|
|
|
|
|
|
// Capturing guard spy — holds onConfirm so we can fire it per-test
|
|
|
|
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
|
|
|
|
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
document.querySelector("#id_role_select .card").click();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("calls Tray.placeCard() on success", async () => {
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
2026-03-31 00:01:04 -04:00
|
|
|
await Promise.resolve(); // flush fetch .then()
|
|
|
|
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
2026-03-30 16:42:23 -04:00
|
|
|
expect(Tray.placeCard).toHaveBeenCalled();
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("passes the role code string to Tray.placeCard", async () => {
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
2026-03-31 00:01:04 -04:00
|
|
|
await Promise.resolve(); // flush fetch .then()
|
|
|
|
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
2026-03-30 16:42:23 -04:00
|
|
|
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
|
|
|
|
expect(typeof roleCode).toBe("string");
|
|
|
|
|
expect(roleCode.length).toBeGreaterThan(0);
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("does not call Tray.placeCard() on server rejection", async () => {
|
|
|
|
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
|
|
|
|
Promise.resolve({ ok: false })
|
|
|
|
|
);
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
|
|
|
|
await Promise.resolve();
|
2026-03-30 16:42:23 -04:00
|
|
|
expect(Tray.placeCard).not.toHaveBeenCalled();
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
2026-03-31 00:01:04 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-03-30 16:42:23 -04:00
|
|
|
});
|
2026-03-29 23:39:03 -04:00
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// WS turn_changed pause during animation //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
2026-03-29 23:39:03 -04:00
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
describe("WS turn_changed pause during placeCard animation", () => {
|
|
|
|
|
let stack, guardConfirm;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
// Six table seats, slot 1 starts active
|
|
|
|
|
for (let i = 1; i <= 6; i++) {
|
|
|
|
|
const seat = document.createElement("div");
|
|
|
|
|
seat.className = "table-seat" + (i === 1 ? " active" : "");
|
|
|
|
|
seat.dataset.slot = String(i);
|
|
|
|
|
testDiv.appendChild(seat);
|
|
|
|
|
}
|
|
|
|
|
stack = document.createElement("div");
|
|
|
|
|
stack.className = "card-stack";
|
|
|
|
|
stack.dataset.state = "ineligible";
|
|
|
|
|
stack.dataset.userSlots = "1";
|
|
|
|
|
stack.dataset.starterRoles = "";
|
|
|
|
|
testDiv.appendChild(stack);
|
|
|
|
|
|
|
|
|
|
const grid = document.createElement("div");
|
|
|
|
|
grid.id = "id_tray_grid";
|
|
|
|
|
testDiv.appendChild(grid);
|
|
|
|
|
|
|
|
|
|
// placeCard spy that holds the onComplete callback
|
|
|
|
|
let heldCallback = null;
|
|
|
|
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
|
|
|
|
heldCallback = cb; // don't call immediately — simulate animation in-flight
|
|
|
|
|
});
|
|
|
|
|
spyOn(Tray, "forceClose");
|
|
|
|
|
|
|
|
|
|
// Expose heldCallback so tests can fire it
|
|
|
|
|
Tray._testFirePlaceCardComplete = () => {
|
|
|
|
|
if (heldCallback) heldCallback();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
|
|
|
|
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
|
2026-03-29 23:39:03 -04:00
|
|
|
);
|
2026-03-30 16:42:23 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
delete Tray._testFirePlaceCardComplete;
|
|
|
|
|
RoleSelect._testReset();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
document.querySelector("#id_role_select .card").click();
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
2026-03-30 16:42:23 -04:00
|
|
|
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2, starter_roles: [] }
|
|
|
|
|
}));
|
|
|
|
|
expect(Tray.forceClose).not.toHaveBeenCalled();
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("turn_changed during animation does not immediately move the active seat", async () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
document.querySelector("#id_role_select .card").click();
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
|
|
|
|
await Promise.resolve();
|
2026-03-30 16:42:23 -04:00
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2, starter_roles: [] }
|
|
|
|
|
}));
|
|
|
|
|
const activeSeat = testDiv.querySelector(".table-seat.active");
|
|
|
|
|
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("deferred turn_changed is processed when animation completes", async () => {
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
document.querySelector("#id_role_select .card").click();
|
2026-03-29 23:39:03 -04:00
|
|
|
guardConfirm();
|
2026-03-31 00:01:04 -04:00
|
|
|
await Promise.resolve(); // flush fetch .then()
|
|
|
|
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
|
2026-03-30 16:42:23 -04:00
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2, starter_roles: [] }
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-31 00:01:04 -04:00
|
|
|
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
|
2026-03-30 16:42:23 -04:00
|
|
|
Tray._testFirePlaceCardComplete();
|
2026-03-31 00:01:04 -04:00
|
|
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
2026-03-30 16:42:23 -04:00
|
|
|
|
2026-03-31 00:01:04 -04:00
|
|
|
// Seat glow is JS-only (tray animation window); after deferred
|
|
|
|
|
// handleTurnChanged runs, all seat glows are cleared.
|
|
|
|
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
2026-03-30 16:42:23 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("turn_changed after animation completes is processed immediately", () => {
|
|
|
|
|
// No animation in flight — turn_changed should run right away
|
|
|
|
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
|
|
|
|
detail: { active_slot: 2, starter_roles: [] }
|
|
|
|
|
}));
|
|
|
|
|
expect(Tray.forceClose).toHaveBeenCalled();
|
2026-03-31 00:01:04 -04:00
|
|
|
// Seats are not persistently glowed; all active cleared
|
|
|
|
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// click-guard integration //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
// NOTE: cascade prevention (outside-click on backdrop not closing the //
|
|
|
|
|
// fan while the guard is active) relies on the guard portal's capture- //
|
|
|
|
|
// phase stopPropagation, which lives in base.html and requires //
|
|
|
|
|
// integration testing. The callback contract is fully covered below. //
|
|
|
|
|
// ------------------------------------------------------------------ //
|
|
|
|
|
|
|
|
|
|
describe("click-guard integration", () => {
|
|
|
|
|
let guardAnchor, guardMessage, guardConfirm, guardDismiss;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
|
|
|
|
(anchor, message, onConfirm, onDismiss) => {
|
|
|
|
|
guardAnchor = anchor;
|
|
|
|
|
guardMessage = message;
|
|
|
|
|
guardConfirm = onConfirm;
|
|
|
|
|
guardDismiss = onDismiss;
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
RoleSelect.openFan();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("clicking a card", () => {
|
|
|
|
|
let card;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.click();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls window.showGuard", () => {
|
|
|
|
|
expect(window.showGuard).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("passes the card element as the anchor", () => {
|
|
|
|
|
expect(guardAnchor).toBe(card);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("message contains the role name", () => {
|
|
|
|
|
const roleName = card.querySelector(".card-role-name").textContent.trim();
|
|
|
|
|
expect(guardMessage).toContain(roleName);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("message contains the role code", () => {
|
|
|
|
|
expect(guardMessage).toContain(card.dataset.role);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("message contains a <br>", () => {
|
|
|
|
|
expect(guardMessage).toContain("<br>");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not immediately close the fan", () => {
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not immediately POST to the select_role URL", () => {
|
|
|
|
|
expect(window.fetch).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .flipped to the card", () => {
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .guard-active to the card", () => {
|
|
|
|
|
expect(card.classList.contains("guard-active")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("mouseleave does not remove .flipped while guard is active", () => {
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("confirming the guard (OK)", () => {
|
|
|
|
|
let card;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.click();
|
|
|
|
|
guardConfirm();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .guard-active from the card", () => {
|
|
|
|
|
expect(card.classList.contains("guard-active")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("closes the fan", () => {
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("POSTs to the select_role URL", () => {
|
|
|
|
|
expect(window.fetch).toHaveBeenCalledWith(
|
|
|
|
|
"/epic/room/test-uuid/select-role",
|
|
|
|
|
jasmine.objectContaining({ method: "POST" })
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("dismissing the guard (NVM or outside click)", () => {
|
|
|
|
|
let card;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
card = document.querySelector("#id_role_select .card");
|
|
|
|
|
card.click();
|
|
|
|
|
guardDismiss();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .guard-active from the card", () => {
|
|
|
|
|
expect(card.classList.contains("guard-active")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .flipped from the card", () => {
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves the fan open", () => {
|
|
|
|
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not POST to the select_role URL", () => {
|
|
|
|
|
expect(window.fetch).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("restores normal mouseleave behaviour on the card", () => {
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
|
|
|
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
|
|
|
|
expect(card.classList.contains("flipped")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|