describe("RoleSelect", () => { let testDiv; beforeEach(() => { RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange 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, trayWrap; 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); trayWrap = document.createElement("div"); trayWrap.id = "id_tray_wrap"; trayWrap.className = "role-select-phase"; testDiv.appendChild(trayWrap); }); 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("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("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); }); 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(); }); 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); }); }); // ------------------------------------------------------------------ // // 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, 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(); }); // 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(); // 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(); // 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); }); 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(); }); 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 = ''; 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); }); }); // ------------------------------------------------------------------ // // 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(); // 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 — post-tray delay (0 in tests) still uses setTimeout Tray._testFirePlaceCardComplete(); await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout // 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", () => { // 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(); // Seats are not persistently glowed; all active cleared expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); }); // ------------------------------------------------------------------ // // 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); }); }); }); });