test.social/core/timers/stage.timer.test.ts

test.social/core/timers/stage.timer.test.ts is a file in Codebase Testing. 162 lines of code and 0 definitions.

import { describe, expect, it } from "vitest";
import {
    playWhileVisible,
    sampleAt,
    samplesOf,
    sequenceIndexAt,
    startClock,
} from "@banes-lab/social-share/core/timers/stage.timer.ts";
import { EMPTY_SEQUENCE } from "@banes-lab/social-share/configuration/strings/card.strings.ts";

type Visibility = (entries: readonly { isIntersecting: boolean }[]) => void;

interface VisibilityStub {
    readonly show: (visible: boolean) => void;
    readonly disconnected: () => boolean;
}

const ignore: Visibility = () => {};

const noFailure = (): void => undefined;

const stubVisibility = function stubVisibility(): VisibilityStub {
    const state = { disconnected: false, notify: ignore };
    Reflect.set(
        globalThis,
        "IntersectionObserver",
        class {
            public constructor(callback: Visibility) {
                state.notify = callback;
            }

            public observe(): void {}

            public disconnect(): void {
                state.disconnected = true;
            }
        },
    );
    return {
        disconnected: () => state.disconnected,
        show: (visible: boolean) => {
            state.notify([{ isIntersecting: visible }]);
        },
    };
};

const stubFrames = function stubFrames(): ((time: number) => void)[] {
    const queued: ((time: number) => void)[] = [];
    Reflect.set(globalThis, "requestAnimationFrame", (callback: (time: number) => void): number => {
        queued.push(callback);
        return queued.length;
    });
    Reflect.set(globalThis, "cancelAnimationFrame", (): void => {});
    return queued;
};

const TIMELINE = { fps: 20, frames: 60, keyFrame: 0, loop: true };

describe("samplesOf", () => {
    it("holds a single sample for a still timeline", () => {
        expect(samplesOf({ fps: 20, frames: 1, keyFrame: 0, loop: true }, 33)).toStrictEqual([0]);
    });

    it("resamples a looping timeline to the output rate without repeating the seam", () => {
        const samples = samplesOf({ fps: 20, frames: 60, keyFrame: 0, loop: true }, 33);
        expect(samples).toHaveLength(99);
        expect(samples[0]).toBe(0);
        expect(samples.at(-1)).toBeLessThan(60);
        expect(samples[1]).toBeCloseTo(60 / 99);
    });

    it("ends a one-shot timeline exactly on its last frame", () => {
        const samples = samplesOf({ fps: 10, frames: 11, keyFrame: 0, loop: false }, 20);
        expect(samples[0]).toBe(0);
        expect(samples.at(-1)).toBeCloseTo(10);
    });
});

describe("sampleAt", () => {
    it("wraps a looping clock and holds a one-shot clock on its last sample", () => {
        expect(sampleAt(0, 10, 33, true)).toBe(0);
        expect(sampleAt(1000, 10, 33, true)).toBe(3);
        expect(sampleAt(1000, 10, 33, false)).toBe(9);
        expect(sampleAt(-50, 10, 33, true)).toBe(0);
    });
});

describe("sequenceIndexAt", () => {
    it("picks the frame whose delay covers the elapsed time, wrapping at the sequence's own loop", () => {
        const delays = [30, 30, 60];
        expect(sequenceIndexAt(delays, 0)).toBe(0);
        expect(sequenceIndexAt(delays, 29.6)).toBe(1);
        expect(sequenceIndexAt(delays, 75)).toBe(2);
        expect(sequenceIndexAt(delays, 120)).toBe(0);
    });

    it("throws for a sequence with no duration", () => {
        expect(() => sequenceIndexAt([], 0)).toThrow(EMPTY_SEQUENCE);
        expect(() => sequenceIndexAt([0, 0], 0)).toThrow(EMPTY_SEQUENCE);
    });
});

describe("startClock", () => {
    it("emits the first sample on the first animation frame and stops when disposed", () => {
        const queued: ((time: number) => void)[] = [];
        const canceled: number[] = [];
        Reflect.set(globalThis, "requestAnimationFrame", (callback: (time: number) => void): number => {
            queued.push(callback);
            return queued.length;
        });
        Reflect.set(globalThis, "cancelAnimationFrame", (handle: number): void => {
            canceled.push(handle);
        });
        const seen: number[] = [];
        const stop = startClock({ fps: 20, frames: 60, keyFrame: 0, loop: true }, 33, (frame) => {
            seen.push(frame);
        });
        queued[0]?.(performance.now());
        stop();
        expect(seen).toStrictEqual([0]);
        expect(canceled).toHaveLength(1);
    });
});

describe("playWhileVisible", () => {
    it("paints only while the element is on screen and skips frames while a paint is pending", async () => {
        const visibility = stubVisibility();
        const queued = stubFrames();
        const painted: number[] = [];
        const pending: (() => void)[] = [];
        const stop = playWhileVisible(
            document.createElement("div"),
            TIMELINE,
            33,
            async (frame) => {
                painted.push(frame);
                return new Promise<void>((settle) => {
                    pending.push(settle);
                });
            },
            noFailure,
        );
        expect(queued).toHaveLength(0);
        visibility.show(true);
        queued[0]?.(performance.now());
        queued[1]?.(performance.now() + 100);
        expect(painted).toHaveLength(1);
        pending[0]?.();
        await Promise.resolve();
        visibility.show(false);
        stop();
        expect(visibility.disconnected()).toBe(true);
    });

    it("reports a failed paint once and stays stopped when the element shows again", async () => {
        const visibility = stubVisibility();
        const queued = stubFrames();
        const failures: unknown[] = [];
        playWhileVisible(
            document.createElement("div"),
            TIMELINE,
            33,
            async () => {
                await Promise.resolve();
                throw new Error("broken");
            },
            (failure) => {
                failures.push(failure);
            },
        );
        visibility.show(true);
        queued[0]?.(performance.now());
        await new Promise((settle) => {
            setTimeout(settle, 0);
        });
        visibility.show(true);
        expect(failures).toHaveLength(1);
        expect(queued).toHaveLength(2);
    });
});