| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { startHomeMotion } from "./homeMotion"; |
| 4 | |
| 5 | class ElementStub extends EventTarget { |
| 6 | properties = new Map<string, string>(); |
| 7 | style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; |
| 8 | children: ElementStub[] = []; |
| 9 | querySelectorAll = () => this.children; |
| 10 | getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); |
| 11 | } |
| 12 | |
| 13 | let observers: ObserverStub[] = []; |
| 14 | class ObserverStub { |
| 15 | constructor(private readonly callback: IntersectionObserverCallback) { |
| 16 | observers.push(this); |
| 17 | } |
| 18 | observe = vi.fn(); |
| 19 | disconnect = vi.fn(); |
| 20 | report(target: ElementStub, isIntersecting: boolean) { |
| 21 | this.callback( |
| 22 | [{ target, isIntersecting } as unknown as IntersectionObserverEntry], |
| 23 | this as unknown as IntersectionObserver, |
| 24 | ); |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); |
| 29 | let reduced = Object.assign(new EventTarget(), { matches: false }); |
| 30 | let fine = Object.assign(new EventTarget(), { matches: true }); |
| 31 | let frames = new Map<number, FrameRequestCallback>(); |
| 32 | let dispose: (() => void) | undefined; |
| 33 | |
| 34 | beforeEach(() => { |
| 35 | observers = []; |
| 36 | frames = new Map(); |
| 37 | page = Object.assign(new EventTarget(), { visibilityState: "visible" }); |
| 38 | reduced = Object.assign(new EventTarget(), { matches: false }); |
| 39 | fine = Object.assign(new EventTarget(), { matches: true }); |
| 40 | vi.stubGlobal("document", page); |
| 41 | vi.stubGlobal( |
| 42 | "window", |
| 43 | Object.assign(new EventTarget(), { |
| 44 | matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), |
| 45 | }), |
| 46 | ); |
| 47 | vi.stubGlobal("IntersectionObserver", ObserverStub); |
| 48 | let frameId = 0; |
| 49 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { |
| 50 | frames.set(++frameId, callback); |
| 51 | return frameId; |
| 52 | }); |
| 53 | vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); |
| 54 | }); |
| 55 | |
| 56 | afterEach(() => { |
| 57 | dispose?.(); |
| 58 | dispose = undefined; |
| 59 | vi.unstubAllGlobals(); |
| 60 | }); |
| 61 | |
| 62 | function fixture() { |
| 63 | const hero = new ElementStub(); |
| 64 | const field = new ElementStub(); |
| 65 | const mark = new ElementStub(); |
| 66 | const otherMark = new ElementStub(); |
| 67 | field.children = [mark, otherMark]; |
| 68 | const track = new ElementStub(); |
| 69 | const caret = new ElementStub(); |
| 70 | dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters< |
| 71 | typeof startHomeMotion |
| 72 | >[0]); |
| 73 | return { hero, field, mark, otherMark, track, caret, observer: observers[0]! }; |
| 74 | } |
| 75 | |
| 76 | function movePointer(hero: ElementStub, x = 400, y = 600) { |
| 77 | hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); |
| 78 | } |
| 79 | |
| 80 | describe("homepage motion", () => { |
| 81 | it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { |
| 82 | const { hero, field, mark, otherMark, track, caret, observer } = fixture(); |
| 83 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 84 | expect(track.properties.get("--home-motion-state")).toBe("paused"); |
| 85 | observer.report(mark, true); |
| 86 | observer.report(track, true); |
| 87 | observer.report(caret, true); |
| 88 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 89 | expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); |
| 90 | expect(track.properties.get("--home-motion-state")).toBe("running"); |
| 91 | expect(caret.properties.get("--home-motion-state")).toBe("running"); |
| 92 | |
| 93 | movePointer(hero, 100, 100); |
| 94 | movePointer(hero); |
| 95 | expect(frames.size).toBe(1); |
| 96 | expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); |
| 97 | const [id, callback] = [...frames][0]!; |
| 98 | frames.delete(id); |
| 99 | callback(0); |
| 100 | expect(field.properties.get("--px")).toBe("18.0px"); |
| 101 | expect(field.properties.get("--py")).toBe("14.0px"); |
| 102 | |
| 103 | movePointer(hero); |
| 104 | page.visibilityState = "hidden"; |
| 105 | page.dispatchEvent(new Event("visibilitychange")); |
| 106 | expect(frames.size).toBe(0); |
| 107 | expect(field.properties.get("--px")).toBe("0px"); |
| 108 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 109 | expect(track.properties.get("--home-motion-state")).toBe("paused"); |
| 110 | expect(caret.properties.get("--home-motion-state")).toBe("paused"); |
| 111 | page.visibilityState = "visible"; |
| 112 | page.dispatchEvent(new Event("visibilitychange")); |
| 113 | reduced.matches = true; |
| 114 | reduced.dispatchEvent(new Event("change")); |
| 115 | movePointer(hero); |
| 116 | expect(frames.size).toBe(0); |
| 117 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 118 | reduced.matches = false; |
| 119 | fine.matches = false; |
| 120 | reduced.dispatchEvent(new Event("change")); |
| 121 | movePointer(hero); |
| 122 | expect(frames.size).toBe(0); |
| 123 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 124 | }); |
| 125 | |
| 126 | it("cancels pending work and ignores events after cleanup", () => { |
| 127 | const { hero, mark, track, observer } = fixture(); |
| 128 | observer.report(mark, true); |
| 129 | observer.report(track, true); |
| 130 | movePointer(hero); |
| 131 | dispose?.(); |
| 132 | observer.report(mark, true); |
| 133 | movePointer(hero); |
| 134 | reduced.dispatchEvent(new Event("change")); |
| 135 | expect(observer.disconnect).toHaveBeenCalledTimes(1); |
| 136 | expect(frames.size).toBe(0); |
| 137 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 138 | expect(track.properties.get("--home-motion-state")).toBe("paused"); |
| 139 | }); |
| 140 | }); |