describe("RoleSelect", () => { let testDiv; beforeEach(() => { testDiv = document.createElement("div"); testDiv.innerHTML = `
`; 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(); }); }); // ------------------------------------------------------------------ // // room:roles_revealed event // // ------------------------------------------------------------------ // describe("room:roles_revealed event", () => { let reloadCalled; beforeEach(() => { reloadCalled = false; RoleSelect.setReload(() => { reloadCalled = true; }); }); afterEach(() => { RoleSelect.setReload(() => { window.location.reload(); }); }); it("triggers a page reload", () => { window.dispatchEvent(new CustomEvent("room:roles_revealed", { detail: {} })); expect(reloadCalled).toBe(true); }); }); // ------------------------------------------------------------------ // // room:turn_changed event // // ------------------------------------------------------------------ // describe("room:turn_changed event", () => { let stack; 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 = '
'; 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); }); 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(); }); it("moves .active to the newly active seat", () => { window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2 } })); expect( testDiv.querySelector(".table-seat.active").dataset.slot ).toBe("2"); }); 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(); }); }); // ------------------------------------------------------------------ // // 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", () => { let guardConfirm; beforeEach(() => { // Spy on Tray.placeCard: call the onComplete callback immediately. spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { if (cb) cb(); }); // 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(); }); it("calls Tray.placeCard() on success", async () => { guardConfirm(); await Promise.resolve(); expect(Tray.placeCard).toHaveBeenCalled(); }); it("passes the role code string to Tray.placeCard", async () => { guardConfirm(); await Promise.resolve(); const roleCode = Tray.placeCard.calls.mostRecent().args[0]; expect(typeof roleCode).toBe("string"); expect(roleCode.length).toBeGreaterThan(0); }); it("does not call Tray.placeCard() on server rejection", async () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: false }) ); guardConfirm(); await Promise.resolve(); expect(Tray.placeCard).not.toHaveBeenCalled(); }); }); // ------------------------------------------------------------------ // // WS turn_changed pause during animation // // ------------------------------------------------------------------ // 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; } ); }); 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(); guardConfirm(); 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(); }); it("turn_changed during animation does not immediately move the active seat", async () => { RoleSelect.openFan(); document.querySelector("#id_role_select .card").click(); guardConfirm(); await Promise.resolve(); 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 }); it("deferred turn_changed is processed when animation completes", async () => { RoleSelect.openFan(); document.querySelector("#id_role_select .card").click(); guardConfirm(); await Promise.resolve(); window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2, starter_roles: [] } })); // Fire onComplete — deferred turn_changed should now run Tray._testFirePlaceCardComplete(); const activeSeat = testDiv.querySelector(".table-seat.active"); expect(activeSeat && activeSeat.dataset.slot).toBe("2"); }); 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(); const activeSeat = testDiv.querySelector(".table-seat.active"); expect(activeSeat && activeSeat.dataset.slot).toBe("2"); }); }); // ------------------------------------------------------------------ // // 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
", () => { expect(guardMessage).toContain("
"); }); 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); }); }); }); });