import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() })); vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); let thumbnails: typeof import("./videoThumbnails"); const frame = { width: 480, height: 270 }; const player = () => ({ replaceAsync: vi.fn(async (): Promise => {}), generateThumbnailsAsync: vi.fn(async () => [frame]), release: vi.fn(), }); const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); beforeEach(async () => { vi.resetModules(); mocks.createPlayer.mockReset().mockImplementation(player); thumbnails = await import("./videoThumbnails"); }); afterEach(() => vi.useRealTimers()); describe("video thumbnails", () => { it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { const file = source(); const resolveSource = vi.fn(async () => file); const signal = new AbortController().signal; const results = await Promise.all([ thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), ]); expect(results).toEqual([frame, frame]); expect(resolveSource).toHaveBeenCalledTimes(1); expect(mocks.createPlayer).toHaveBeenCalledTimes(1); expect(file.dispose).toHaveBeenCalledTimes(1); const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); expect(refreshed).not.toHaveBeenCalled(); }); it("serializes decoding and skips queued requests that scroll out of view", async () => { const started = Promise.withResolvers(); const generated = Promise.withResolvers<(typeof frame)[]>(); const first = player(); first.generateThumbnailsAsync.mockImplementation(() => { started.resolve(); return generated.promise; }); mocks.createPlayer.mockReturnValueOnce(first); const firstRequest = thumbnails.loadVideoThumbnail( "first", async () => source(), new AbortController().signal, ); await started.promise; const removed = new AbortController(); const skipped = vi.fn(async () => source()); const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal); const next = vi.fn(async () => source()); const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal); expect(next).not.toHaveBeenCalled(); removed.abort(); generated.resolve([frame]); expect(await firstRequest).toBe(frame); expect(await queued).toBeNull(); expect(await nextRequest).toBe(frame); expect(skipped).not.toHaveBeenCalled(); expect(first.release).toHaveBeenCalledTimes(1); }); it("releases an active canceled player and ignores late source loading", async () => { const started = Promise.withResolvers(); const replaced = Promise.withResolvers(); const first = player(); first.replaceAsync.mockImplementation(() => { started.resolve(); return replaced.promise; }); mocks.createPlayer.mockReturnValueOnce(first); const file = source(); const controller = new AbortController(); const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); await started.promise; controller.abort(); expect(await request).toBeNull(); expect(first.release).toHaveBeenCalledTimes(1); expect(file.dispose).toHaveBeenCalledTimes(1); replaced.resolve(); expect( await thumbnails.loadVideoThumbnail( "next", async () => source(), new AbortController().signal, ), ).toBe(frame); expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); }); it("releases failed extractions and permits a later retry", async () => { const broken = player(); broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); mocks.createPlayer.mockReturnValueOnce(broken); const file = source(); expect( await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), ).toBeNull(); expect(broken.release).toHaveBeenCalledTimes(1); expect(file.dispose).toHaveBeenCalledTimes(1); expect( await thumbnails.loadVideoThumbnail( "retry", async () => source(), new AbortController().signal, ), ).toBe(frame); }); it("does not let an unreachable source block the queue indefinitely", async () => { vi.useFakeTimers(); const started = Promise.withResolvers(); const first = player(); first.replaceAsync.mockImplementation(() => { started.resolve(); return new Promise(() => {}); }); mocks.createPlayer.mockReturnValueOnce(first); const file = source(); const request = thumbnails.loadVideoThumbnail( "unreachable", async () => file, new AbortController().signal, ); await started.promise; await vi.advanceTimersByTimeAsync(15_000); expect(await request).toBeNull(); expect(first.release).toHaveBeenCalledTimes(1); expect(file.dispose).toHaveBeenCalledTimes(1); expect( await thumbnails.loadVideoThumbnail( "reachable", async () => source(), new AbortController().signal, ), ).toBe(frame); }); it("bounds the retained native images without invalidating frames still displayed", async () => { for (let i = 0; i < 33; i++) { await thumbnails.loadVideoThumbnail( `clip:${i}`, async () => source(), new AbortController().signal, ); } expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); expect(mocks.createPlayer).toHaveBeenCalledTimes(33); expect( await thumbnails.loadVideoThumbnail( "clip:0", async () => source(), new AbortController().signal, ), ).toBe(frame); expect(mocks.createPlayer).toHaveBeenCalledTimes(34); }); });