Files
kandev/apps/web/components/grid-spinner.test.tsx
2026-09-12 16:35:20 +01:00

221 lines
7.8 KiB
TypeScript

import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GridSpinner } from "./grid-spinner";
const delays = ["0.2s", "0.3s", "0.4s", "0.1s", "0.2s", "0.3s", "0s", "0.1s", "0.2s"];
const CUBE_SELECTOR = ".spinner-grid-cube";
const originalAnimate = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "animate");
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
afterEach(() => {
cleanup();
vi.restoreAllMocks();
if (originalAnimate) {
Object.defineProperty(HTMLElement.prototype, "animate", originalAnimate);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "animate");
}
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
Reflect.deleteProperty(document, "visibilityState");
}
});
function installComputedAnimationStyles(easing = "ease-in-out") {
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const cubeIndex = Array.from(element.parentElement?.children ?? []).indexOf(element);
return {
animationDelay: delays[cubeIndex] ?? "0s",
animationDuration: "1.3s",
animationName: "spinner-grid",
animationTimingFunction: easing,
} as CSSStyleDeclaration;
});
}
function installAnimate(implementation?: (...args: unknown[]) => Animation) {
const animate = vi.fn(implementation ?? (() => makeAnimation()));
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: animate,
});
return animate;
}
function makeAnimation() {
let playState: AnimationPlayState = "running";
return {
get playState() {
return playState;
},
cancel: vi.fn(() => {
playState = "idle";
}),
pause: vi.fn(() => {
playState = "paused";
}),
play: vi.fn(() => {
playState = "running";
}),
} as unknown as Animation;
}
describe("GridSpinner", () => {
it("pauses and resumes all owned cube effects with document visibility", () => {
installComputedAnimationStyles();
const animations = Array.from({ length: 9 }, () => makeAnimation());
let animationIndex = 0;
installAnimate(() => animations[animationIndex++]);
const { container } = render(<GridSpinner />);
const cubes = Array.from(container.querySelectorAll<HTMLElement>(CUBE_SELECTOR));
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
for (const animation of animations) expect(animation.pause).toHaveBeenCalledOnce();
expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
for (const animation of animations) expect(animation.play).toHaveBeenCalledOnce();
expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true);
});
it("starts nine compositor transform effects with the CSS timing and stagger", () => {
installComputedAnimationStyles();
const animate = installAnimate();
const { container } = render(<GridSpinner className="text-primary" />);
const status = container.querySelector<HTMLElement>('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Loading");
expect(status?.className).toContain("text-primary");
const cubes = Array.from(container.querySelectorAll<HTMLElement>(CUBE_SELECTOR));
expect(cubes).toHaveLength(9);
expect(animate).toHaveBeenCalledTimes(9);
expect(animate.mock.calls.map((call) => call[0])).toEqual(
Array.from({ length: 9 }, () => [
{ offset: 0, transform: "scale3d(0.5, 0.5, 1)" },
{ offset: 0.35, transform: "scale3d(0, 0, 1)" },
{ offset: 0.7, transform: "scale3d(0.5, 0.5, 1)" },
{ offset: 1, transform: "scale3d(0.5, 0.5, 1)" },
]),
);
expect(animate.mock.calls.map((call) => call[1])).toEqual(
[200, 300, 400, 100, 200, 300, 0, 100, 200].map((delay) => ({
delay,
duration: 1_300,
easing: "ease-in-out",
iterations: Infinity,
})),
);
expect(cubes.every((cube) => cube.style.animation === "none")).toBe(true);
});
it("keeps the CSS animations when Web Animations is unavailable", () => {
Reflect.deleteProperty(HTMLElement.prototype, "animate");
const { container } = render(<GridSpinner />);
const cubes = Array.from(container.querySelectorAll<HTMLElement>(CUBE_SELECTOR));
expect(cubes).toHaveLength(9);
expect(cubes.every((cube) => cube.style.animation === "")).toBe(true);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true);
});
});
describe("GridSpinner lifecycle", () => {
it("preserves commas inside CSS easing functions", () => {
installComputedAnimationStyles("cubic-bezier(0.4, 0, 0.6, 1)");
const animate = installAnimate();
render(<GridSpinner />);
expect(animate.mock.calls[0]?.[1]).toEqual({
delay: 200,
duration: 1_300,
easing: "cubic-bezier(0.4, 0, 0.6, 1)",
iterations: Infinity,
});
});
it("keeps the animation phase when its presentation class changes", () => {
installComputedAnimationStyles();
const animations = Array.from({ length: 9 }, () => makeAnimation());
let animationIndex = 0;
const animate = installAnimate(() => animations[animationIndex++]);
const { rerender } = render(<GridSpinner className="opacity-50" />);
rerender(<GridSpinner className="opacity-75" />);
expect(animate).toHaveBeenCalledTimes(9);
for (const animation of animations) expect(animation.cancel).not.toHaveBeenCalled();
});
it("restores one consistent CSS fallback when setup fails partway", () => {
installComputedAnimationStyles();
const firstAnimation = makeAnimation();
const animate = installAnimate(() => {
if (animate.mock.calls.length === 2) throw new Error("animation setup failed");
return firstAnimation;
});
const { container } = render(<GridSpinner />);
const cubes = Array.from(container.querySelectorAll<HTMLElement>(CUBE_SELECTOR));
expect(animate).toHaveBeenCalledTimes(2);
expect(firstAnimation.cancel).toHaveBeenCalledOnce();
expect(cubes.every((cube) => cube.style.animation === "")).toBe(true);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
expect(cubes.every((cube) => cube.style.animationPlayState === "paused")).toBe(true);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
document.dispatchEvent(new Event("visibilitychange"));
expect(cubes.every((cube) => cube.style.animationPlayState === "")).toBe(true);
});
it("cancels every compositor effect when it unmounts", () => {
installComputedAnimationStyles();
const animations = Array.from({ length: 9 }, () => makeAnimation());
let animationIndex = 0;
installAnimate(() => animations[animationIndex++]);
const { unmount } = render(<GridSpinner />);
unmount();
for (const animation of animations) {
expect(animation.cancel).toHaveBeenCalledOnce();
}
});
});