90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A loading state fades in only after a delay, and loading states that hand over to each other share
|
|
// ONE clock. The bug it replaced: every LoadingState restarted its own 300 ms CSS delay, so a slow
|
|
// first load went spinner → blank → spinner when the page chunk's Suspense fallback handed over to
|
|
// the page's own "fetching" state. The clock is module state, so each test runs at its own time.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, cleanup } from "@testing-library/react";
|
|
import { LoadingState } from "./QueryState";
|
|
|
|
// The label is not under test; without an i18n instance react-i18next warns on every render.
|
|
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
|
|
|
let now = 0;
|
|
beforeEach(() => {
|
|
vi.spyOn(performance, "now").mockImplementation(() => now);
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** The reveal delay the rendered loading state waits, in ms (0 = shown at once). */
|
|
function revealDelay(container: HTMLElement): number {
|
|
const el = container.querySelector<HTMLElement>("[role=status]")!;
|
|
const m = /ease (\d+)ms/.exec(el.style.animation);
|
|
return m ? Number(m[1]) : 0;
|
|
}
|
|
|
|
describe("LoadingState reveal", () => {
|
|
it("a loading state on its own waits the full delay", () => {
|
|
now = 10_000;
|
|
const { container } = render(<LoadingState />);
|
|
expect(revealDelay(container)).toBe(300);
|
|
});
|
|
|
|
it("one that takes over from a loading state on screen keeps that one's clock", () => {
|
|
now = 20_000;
|
|
const { container, rerender } = render(<LoadingState key="fallback" />);
|
|
now = 20_200;
|
|
rerender(<LoadingState key="page" />); // a new element: the fallback leaves as it mounts
|
|
expect(revealDelay(container)).toBe(100);
|
|
});
|
|
|
|
it("once the shared clock has run out, the next one in the hand-off shows at once", () => {
|
|
now = 30_000;
|
|
const { container, rerender } = render(<LoadingState key="fallback" />);
|
|
now = 30_400;
|
|
rerender(<LoadingState key="page" />);
|
|
expect(revealDelay(container)).toBe(0);
|
|
});
|
|
|
|
it("a loading state after a pause starts a new clock", () => {
|
|
now = 40_000;
|
|
const first = render(<LoadingState />);
|
|
first.unmount();
|
|
now = 40_500;
|
|
const { container } = render(<LoadingState />);
|
|
expect(revealDelay(container)).toBe(300);
|
|
});
|
|
|
|
it("one that mounts just after another left, in a later commit, keeps that one's clock", () => {
|
|
now = 45_000;
|
|
const first = render(<LoadingState />);
|
|
now = 45_050;
|
|
first.unmount(); // a Suspense fallback leaves a commit before the page's own loading state mounts
|
|
now = 45_080;
|
|
const { container } = render(<LoadingState />);
|
|
expect(revealDelay(container)).toBe(220);
|
|
});
|
|
|
|
it("`since` counts the wait from an earlier start (the boot screen: navigation start)", () => {
|
|
now = 50_250;
|
|
const { container } = render(<LoadingState since={50_000} delayMs={400} />);
|
|
expect(revealDelay(container)).toBe(150);
|
|
});
|
|
});
|
|
|
|
describe("LoadingState shape (B8)", () => {
|
|
it("shows the content's skeleton on the same clock, the label for a screen reader only", () => {
|
|
now = 90_000;
|
|
const { container } = render(<LoadingState shape="list" label="Loading playlists" />);
|
|
const status = container.querySelector<HTMLElement>("[role=status]")!;
|
|
expect(revealDelay(container)).toBe(300);
|
|
expect(status.querySelector(".animate-spin")).toBeNull();
|
|
expect(status.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
|
expect(status.querySelector(".sr-only")?.textContent).toBe("Loading playlists");
|
|
});
|
|
});
|