1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { observeVisibleAnimation } from "./visibleAnimation";
4
5let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
6let motion = Object.assign(new EventTarget(), { matches: false });
7let observers: TestIntersectionObserver[] = [];
8let cleanups: Array<() => void> = [];
9
10class TestIntersectionObserver {
11 constructor(private readonly callback: IntersectionObserverCallback) {
12 observers.push(this);
13 }
14
15 observe = vi.fn();
16 unobserve = vi.fn();
17 disconnect = vi.fn();
18
19 report(target: Element, isIntersecting: boolean) {
20 this.callback(
21 [{ target, isIntersecting } as IntersectionObserverEntry],
22 this as unknown as IntersectionObserver,
23 );
24 }
25}
26
27function animationElement() {
28 const properties = new Map<string, string>();
29 const element = {
30 style: { setProperty: (name: string, value: string) => properties.set(name, value) },
31 } as unknown as HTMLElement;
32 return {
33 element,
34 state: () => properties.get("--visible-animation-state"),
35 willChange: () => properties.get("--visible-animation-will-change"),
36 };
37}
38
39function attach(element: HTMLElement) {
40 const cleanup = observeVisibleAnimation(element);
41 if (cleanup) cleanups.push(cleanup);
42 return cleanup;
43}
44
45beforeEach(() => {
46 page = Object.assign(new EventTarget(), { visibilityState: "visible" });
47 motion = Object.assign(new EventTarget(), { matches: false });
48 observers = [];
49 cleanups = [];
50 vi.stubGlobal("document", page);
51 vi.stubGlobal("window", { matchMedia: () => motion });
52 vi.stubGlobal("IntersectionObserver", TestIntersectionObserver);
53});
54
55afterEach(() => {
56 for (const cleanup of cleanups) cleanup();
57 vi.unstubAllGlobals();
58 vi.restoreAllMocks();
59});
60
61describe("observeVisibleAnimation", () => {
62 it("runs only intersecting animations in a visible document with motion enabled", () => {
63 const first = animationElement();
64 const second = animationElement();
65 attach(first.element);
66 attach(second.element);
67 expect(first.state()).toBe("paused");
68
69 const observer = observers[0]!;
70 observer.report(first.element, true);
71 expect(first.state()).toBe("running");
72 expect(second.state()).toBe("paused");
73
74 page.visibilityState = "hidden";
75 page.dispatchEvent(new Event("visibilitychange"));
76 observer.report(second.element, true);
77 expect(first.state()).toBe("paused");
78 expect(first.willChange()).toBe("auto");
79 expect(second.state()).toBe("paused");
80
81 page.visibilityState = "visible";
82 page.dispatchEvent(new Event("visibilitychange"));
83 expect(first.state()).toBe("running");
84 expect(second.state()).toBe("running");
85
86 observer.report(first.element, false);
87 motion.matches = true;
88 motion.dispatchEvent(new Event("change"));
89 expect(first.state()).toBe("paused");
90 expect(second.state()).toBe("paused");
91
92 motion.matches = false;
93 motion.dispatchEvent(new Event("change"));
94 expect(first.state()).toBe("paused");
95 expect(second.state()).toBe("running");
96 });
97
98 it.each(["hidden", "reduced motion"])("starts paused with %s already active", (condition) => {
99 page.visibilityState = condition === "hidden" ? "hidden" : "visible";
100 motion.matches = condition === "reduced motion";
101 const animation = animationElement();
102 attach(animation.element);
103 observers[0]!.report(animation.element, true);
104 expect(animation.state()).toBe("paused");
105
106 page.visibilityState = "visible";
107 motion.matches = false;
108 page.dispatchEvent(new Event("visibilitychange"));
109 expect(animation.state()).toBe("running");
110 });
111
112 it("shares observers, releases the final ref, and ignores late callbacks after remount", () => {
113 const addVisibility = vi.spyOn(page, "addEventListener");
114 const removeVisibility = vi.spyOn(page, "removeEventListener");
115 const addMotion = vi.spyOn(motion, "addEventListener");
116 const removeMotion = vi.spyOn(motion, "removeEventListener");
117 const first = animationElement();
118 const second = animationElement();
119 const detachFirst = attach(first.element);
120 const detachSecond = attach(second.element);
121 expect(observers).toHaveLength(1);
122 expect(addVisibility).toHaveBeenCalledTimes(1);
123 expect(addMotion).toHaveBeenCalledTimes(1);
124
125 const previousObserver = observers[0]!;
126 detachFirst?.();
127 previousObserver.report(first.element, true);
128 expect(first.state()).toBe("paused");
129 expect(previousObserver.unobserve).toHaveBeenCalledWith(first.element);
130 expect(previousObserver.disconnect).not.toHaveBeenCalled();
131
132 detachSecond?.();
133 expect(previousObserver.disconnect).toHaveBeenCalledTimes(1);
134 expect(removeVisibility).toHaveBeenCalledTimes(1);
135 expect(removeMotion).toHaveBeenCalledTimes(1);
136
137 attach(second.element);
138 expect(observers).toHaveLength(2);
139 detachSecond?.();
140 previousObserver.report(second.element, true);
141 expect(second.state()).toBe("paused");
142 observers[1]!.report(second.element, true);
143 expect(second.state()).toBe("running");
144 });
145
146 it("keeps unknown visibility static when IntersectionObserver is unavailable", () => {
147 vi.stubGlobal("IntersectionObserver", undefined);
148 const animation = animationElement();
149 attach(animation.element);
150 expect(animation.state()).toBe("paused");
151 expect(animation.willChange()).toBe("auto");
152 expect(observers).toHaveLength(0);
153 });
154});