// ── TrayTooltipSpec.js ───────────────────────────────────────────────────────── // // Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a // tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2 // (sig-card with PRV/NXT pager) is a separate sprint. // // Public API under test: // TrayTooltip.init() — binds document hover/move listeners // TrayTooltip.reset() — detaches listeners + hides portal (afterEach) // // DOM contract assumed by the module: // #id_tooltip_portal — fixed portal at page root, hidden by default // #id_tray_grid — tray's grid container // .tray-role-card — cell carrying the role art // > img — hover trigger // > .tt — server-rendered tooltip content (.tt-title, .tt-description, …) // // Behaviour: // * Hovering the img copies its sibling .tt's innerHTML into the portal, // marks the portal .active, and shows it (display: block). // * Pointer leaving the union of [trigger, portal] rects clears the portal. // * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8; // portal.left ≤ viewportW − halfPortalW − 8. // // ───────────────────────────────────────────────────────────────────────────── describe("TrayTooltip", () => { let portal, grid, cell, img, tt; beforeEach(() => { portal = document.createElement("div"); portal.id = "id_tooltip_portal"; portal.style.display = "none"; document.body.appendChild(portal); grid = document.createElement("div"); grid.id = "id_tray_grid"; cell = document.createElement("div"); cell.className = "tray-cell tray-role-card"; cell.dataset.role = "PC"; img = document.createElement("img"); img.alt = "PC"; tt = document.createElement("div"); tt.className = "tt"; tt.style.display = "none"; tt.innerHTML = '

Player

' + '

[Placeholder description]

'; cell.appendChild(img); cell.appendChild(tt); grid.appendChild(cell); document.body.appendChild(grid); // Force a known geometry so clamp math has something to clamp against. // (jsdom-style env: getBoundingClientRect() returns zeroes by default; // override via stubs.) spyOn(img, "getBoundingClientRect").and.returnValue({ left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48, }); TrayTooltip.init(); }); afterEach(() => { TrayTooltip.reset(); portal.remove(); grid.remove(); }); // ---------------------------------------------------------------------- // // Hover → portal becomes active // // ---------------------------------------------------------------------- // describe("on mouseenter of role-card img", () => { it("copies .tt innerHTML into the portal", () => { img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(portal.innerHTML).toContain('class="tt-title"'); expect(portal.innerHTML).toContain("Player"); expect(portal.innerHTML).toContain("[Placeholder description]"); }); it("marks the portal .active and shows it", () => { img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(portal.classList.contains("active")).toBe(true); expect(portal.style.display).not.toBe("none"); }); it("does nothing if there is no sibling .tt", () => { tt.remove(); img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(portal.classList.contains("active")).toBe(false); expect(portal.style.display).toBe("none"); }); }); // ---------------------------------------------------------------------- // // Mouseleave (extended) → portal clears // // ---------------------------------------------------------------------- // describe("on pointer leaving the trigger+portal union", () => { beforeEach(() => { // Pin the portal to a known location after activation so the // mousemove union test is deterministic. img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); spyOn(portal, "getBoundingClientRect").and.returnValue({ left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80, }); }); it("clears the portal when the pointer is well outside both rects", () => { document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 500, clientY: 500, })); expect(portal.classList.contains("active")).toBe(false); expect(portal.style.display).toBe("none"); }); it("keeps the portal active while the pointer is inside the trigger", () => { document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 120, clientY: 220, })); expect(portal.classList.contains("active")).toBe(true); }); it("keeps the portal active while the pointer is inside the portal", () => { document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 180, clientY: 160, })); expect(portal.classList.contains("active")).toBe(true); }); }); // ---------------------------------------------------------------------- // // Clamping — portal left stays inside the viewport // // ---------------------------------------------------------------------- // // ---------------------------------------------------------------------- // // .tt-active on the cell — tilt persistence // // ---------------------------------------------------------------------- // // // The role-card / sig-card hover-tilt is keyed off :hover + :focus on the // cell. While the portal is open the pointer is typically OFF the cell // (hovering the portal itself), so :hover drops; if the cell never // received focus, :focus is also absent and the tilt reverts even though // the tooltip is still active. Solution: TrayTooltip adds .tt-active to // the cell while its tooltip is open and removes it on _hide. SCSS // includes .tt-active in the tilt selector list. describe(".tt-active class on the cell", () => { it("is added to the role-card cell on mouseenter and removed on hide", () => { img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(cell.classList.contains("tt-active")).toBe(true); // outer beforeEach already stubs img.getBoundingClientRect; just // pin the portal so the union test is deterministic. spyOn(portal, "getBoundingClientRect").and.returnValue({ left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, }); document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 9999, clientY: 9999, })); expect(cell.classList.contains("tt-active")).toBe(false); }); }); // ---------------------------------------------------------------------- // // Phase 2 — sig-card branch // // ---------------------------------------------------------------------- // // // Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with // a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns). // No click-to-dismiss; mouseleave union covers the overhanging btn rects. describe("sig-card branch", () => { let sigCell, sigStage; beforeEach((done) => { sigCell = document.createElement("div"); sigCell.className = "tray-cell tray-sig-card"; sigStage = document.createElement("div"); sigStage.className = "sig-stage-card sea-sig-card"; // Two FYI entries — 1 energy + 1 operation — so PRV/NXT have // something to cycle through. sigStage.dataset.energies = JSON.stringify([ { type: "TESTLIBIDO", effect: "First energy effect." }, ]); sigStage.dataset.operations = JSON.stringify([ { type: "TESTOP", effect: "First operation effect." }, ]); sigCell.appendChild(sigStage); grid.appendChild(sigCell); spyOn(sigStage, "getBoundingClientRect").and.returnValue({ left: 100, right: 160, top: 300, bottom: 396, width: 60, height: 96, }); // MutationObserver fires asynchronously — yield once so the new // sig cell is bound before the spec body runs. setTimeout(done, 0); }); it("populates the portal with a .sig-info panel + PRV/NXT btns", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(portal.querySelector(".sig-info-title")).not.toBe(null); expect(portal.querySelector(".sig-info-type")).not.toBe(null); expect(portal.querySelector(".sig-info-effect")).not.toBe(null); expect(portal.querySelector(".sig-info-index")).not.toBe(null); expect(portal.querySelector(".fyi-prev")).not.toBe(null); expect(portal.querySelector(".fyi-next")).not.toBe(null); }); it("renders the first energy entry on hover", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy"); expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO"); expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect."); expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2"); }); it("NXT advances to the operation entry; PRV cycles back", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); portal.querySelector(".fyi-next").dispatchEvent( new MouseEvent("click", { bubbles: true }) ); expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation"); expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP"); expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2"); portal.querySelector(".fyi-prev").dispatchEvent( new MouseEvent("click", { bubbles: true }) ); expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy"); expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2"); }); it("clicking the panel body does NOT dismiss (departure from fan stage)", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); portal.querySelector(".sig-info-effect").dispatchEvent( new MouseEvent("click", { bubbles: true }) ); expect(portal.classList.contains("active")).toBe(true); expect(portal.style.display).not.toBe("none"); }); it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // Stub the portal + btn rects so the union test is deterministic. spyOn(portal, "getBoundingClientRect").and.returnValue({ left: 80, right: 280, top: 200, bottom: 320, width: 200, height: 120, }); const prv = portal.querySelector(".fyi-prev"); const nxt = portal.querySelector(".fyi-next"); spyOn(prv, "getBoundingClientRect").and.returnValue({ left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32, }); spyOn(nxt, "getBoundingClientRect").and.returnValue({ left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32, }); // Pointer over the PRV btn (left:60–92) — outside the portal's left // edge (80) but inside the btn rect — should keep the portal alive. document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 70, clientY: 256, })); expect(portal.classList.contains("active")).toBe(true); // Pointer over the NXT btn (right:268–300, past the portal right // edge 280) — likewise stays alive. document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 290, clientY: 256, })); expect(portal.classList.contains("active")).toBe(true); }); it("adds .tt-active to the sig cell on hover and removes it on _hide", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(sigCell.classList.contains("tt-active")).toBe(true); spyOn(portal, "getBoundingClientRect").and.returnValue({ left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, }); document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 9999, clientY: 9999, })); expect(sigCell.classList.contains("tt-active")).toBe(false); }); it("clears portal when pointer leaves trigger + portal + btn rect union", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); spyOn(portal, "getBoundingClientRect").and.returnValue({ left: 80, right: 280, top: 200, bottom: 320, width: 200, height: 120, }); const prv = portal.querySelector(".fyi-prev"); const nxt = portal.querySelector(".fyi-next"); spyOn(prv, "getBoundingClientRect").and.returnValue({ left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32, }); spyOn(nxt, "getBoundingClientRect").and.returnValue({ left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32, }); document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 9999, clientY: 9999, })); expect(portal.classList.contains("active")).toBe(false); expect(portal.style.display).toBe("none"); }); }); describe("position clamping", () => { it("clamps to the right when the trigger is near the left edge", () => { // Trigger near x=0 should push portal centre rightward to halfW + 8. img.getBoundingClientRect.and.returnValue({ left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48, }); // Stub portal width so halfW is predictable AFTER innerHTML is set. const origDescriptor = Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetWidth" ); Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 }); img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); const left = parseFloat(portal.style.left); expect(left).toBeGreaterThanOrEqual(108); // halfW(100) + 8 if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor); }); it("clamps to the left when the trigger is near the right edge", () => { const vw = window.innerWidth; img.getBoundingClientRect.and.returnValue({ left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48, }); Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 }); img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); const left = parseFloat(portal.style.left); expect(left).toBeLessThanOrEqual(vw - 100 - 8); // viewport − halfW − 8 }); }); });