import { describe, expect, it, vi } from "vitest"; import { registerToolcraftRendererPipeline, type ToolcraftRendererPipelinePassContract, } from "./renderer-pipeline-registration"; import { createToolcraftRendererPipelineRuntime, type ToolcraftRendererPipelineRuntime, } from "./renderer-pipeline-runtime"; type Resource = Readonly<{ id: string }>; function createRegistration() { const pass = < Id extends "renderer" | "source", Scope extends "renderer" | "source", >( id: Id, resourceScope: Scope, ) => ({ cacheKey: ["source.asset"] as const, id, inputs: ["source.asset"], invalidatedBy: ["source.asset"], kind: "preprocess" as const, lifecycle: { cache: "retained-resource" as const, resourceScope }, output: "intermediate" as const, quality: "full" as const, runsOn: "worker" as const, }); return registerToolcraftRendererPipeline<{ renderer: ToolcraftRendererPipelinePassContract< string, Resource, readonly [resourceId: unknown] >; source: ToolcraftRendererPipelinePassContract< string, Resource, readonly [sourceId: unknown] >; }>()({ interactionInvalidation: [], passes: [pass("renderer", "renderer"), pass("source", "source")], runtimeId: "resource-lifecycle-test-v1", }); } function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { resolve = resolvePromise; }); return { promise, resolve }; } let invocation = 0; async function getResource( runtime: ToolcraftRendererPipelineRuntime, registration: ReturnType, passId: "renderer" | "source", resourceKey: readonly [unknown], create: () => PromiseLike | Resource, dispose: (resource: Resource) => PromiseLike | void, ): Promise { let resource!: Resource; await runtime.runPass( registration.getPass(passId), { "source.asset": { invocation: ++invocation } }, async (context) => { resource = await context.getOrCreateResource(resourceKey, create, dispose); return "pixels"; }, ); return resource; } describe("renderer pipeline retained resources", () => { it("cleans up rejected creation and permits retry", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const failure = new Error("resource failed"); const create = vi .fn<() => Promise>() .mockRejectedValueOnce(failure) .mockResolvedValueOnce({ id: "gpu" }); await expect( getResource(runtime, registration, "renderer", ["gpu"], create, vi.fn()), ).rejects.toBe(failure); await expect( getResource(runtime, registration, "renderer", ["gpu"], create, vi.fn()), ).resolves.toEqual({ id: "gpu" }); expect(create).toHaveBeenCalledTimes(2); expect(runtime.getSnapshot().passes.renderer.resourceCreations).toBe(1); }); it("deduplicates concurrent creation inside one execution generation", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const renderer = registration.getPass("renderer"); const creation = createDeferred(); const create = vi.fn(() => creation.promise); const dispose = vi.fn(); let first!: Promise; let second!: Promise; const execution = runtime.runPass( renderer, { "source.asset": "concurrent" }, async (context) => { first = context.getOrCreateResource(["gpu"], create, dispose); second = context.getOrCreateResource(["gpu"], create, dispose); await Promise.all([first, second]); return "pixels"; }, ); const value = { id: "gpu" }; creation.resolve(value); await expect(Promise.all([first, second])).resolves.toEqual([value, value]); await expect(execution).resolves.toBe("pixels"); expect(create).toHaveBeenCalledTimes(1); expect(runtime.getSnapshot().passes.renderer.resourceCreations).toBe(1); }); it("uses SameValueZero for source-scoped resource invalidation", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const dispose = vi.fn(); await getResource( runtime, registration, "source", [-0], () => ({ id: "zero" }), dispose, ); await runtime.invalidateSource(0).cleanup; await getResource( runtime, registration, "source", [Number.NaN], () => ({ id: "nan" }), dispose, ); await runtime.invalidateSource(Number.NaN).cleanup; expect(dispose).toHaveBeenCalledTimes(2); }); it("reuses renderer resources with SameValueZero keys", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const create = vi .fn() .mockReturnValueOnce({ id: "zero" }) .mockReturnValueOnce({ id: "nan" }); const dispose = vi.fn(); const negativeZero = await getResource( runtime, registration, "renderer", [-0], create, dispose, ); const positiveZero = await getResource( runtime, registration, "renderer", [+0], create, dispose, ); const firstNaN = await getResource( runtime, registration, "renderer", [Number.NaN], create, dispose, ); const secondNaN = await getResource( runtime, registration, "renderer", [Number.NaN], create, dispose, ); expect(positiveZero).toBe(negativeZero); expect(secondNaN).toBe(firstNaN); expect(create).toHaveBeenCalledTimes(2); }); it("creates, reuses, source-invalidates, and pass-invalidates typed resources", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const disposeRenderer = vi.fn(); const disposeSource = vi.fn(); const first = await getResource( runtime, registration, "renderer", ["shared"], () => ({ id: "renderer" }), disposeRenderer, ); const second = await getResource( runtime, registration, "renderer", ["shared"], () => ({ id: "unused" }), disposeRenderer, ); await getResource( runtime, registration, "source", ["source-a"], () => ({ id: "source-a" }), disposeSource, ); expect(second).toBe(first); expect(runtime.getSnapshot().passes.renderer.activeResources).toBe(1); expect(runtime.getSnapshot().passes.source.activeResources).toBe(1); await runtime.invalidateSource("source-a").cleanup; expect(disposeSource).toHaveBeenCalledTimes(1); expect(runtime.getSnapshot().passes.source.activeResources).toBe(0); await runtime.invalidatePass(registration.getPass("renderer")).cleanup; expect(disposeRenderer).toHaveBeenCalledTimes(1); expect(runtime.getSnapshot().passes.renderer).toMatchObject({ activeResources: 0, resourceCreations: 1, resourceDisposals: 1, }); }); });