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 appends a .card to #id_inv_role_card", () => { const card = document.querySelector("#id_role_select .card"); card.click(); expect(document.querySelector("#id_inv_role_card .card")).not.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" }) ); }); it("clicking a card results in exactly one card in inventory", () => { const card = document.querySelector("#id_role_select .card"); card.click(); expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1); }); }); // ------------------------------------------------------------------ // // Backdrop click // // ------------------------------------------------------------------ // describe("backdrop click", () => { it("closes the fan", () => { RoleSelect.openFan(); document.querySelector(".role-select-backdrop").click(); expect(document.getElementById("id_role_select")).toBeNull(); }); it("does not add a card to inventory", () => { RoleSelect.openFan(); document.querySelector(".role-select-backdrop").click(); expect(document.querySelector("#id_inv_role_card .card")).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 grid, guardConfirm; beforeEach(() => { // Minimal tray grid matching room.html structure grid = document.createElement("div"); grid.id = "id_tray_grid"; for (let i = 0; i < 8; i++) { const cell = document.createElement("div"); cell.className = "tray-cell"; grid.appendChild(cell); } testDiv.appendChild(grid); spyOn(Tray, "open"); // 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("prepends a .tray-role-card to #id_tray_grid on success", async () => { guardConfirm(); await Promise.resolve(); expect(grid.querySelector(".tray-role-card")).not.toBeNull(); }); it("tray-role-card is the first child of #id_tray_grid", async () => { guardConfirm(); await Promise.resolve(); expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true); }); it("tray-role-card carries the selected role as data-role", async () => { guardConfirm(); await Promise.resolve(); const trayCard = grid.querySelector(".tray-role-card"); expect(trayCard.dataset.role).toBeTruthy(); }); it("calls Tray.open() on success", async () => { guardConfirm(); await Promise.resolve(); expect(Tray.open).toHaveBeenCalled(); }); it("does not prepend a tray-role-card on server rejection", async () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: false }) ); guardConfirm(); await Promise.resolve(); expect(grid.querySelector(".tray-role-card")).toBeNull(); }); it("does not call Tray.open() on server rejection", async () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: false }) ); guardConfirm(); await Promise.resolve(); expect(Tray.open).not.toHaveBeenCalled(); }); it("grid grows by exactly 1 on success", async () => { const before = grid.children.length; guardConfirm(); await Promise.resolve(); expect(grid.children.length).toBe(before + 1); }); }); // ------------------------------------------------------------------ // // 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" }) ); }); it("appends a .card to #id_inv_role_card", () => { expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); }); }); 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("does not add a card to inventory", () => { expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); }); 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); }); }); }); });