import { describe, expect, it, vi } from "vitest"; import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets"; import { createThumbnailKey, createThumbnailRequestIdentity, ThumbnailScheduler, type ThumbnailLoadedResult, type ThumbnailPriority, type ThumbnailRequest, } from "./thumbnailScheduler"; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((accept, fail) => { resolve = accept; reject = fail; }); return { promise, resolve, reject }; } function result(name: string, weight = 1, dispose = vi.fn()): ThumbnailLoadedResult { return { value: { kind: "image", url: name, aspect: 1 }, weight, dispose }; } function request( key: string, load: ThumbnailRequest["load"], priority: ThumbnailPriority = "visible", overrides: Partial = {}, ): ThumbnailRequest { return { key, projectId: "project-a", sessionEpoch: 1, kind: "image", priority, load, ...overrides, }; } async function flush(): Promise { await Promise.resolve(); await Promise.resolve(); } describe("ThumbnailScheduler", () => { it("deduplicates identical requests and releases subscribers independently", async () => { const scheduler = new ThumbnailScheduler(); const pending = deferred(); const load = vi.fn(() => pending.promise); const listenerA = vi.fn(); const listenerB = vi.fn(); const leaseA = scheduler.acquire(request("same", load), listenerA); const leaseB = scheduler.acquire(request("same", load), listenerB); expect(load).toHaveBeenCalledTimes(1); leaseA.release(); listenerA.mockClear(); pending.resolve(result("poster")); await flush(); expect(scheduler.getSnapshot(request("same", load))).toMatchObject({ status: "ready", value: { url: "poster" }, }); expect(listenerA).not.toHaveBeenCalled(); expect(listenerB).toHaveBeenCalled(); leaseB.release(); expect(scheduler.getDiagnostics().leases).toBe(0); }); it("keeps snapshots referentially stable and supports independent leases sharing a callback", async () => { const scheduler = new ThumbnailScheduler(); const listener = vi.fn(); const shared = request("stable", async () => result("stable")); const first = scheduler.acquire(shared, listener); const second = scheduler.acquire(shared, listener); await flush(); expect(scheduler.getSnapshot(shared)).toBe(scheduler.getSnapshot(shared)); listener.mockClear(); scheduler.invalidateProject("project-a"); expect(listener).not.toHaveBeenCalled(); expect(scheduler.getDiagnostics().leases).toBe(2); first.release(); second.release(); scheduler.invalidateProject("project-a"); expect(scheduler.getSnapshot(shared).status).toBe("idle"); }); it("scopes the same caller key by work shape", async () => { const scheduler = new ThumbnailScheduler(); const poster = request("shared", async () => result("poster")); const strip = request("shared", async () => result("strip"), "visible", { kind: "video", rich: true, }); scheduler.acquire(poster, vi.fn()); scheduler.acquire(strip, vi.fn()); await flush(); expect(scheduler.getSnapshot(poster)).toMatchObject({ status: "ready", value: { url: "poster" }, }); expect(scheduler.getSnapshot(strip)).toMatchObject({ status: "ready", value: { url: "strip" }, }); }); it("orders interaction before visible before overscan when a slot opens", async () => { const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ concurrentMetadataJobs: 1 }), ); const first = deferred(); const starts: string[] = []; const load = (name: string, pending?: ReturnType>) => vi.fn(() => { starts.push(name); return pending?.promise ?? Promise.resolve(result(name)); }); scheduler.acquire(request("first", load("first", first)), vi.fn()); scheduler.acquire(request("overscan", load("overscan"), "overscan"), vi.fn()); scheduler.acquire(request("visible", load("visible"), "visible"), vi.fn()); scheduler.acquire(request("interaction", load("interaction"), "interaction"), vi.fn()); expect(starts).toEqual(["first"]); first.resolve(result("first")); await vi.waitFor(() => { expect(starts).toEqual(["first", "interaction", "visible", "overscan"]); }); }); it("suspends new rich work while scrolling but lets posters proceed", async () => { const scheduler = new ThumbnailScheduler(); const richLoad = vi.fn(async () => result("rich")); const posterLoad = vi.fn(async () => result("poster")); scheduler.setScrolling(true); scheduler.acquire(request("rich", richLoad, "interaction", { rich: true }), vi.fn()); scheduler.acquire(request("poster", posterLoad), vi.fn()); await flush(); expect(richLoad).not.toHaveBeenCalled(); expect(posterLoad).toHaveBeenCalledTimes(1); scheduler.setScrolling(false); await flush(); expect(richLoad).toHaveBeenCalledTimes(1); }); it("aborts queued and active jobs after the final release", async () => { const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }), ); const active = deferred(); let activeSignal: AbortSignal | undefined; const activeLease = scheduler.acquire( request( "active", (signal) => { activeSignal = signal; return active.promise; }, "visible", { kind: "video" }, ), vi.fn(), ); const queuedLoad = vi.fn(async () => result("queued")); const queuedLease = scheduler.acquire( request("queued", queuedLoad, "visible", { kind: "video" }), vi.fn(), ); queuedLease.release(); activeLease.release(); expect(activeSignal?.aborted).toBe(true); expect(queuedLoad).not.toHaveBeenCalled(); active.reject(new DOMException("aborted", "AbortError")); await flush(); expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 }); }); it("disposes a late result exactly once after its final lease releases", async () => { const scheduler = new ThumbnailScheduler(); const pending = deferred(); const dispose = vi.fn(); const lease = scheduler.acquire( request("late", () => pending.promise), vi.fn(), ); lease.release(); pending.resolve(result("late", 1, dispose)); await flush(); expect(dispose).toHaveBeenCalledTimes(1); expect(scheduler.getSnapshot(request("late", () => pending.promise))).toEqual({ status: "idle", }); }); it("preserves a synchronous re-acquire when an expired failure is replaced", async () => { vi.useFakeTimers(); const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ metadataFailureTtlMs: 10 }), ); const load = vi .fn() .mockRejectedValueOnce(new Error("temporary")) .mockResolvedValue(result("recovered")); const failed = request("retry", load); let reacquireOnNotify = false; let nestedLease: ReturnType | undefined; const firstLease = scheduler.acquire(failed, () => { if (!reacquireOnNotify) return; reacquireOnNotify = false; nestedLease = scheduler.acquire(failed, vi.fn()); }); await flush(); vi.advanceTimersByTime(11); reacquireOnNotify = true; const outerLease = scheduler.acquire(failed, vi.fn()); await flush(); expect(load).toHaveBeenCalledTimes(2); expect(scheduler.getSnapshot(failed)).toMatchObject({ status: "ready", value: { url: "recovered" }, }); expect(scheduler.getDiagnostics().leases).toBe(2); firstLease.release(); nestedLease?.release(); outerLease.release(); vi.useRealTimers(); }); it("times out a hung loader, frees its bucket, and disposes a late result", async () => { vi.useFakeTimers(); const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1, thumbnailLoadTimeoutMs: 10, }), ); const hung = deferred(); const dispose = vi.fn(); let signal: AbortSignal | undefined; const hungRequest = request( "hung", (activeSignal) => { signal = activeSignal; return hung.promise; }, "visible", { kind: "video" }, ); const nextLoad = vi.fn(async () => result("next")); const nextRequest = request("next", nextLoad, "visible", { kind: "video" }); const hungLease = scheduler.acquire(hungRequest, vi.fn()); const nextLease = scheduler.acquire(nextRequest, vi.fn()); await vi.advanceTimersByTimeAsync(11); await flush(); expect(signal?.aborted).toBe(true); expect(nextLoad).toHaveBeenCalledTimes(1); expect(scheduler.getSnapshot(hungRequest).status).toBe("error"); hung.resolve(result("late", 1, dispose)); await flush(); expect(dispose).toHaveBeenCalledTimes(1); hungLease.release(); nextLease.release(); vi.useRealTimers(); }); it("isolates late results by session epoch", async () => { const scheduler = new ThumbnailScheduler(); const old = deferred(); const oldRequest = request("poster", () => old.promise, "visible", { sessionEpoch: 1 }); const nextRequest = request("poster", async () => result("next"), "visible", { sessionEpoch: 2, }); scheduler.acquire(oldRequest, vi.fn()); scheduler.acquire(nextRequest, vi.fn()); await flush(); old.resolve(result("old")); await flush(); expect(scheduler.getSnapshot(nextRequest)).toMatchObject({ status: "ready", value: { url: "next" }, }); }); it("turns synchronous loader errors into bounded failure snapshots", async () => { const scheduler = new ThumbnailScheduler(); const bad = request("throws", () => { throw new Error("sync failure"); }); const lease = scheduler.acquire(bad, vi.fn()); await flush(); expect(scheduler.getSnapshot(bad)).toMatchObject({ status: "error" }); expect(scheduler.getDiagnostics().active).toBe(0); lease.release(); }); it("disposes uncached results after the final lease and tolerates broken disposers", async () => { const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ thumbnailCacheBytes: 1 }), ); const dispose = vi.fn(() => { throw new Error("cleanup failure"); }); const oversized = request("oversized", async () => result("large", 2, dispose)); const lease = scheduler.acquire(oversized, vi.fn()); await flush(); expect(scheduler.getSnapshot(oversized).status).toBe("ready"); expect(() => lease.release()).not.toThrow(); expect(dispose).toHaveBeenCalledTimes(1); expect(scheduler.getSnapshot(oversized).status).toBe("idle"); }); it("evicts least-recently-used unleased values by bytes, count, and project count", async () => { const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ thumbnailCacheBytes: 6, thumbnailCacheEntries: 2, thumbnailCacheEntriesPerProject: 2, }), ); const disposals = [vi.fn(), vi.fn(), vi.fn()]; for (let index = 0; index < 3; index++) { const lease = scheduler.acquire( request(`key-${index}`, async () => result(`url-${index}`, 3, disposals[index])), vi.fn(), ); await flush(); lease.release(); } expect(scheduler.getDiagnostics()).toMatchObject({ cacheEntries: 2, cacheBytes: 6 }); expect(disposals[0]).toHaveBeenCalledTimes(1); expect(disposals[1]).not.toHaveBeenCalled(); expect(disposals[2]).not.toHaveBeenCalled(); }); it("keeps failed requests quiet until the TTL expires", async () => { vi.useFakeTimers(); const scheduler = new ThumbnailScheduler( resolveTimelineViewportBudgets({ metadataFailureTtlMs: 100 }), ); const load = vi.fn(async () => { throw new Error("unsupported"); }); const first = scheduler.acquire(request("failed", load), vi.fn()); await flush(); first.release(); const second = scheduler.acquire(request("failed", load), vi.fn()); expect(load).toHaveBeenCalledTimes(1); second.release(); vi.advanceTimersByTime(101); scheduler.acquire(request("failed", load), vi.fn()); await flush(); expect(load).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); }); describe("createThumbnailKey", () => { it("normalizes field order and preserves sentinel zero values", () => { expect(createThumbnailKey({ source: "clip a", time: 0, missing: undefined })).toBe( createThumbnailKey({ time: 0, source: "clip a" }), ); }); it("includes the work shape in request identity", () => { const poster = request("shared", vi.fn(), "visible", { kind: "video" }); const strip = { ...poster, rich: true }; expect(createThumbnailRequestIdentity(poster)).not.toBe(createThumbnailRequestIdentity(strip)); }); });