// ── TraySpec.js ─────────────────────────────────────────────────────────────── // // Unit specs for tray.js — the per-seat, per-room slide-out panel anchored // to the right edge of the viewport. // // DOM contract assumed by the module: // #id_tray_wrap — outermost container; JS sets style.left for positioning // #id_tray_btn — the drawer-handle button // #id_tray — the tray panel (hidden by default) // // Public API under test: // Tray.init() — compute bounds, apply vertical bounds, attach listeners // Tray.open() — reveal tray, animate wrap to minLeft // Tray.close() — hide tray, animate wrap to maxLeft // Tray.isOpen() — state predicate // Tray.reset() — restore initial state (for afterEach) // // Drag model: tray follows pointer in real-time; position persists on release. // Any leftward drag opens the tray. // Drag > 10px suppresses the subsequent click event. // // ───────────────────────────────────────────────────────────────────────────── describe("Tray", () => { let btn, tray, wrap; beforeEach(() => { wrap = document.createElement("div"); wrap.id = "id_tray_wrap"; btn = document.createElement("button"); btn.id = "id_tray_btn"; tray = document.createElement("div"); tray.id = "id_tray"; tray.style.display = "none"; wrap.appendChild(btn); document.body.appendChild(wrap); document.body.appendChild(tray); Tray._testSetLandscape(false); // force portrait regardless of window size Tray.init(); }); afterEach(() => { Tray.reset(); wrap.remove(); tray.remove(); }); // ---------------------------------------------------------------------- // // open() // // ---------------------------------------------------------------------- // describe("open()", () => { it("makes #id_tray visible", () => { Tray.open(); expect(tray.style.display).not.toBe("none"); }); it("adds .open to #id_tray_btn", () => { Tray.open(); expect(btn.classList.contains("open")).toBe(true); }); it("sets wrap left to minLeft (0)", () => { Tray.open(); expect(wrap.style.left).toBe("0px"); }); it("calling open() twice does not duplicate .open", () => { Tray.open(); Tray.open(); const openCount = btn.className.split(" ").filter(c => c === "open").length; expect(openCount).toBe(1); }); }); // ---------------------------------------------------------------------- // // close() // // ---------------------------------------------------------------------- // describe("close()", () => { beforeEach(() => Tray.open()); it("hides #id_tray after slide + snap both complete", () => { Tray.close(); wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); wrap.dispatchEvent(new Event("animationend")); expect(tray.style.display).toBe("none"); }); it("adds .snap to wrap after slide transition completes", () => { Tray.close(); wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); expect(wrap.classList.contains("snap")).toBe(true); }); it("removes .snap from wrap once animationend fires", () => { Tray.close(); wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" })); wrap.dispatchEvent(new Event("animationend")); expect(wrap.classList.contains("snap")).toBe(false); }); it("removes .open from #id_tray_btn", () => { Tray.close(); expect(btn.classList.contains("open")).toBe(false); }); it("sets wrap left to maxLeft", () => { Tray.close(); expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0); }); it("does not throw if already closed", () => { Tray.close(); expect(() => Tray.close()).not.toThrow(); }); }); // ---------------------------------------------------------------------- // // isOpen() // // ---------------------------------------------------------------------- // describe("isOpen()", () => { it("returns false by default", () => { expect(Tray.isOpen()).toBe(false); }); it("returns true after open()", () => { Tray.open(); expect(Tray.isOpen()).toBe(true); }); it("returns false after close()", () => { Tray.open(); Tray.close(); expect(Tray.isOpen()).toBe(false); }); }); // ---------------------------------------------------------------------- // // Click when closed — wobble wrap, do not open // // ---------------------------------------------------------------------- // describe("clicking btn when closed", () => { it("adds .wobble to wrap", () => { btn.click(); expect(wrap.classList.contains("wobble")).toBe(true); }); it("does not open the tray", () => { btn.click(); expect(Tray.isOpen()).toBe(false); }); it("removes .wobble once animationend fires on wrap", () => { btn.click(); wrap.dispatchEvent(new Event("animationend")); expect(wrap.classList.contains("wobble")).toBe(false); }); }); // ---------------------------------------------------------------------- // // Click when open — close, no wobble // // ---------------------------------------------------------------------- // describe("clicking btn when open", () => { beforeEach(() => Tray.open()); it("closes the tray", () => { btn.click(); expect(Tray.isOpen()).toBe(false); }); it("does not add .wobble", () => { btn.click(); expect(wrap.classList.contains("wobble")).toBe(false); }); }); // ---------------------------------------------------------------------- // // Drag interaction — continuous positioning // // ---------------------------------------------------------------------- // describe("drag interaction", () => { function simulateDrag(deltaX) { const startX = 800; btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true })); btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true })); btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true })); } it("dragging left opens the tray", () => { simulateDrag(-60); expect(Tray.isOpen()).toBe(true); }); it("any leftward drag opens the tray", () => { simulateDrag(-20); expect(Tray.isOpen()).toBe(true); }); it("dragging right does not open the tray", () => { simulateDrag(100); expect(Tray.isOpen()).toBe(false); }); it("drag > 10px suppresses the subsequent click", () => { simulateDrag(-60); btn.click(); // should be swallowed — tray stays open expect(Tray.isOpen()).toBe(true); }); it("does not add .wobble during drag", () => { simulateDrag(-60); expect(wrap.classList.contains("wobble")).toBe(false); }); }); // ---------------------------------------------------------------------- // // Landscape mode — Y-axis drag, top-positioned wrap // // ---------------------------------------------------------------------- // describe("landscape mode", () => { // Re-init in landscape after the portrait init from outer beforeEach. beforeEach(() => { Tray.reset(); Tray._testSetLandscape(true); Tray.init(); }); function simulateDragY(deltaY) { const startY = 50; btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true })); btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true })); btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true })); } // ── open() in landscape ─────────────────────────────────────────── // describe("open()", () => { it("makes #id_tray visible", () => { Tray.open(); expect(tray.style.display).not.toBe("none"); }); it("adds .open to #id_tray_btn", () => { Tray.open(); expect(btn.classList.contains("open")).toBe(true); }); it("positions wrap via style.top, not style.left", () => { Tray.open(); expect(wrap.style.top).not.toBe(""); expect(wrap.style.left).toBe(""); }); }); // ── close() in landscape ────────────────────────────────────────── // describe("close()", () => { beforeEach(() => Tray.open()); it("closes the tray (display not toggled in landscape)", () => { Tray.close(); expect(Tray.isOpen()).toBe(false); }); it("removes .open from #id_tray_btn", () => { Tray.close(); expect(btn.classList.contains("open")).toBe(false); }); it("closed top is less than open top (wrap slides up to close)", () => { const openTop = parseInt(wrap.style.top, 10); Tray.close(); const closedTop = parseInt(wrap.style.top, 10); expect(closedTop).toBeLessThan(openTop); }); it("adds .snap to wrap after top transition completes", () => { Tray.close(); wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" })); expect(wrap.classList.contains("snap")).toBe(true); }); it("removes .snap from wrap once animationend fires", () => { Tray.close(); wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" })); wrap.dispatchEvent(new Event("animationend")); expect(wrap.classList.contains("snap")).toBe(false); }); }); // ── drag — Y axis ──────────────────────────────────────────────── // describe("drag interaction", () => { it("dragging down opens the tray", () => { simulateDragY(100); expect(Tray.isOpen()).toBe(true); }); it("dragging up does not open the tray", () => { simulateDragY(-100); expect(Tray.isOpen()).toBe(false); }); it("drag > 10px downward suppresses subsequent click", () => { simulateDragY(100); btn.click(); // should be swallowed — tray stays open expect(Tray.isOpen()).toBe(true); }); it("does not set style.left (Y axis only)", () => { simulateDragY(100); expect(wrap.style.left).toBe(""); }); it("does not add .wobble during drag", () => { simulateDragY(100); expect(wrap.classList.contains("wobble")).toBe(false); }); }); // ── click when closed — wobble, no open ───────────────────────── // describe("clicking btn when closed", () => { it("adds .wobble to wrap", () => { btn.click(); expect(wrap.classList.contains("wobble")).toBe(true); }); it("does not open the tray", () => { btn.click(); expect(Tray.isOpen()).toBe(false); }); }); // ── click when open — close ────────────────────────────────────── // describe("clicking btn when open", () => { beforeEach(() => Tray.open()); it("closes the tray", () => { btn.click(); expect(Tray.isOpen()).toBe(false); }); }); // ── init positions wrap at closed (top) ────────────────────────── // it("init sets wrap to closed position (top < 0 or = maxTop)", () => { // After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback) // which will be negative. Wrap starts off-screen above. const top = parseInt(wrap.style.top, 10); expect(top).toBeLessThan(0); }); // ── resize closes landscape tray ─────────────────────────────── // describe("resize closes the tray", () => { it("closes when landscape tray is open", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(Tray.isOpen()).toBe(false); }); it("removes .open from btn on resize", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(btn.classList.contains("open")).toBe(false); }); it("resets wrap to closed top position on resize", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(parseInt(wrap.style.top, 10)).toBeLessThan(0); }); it("does not re-open a closed tray on resize", () => { window.dispatchEvent(new Event("resize")); expect(Tray.isOpen()).toBe(false); }); }); }); // ---------------------------------------------------------------------- // // window resize — portrait // // ---------------------------------------------------------------------- // describe("window resize (portrait)", () => { it("closes the tray when open", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(Tray.isOpen()).toBe(false); }); it("removes .open from btn on resize", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(btn.classList.contains("open")).toBe(false); }); it("hides the tray panel on resize", () => { Tray.open(); window.dispatchEvent(new Event("resize")); expect(tray.style.display).toBe("none"); }); it("resets wrap to closed left position on resize", () => { Tray.open(); expect(wrap.style.left).toBe("0px"); window.dispatchEvent(new Event("resize")); expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0); }); it("does not re-open a closed tray on resize", () => { window.dispatchEvent(new Event("resize")); expect(Tray.isOpen()).toBe(false); }); }); // ---------------------------------------------------------------------- // // placeCard() // // ---------------------------------------------------------------------- // // // placeCard(roleCode, onComplete): // 1. Marks the first .tray-cell with .tray-role-card + data-role. // 2. Opens the tray. // 3. Fade-in animates the cell (.fade-in class, animationend fires). // 4. forceClose() — tray closes instantly. // 5. Calls onComplete. // // The grid always has exactly 8 .tray-cell elements (from the template); // no new elements are inserted. // // ---------------------------------------------------------------------- // describe("placeCard()", () => { let grid, firstCell; beforeEach(() => { 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); } document.body.appendChild(grid); // Re-init so _grid is set (reset() in outer afterEach clears it) Tray.init(); firstCell = grid.querySelector(".tray-cell"); }); afterEach(() => { grid.remove(); }); it("adds .tray-role-card to the first .tray-cell", () => { Tray.placeCard("PC", null); expect(firstCell.classList.contains("tray-role-card")).toBe(true); }); it("sets data-role on the first cell", () => { Tray.placeCard("NC", null); expect(firstCell.dataset.role).toBe("NC"); }); it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => { Tray.placeCard("PC", null); expect(firstCell.tabIndex).toBe(0); }); it("grid cell count stays at 8", () => { Tray.placeCard("PC", null); expect(grid.children.length).toBe(8); }); it("opens the tray", () => { Tray.placeCard("PC", null); expect(Tray.isOpen()).toBe(true); }); it("adds .fade-in to the first cell", () => { Tray.placeCard("PC", null); expect(firstCell.classList.contains("fade-in")).toBe(true); }); it("removes .fade-in and closes after animationend", () => { Tray.placeCard("PC", null); expect(Tray.isOpen()).toBe(true); firstCell.dispatchEvent(new Event("animationend")); expect(firstCell.classList.contains("fade-in")).toBe(false); expect(Tray.isOpen()).toBe(false); }); it("calls onComplete after the tray closes", () => { let called = false; Tray.placeCard("PC", () => { called = true; }); firstCell.dispatchEvent(new Event("animationend")); // Simulate the close transition completing (portrait: 'left' property) const te = new Event("transitionend"); te.propertyName = "left"; wrap.dispatchEvent(te); expect(called).toBe(true); }); it("landscape: same behaviour — first cell gets role card", () => { Tray._testSetLandscape(true); Tray.init(); Tray.placeCard("EC", null); expect(firstCell.classList.contains("tray-role-card")).toBe(true); expect(firstCell.dataset.role).toBe("EC"); }); it("reset() removes .tray-role-card and data-role from cells", () => { Tray.placeCard("PC", null); Tray.reset(); expect(firstCell.classList.contains("tray-role-card")).toBe(false); expect(firstCell.dataset.role).toBeUndefined(); }); it("reset() also clears tabindex from the placed cell", () => { Tray.placeCard("PC", null); Tray.reset(); expect(firstCell.hasAttribute("tabindex")).toBe(false); }); }); // ---------------------------------------------------------------------- // // placeSig() // // ---------------------------------------------------------------------- // // // placeSig(sourceEl, onComplete) — analogue of placeCard, applied to the // SECOND tray cell (the sig slot). sourceEl is the user's selected sig // .sig-stage-card (during sig-select), whose data-* attrs and inner // markup are copied into the tray's .sig-stage-card.sea-sig-card. // // Sequence: open → fade-in → animationend → close → onComplete. describe("placeSig()", () => { let grid, firstCell, secondCell, sourceEl; beforeEach(() => { 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); } document.body.appendChild(grid); Tray.init(); firstCell = grid.children[0]; secondCell = grid.children[1]; // Source: the user's selected sig stage card during sig-select. // Carries the same data-* shape that StageCard.fromDataset reads // and the same rank+icon child markup the tray template renders. sourceEl = document.createElement("div"); sourceEl.className = "sig-stage-card"; sourceEl.setAttribute("aria-label", "The Tester"); sourceEl.dataset.energies = '[{"type":"TESTLIBIDO","effect":"e1"}]'; sourceEl.dataset.operations = '[{"type":"TESTOP","effect":"o1"}]'; sourceEl.innerHTML = 'XCVIII' + ''; }); afterEach(() => grid.remove()); it("adds .tray-sig-card to the SECOND .tray-cell", () => { Tray.placeSig(sourceEl, null); expect(secondCell.classList.contains("tray-sig-card")).toBe(true); expect(firstCell.classList.contains("tray-sig-card")).toBe(false); }); it("sets tabIndex=0 on the placed cell", () => { Tray.placeSig(sourceEl, null); expect(secondCell.tabIndex).toBe(0); }); it("renders the .sig-stage-card.sea-sig-card child w. copied data + markup", () => { Tray.placeSig(sourceEl, null); const stage = secondCell.querySelector(".sig-stage-card.sea-sig-card"); expect(stage).not.toBe(null); expect(stage.getAttribute("aria-label")).toBe("The Tester"); expect(stage.dataset.energies).toBe(sourceEl.dataset.energies); expect(stage.dataset.operations).toBe(sourceEl.dataset.operations); expect(stage.querySelector(".fan-corner-rank").textContent).toBe("XCVIII"); expect(stage.querySelector("i.fa-solid")).not.toBe(null); }); it("grid cell count stays at 8", () => { Tray.placeSig(sourceEl, null); expect(grid.children.length).toBe(8); }); it("opens the tray", () => { Tray.placeSig(sourceEl, null); expect(Tray.isOpen()).toBe(true); }); it("adds .fade-in to the placed cell", () => { Tray.placeSig(sourceEl, null); expect(secondCell.classList.contains("fade-in")).toBe(true); }); it("removes .fade-in and closes after animationend", () => { Tray.placeSig(sourceEl, null); secondCell.dispatchEvent(new Event("animationend")); expect(secondCell.classList.contains("fade-in")).toBe(false); expect(Tray.isOpen()).toBe(false); }); it("calls onComplete after the tray closes", () => { let called = false; Tray.placeSig(sourceEl, () => { called = true; }); secondCell.dispatchEvent(new Event("animationend")); const te = new Event("transitionend"); te.propertyName = "left"; wrap.dispatchEvent(te); expect(called).toBe(true); }); it("landscape: same behaviour — second cell gets sig card", () => { Tray._testSetLandscape(true); Tray.init(); Tray.placeSig(sourceEl, null); expect(secondCell.classList.contains("tray-sig-card")).toBe(true); }); it("reset() removes .tray-sig-card from cells", () => { Tray.placeSig(sourceEl, null); Tray.reset(); expect(secondCell.classList.contains("tray-sig-card")).toBe(false); }); }); // ---------------------------------------------------------------------- // // init() — focusable tray cards // // ---------------------------------------------------------------------- // // // .tray-sig-card is rendered server-side by room.html when the seat has a // significator; .tray-role-card may be too if the seat already has a role. // init() must mark these cells tabbable so the SCSS :focus rule persists // the hover-tilt animation after the user clicks the card. describe("init() — focusable tray cards", () => { let grid; beforeEach(() => { grid = document.createElement("div"); grid.id = "id_tray_grid"; document.body.appendChild(grid); }); afterEach(() => grid.remove()); function _addCell(extraClass) { const cell = document.createElement("div"); cell.className = "tray-cell" + (extraClass ? " " + extraClass : ""); grid.appendChild(cell); return cell; } it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => { const sigCell = _addCell("tray-sig-card"); Tray.init(); expect(sigCell.tabIndex).toBe(0); }); it("sets tabIndex=0 on a template-rendered .tray-role-card", () => { const roleCell = _addCell("tray-role-card"); Tray.init(); expect(roleCell.tabIndex).toBe(0); }); it("does NOT set tabindex on bare .tray-cell elements", () => { const empty = _addCell(); Tray.init(); expect(empty.hasAttribute("tabindex")).toBe(false); }); }); });