1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { startHomeMotion } from "./homeMotion";
4
5class 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
13let observers: ObserverStub[] = [];
14class 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
28let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
29let reduced = Object.assign(new EventTarget(), { matches: false });
30let fine = Object.assign(new EventTarget(), { matches: true });
31let frames = new Map<number, FrameRequestCallback>();
32let dispose: (() => void) | undefined;
33
34beforeEach(() => {
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
56afterEach(() => {
57 dispose?.();
58 dispose = undefined;
59 vi.unstubAllGlobals();
60});
61
62function 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
76function movePointer(hero: ElementStub, x = 400, y = 600) {
77 hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y }));
78}
79
80describe("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});