Files
siftlode/frontend/src/components/ui/QueryState.dom.test.tsx
T

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");
});
});