import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { startHomeMotion } from "./homeMotion"; class ElementStub extends EventTarget { properties = new Map(); style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; children: ElementStub[] = []; querySelectorAll = () => this.children; getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); } let observers: ObserverStub[] = []; class ObserverStub { constructor(private readonly callback: IntersectionObserverCallback) { observers.push(this); } observe = vi.fn(); disconnect = vi.fn(); report(target: ElementStub, isIntersecting: boolean) { this.callback( [{ target, isIntersecting } as unknown as IntersectionObserverEntry], this as unknown as IntersectionObserver, ); } } let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); let reduced = Object.assign(new EventTarget(), { matches: false }); let fine = Object.assign(new EventTarget(), { matches: true }); let frames = new Map(); let dispose: (() => void) | undefined; beforeEach(() => { observers = []; frames = new Map(); page = Object.assign(new EventTarget(), { visibilityState: "visible" }); reduced = Object.assign(new EventTarget(), { matches: false }); fine = Object.assign(new EventTarget(), { matches: true }); vi.stubGlobal("document", page); vi.stubGlobal( "window", Object.assign(new EventTarget(), { matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), }), ); vi.stubGlobal("IntersectionObserver", ObserverStub); let frameId = 0; vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; }); vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); }); afterEach(() => { dispose?.(); dispose = undefined; vi.unstubAllGlobals(); }); function fixture() { const hero = new ElementStub(); const field = new ElementStub(); const mark = new ElementStub(); const otherMark = new ElementStub(); field.children = [mark, otherMark]; const track = new ElementStub(); const caret = new ElementStub(); dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters< typeof startHomeMotion >[0]); return { hero, field, mark, otherMark, track, caret, observer: observers[0]! }; } function movePointer(hero: ElementStub, x = 400, y = 600) { hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); } describe("homepage motion", () => { it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { const { hero, field, mark, otherMark, track, caret, observer } = fixture(); expect(mark.properties.get("--home-motion-state")).toBe("paused"); expect(track.properties.get("--home-motion-state")).toBe("paused"); observer.report(mark, true); observer.report(track, true); observer.report(caret, true); expect(mark.properties.get("--home-motion-state")).toBe("running"); expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); expect(track.properties.get("--home-motion-state")).toBe("running"); expect(caret.properties.get("--home-motion-state")).toBe("running"); movePointer(hero, 100, 100); movePointer(hero); expect(frames.size).toBe(1); expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); const [id, callback] = [...frames][0]!; frames.delete(id); callback(0); expect(field.properties.get("--px")).toBe("18.0px"); expect(field.properties.get("--py")).toBe("14.0px"); movePointer(hero); page.visibilityState = "hidden"; page.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(0); expect(field.properties.get("--px")).toBe("0px"); expect(mark.properties.get("--home-motion-state")).toBe("paused"); expect(track.properties.get("--home-motion-state")).toBe("paused"); expect(caret.properties.get("--home-motion-state")).toBe("paused"); page.visibilityState = "visible"; page.dispatchEvent(new Event("visibilitychange")); reduced.matches = true; reduced.dispatchEvent(new Event("change")); movePointer(hero); expect(frames.size).toBe(0); expect(mark.properties.get("--home-motion-state")).toBe("paused"); reduced.matches = false; fine.matches = false; reduced.dispatchEvent(new Event("change")); movePointer(hero); expect(frames.size).toBe(0); expect(mark.properties.get("--home-motion-state")).toBe("running"); }); it("cancels pending work and ignores events after cleanup", () => { const { hero, mark, track, observer } = fixture(); observer.report(mark, true); observer.report(track, true); movePointer(hero); dispose?.(); observer.report(mark, true); movePointer(hero); reduced.dispatchEvent(new Event("change")); expect(observer.disconnect).toHaveBeenCalledTimes(1); expect(frames.size).toBe(0); expect(mark.properties.get("--home-motion-state")).toBe("paused"); expect(track.properties.get("--home-motion-state")).toBe("paused"); }); });