import { act, renderHook } from "@testing-library/react"; import type * as React from "react"; import { describe, expect, it, vi } from "vitest"; import { createToolcraftRendererPipelineRuntime, registerToolcraftRendererPipeline, type ToolcraftRendererPipelineInvalidation, type ToolcraftRendererPipelinePassContract, type ToolcraftRendererPipelinePassExecutionContext, } from "../../rendering"; import { ToolcraftPipelineContext } from "./toolcraft-pipeline-context"; import { useToolcraftPipelinePass } from "./use-toolcraft-pipeline-pass"; function createDeferred() { let reject!: (error: unknown) => void; let resolve!: (value: T) => void; const promise = new Promise((resolvePromise, rejectPromise) => { reject = rejectPromise; resolve = resolvePromise; }); return { promise, reject, resolve }; } function createRegistration() { return registerToolcraftRendererPipeline<{ preview: ToolcraftRendererPipelinePassContract; }>()({ interactionInvalidation: [], passes: [ { cacheKey: ["value"], id: "preview", inputs: ["value"], invalidatedBy: ["value"], kind: "composite", lifecycle: { cache: "memoized", resourceScope: "renderer" }, output: "preview", quality: "full", runsOn: "main", }, ], runtimeId: "pipeline-hook-test-v1", }); } type RetainedResource = Readonly<{ id: string }>; function createRetainedRegistration() { return registerToolcraftRendererPipeline<{ preview: ToolcraftRendererPipelinePassContract< string, RetainedResource, readonly [resourceId: string] >; }>()({ interactionInvalidation: [], passes: [ { cacheKey: ["value"], id: "preview", inputs: ["value"], invalidatedBy: ["value"], kind: "composite", lifecycle: { cache: "retained-resource", resourceScope: "renderer", }, output: "preview", quality: "full", runsOn: "main", }, ], runtimeId: "pipeline-hook-retained-test-v1", }); } describe("useToolcraftPipelinePass", () => { it("keeps retained resources available to compute and disposes one shared key once", async () => { const registration = createRetainedRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const create = vi.fn(() => ({ id: "gpu" })); const dispose = vi.fn(); const resources: RetainedResource[] = []; const compute = vi.fn(async ( context: ToolcraftRendererPipelinePassExecutionContext< ReturnType >, ) => { const resource = await context.getOrCreateResource( ["gpu"], create, dispose, ); resources.push(resource); return resource.id; }); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { rerender, result } = renderHook( ({ value }) => useToolcraftPipelinePass( registration.getPass("preview"), { value }, compute, ), { initialProps: { value: "retained-a" }, wrapper }, ); await act(() => Promise.resolve()); expect(result.current).toEqual({ result: "gpu", status: "success" }); rerender({ value: "retained-b" }); await act(() => Promise.resolve()); expect(result.current).toEqual({ result: "gpu", status: "success" }); expect(compute).toHaveBeenCalledTimes(2); expect(create).toHaveBeenCalledTimes(1); expect(resources).toHaveLength(2); expect(resources[1]).toBe(resources[0]); await runtime.dispose(); expect(dispose).toHaveBeenCalledTimes(1); }); it("keeps an identical cache input on the memoized retained-resource result after rerender", async () => { const registration = createRetainedRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const create = vi.fn(() => ({ id: "gpu" })); const dispose = vi.fn(); const compute = vi.fn(async ( context: ToolcraftRendererPipelinePassExecutionContext< ReturnType >, ) => { const resource = await context.getOrCreateResource( ["gpu"], create, dispose, ); return resource.id; }); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { rerender, result } = renderHook( ({ rerenderToken }) => { void rerenderToken; return useToolcraftPipelinePass( registration.getPass("preview"), { value: "retained" }, compute, ); }, { initialProps: { rerenderToken: 0 }, wrapper }, ); await act(() => Promise.resolve()); expect(result.current).toEqual({ result: "gpu", status: "success" }); rerender({ rerenderToken: 1 }); await act(() => Promise.resolve()); expect(result.current).toEqual({ result: "gpu", status: "success" }); expect(compute).toHaveBeenCalledTimes(1); expect(create).toHaveBeenCalledTimes(1); await runtime.dispose(); expect(dispose).toHaveBeenCalledTimes(1); }); it("keeps retained-resource fallback behavior explicit without a pipeline registration", async () => { const registration = createRetainedRegistration(); const invalidatePass = vi.fn(); const invalidateSource = vi.fn(); let fallbackContext: object | undefined; let passInvalidation: ToolcraftRendererPipelineInvalidation | undefined; let sourceInvalidation: ToolcraftRendererPipelineInvalidation | undefined; const { result } = renderHook(() => useToolcraftPipelinePass( registration.getPass("preview"), { value: "fallback-retained" }, async (context) => { fallbackContext = context; passInvalidation = context.invalidatePass(); sourceInvalidation = context.invalidateSource("source"); await passInvalidation.cleanup; await sourceInvalidation.cleanup; invalidatePass(); invalidateSource(); await context.getOrCreateResource( ["gpu"], () => ({ id: "gpu" }), () => undefined, ); return "unexpected"; }, ), ); await act(() => Promise.resolve()); expect(invalidatePass).toHaveBeenCalledTimes(1); expect(invalidateSource).toHaveBeenCalledTimes(1); expect(Object.isFrozen(fallbackContext)).toBe(true); expect(passInvalidation).toBe(sourceInvalidation); expect(result.current).toEqual({ error: new Error( "rendererPipelineRegistration is required for retained renderer resources", ), status: "error", }); }); it("does not expose retained resources to callbacks for ordinary passes", () => { const registration = createRegistration(); if (false) { useToolcraftPipelinePass( registration.getPass("preview"), { value: "typed" }, (context) => { // @ts-expect-error Memoized passes do not expose retained renderer resources. void context.getOrCreateResource; return "typed"; }, ); } expect(registration.getPass("preview").id).toBe("preview"); }); it("bridges a synchronous pipeline result into the initial committed state", () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const compute = vi.fn(() => "model-a"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => useToolcraftPipelinePass( registration.getPass("preview"), { value: "a" }, compute, ), { wrapper }, ); expect(result.current).toEqual({ result: "model-a", status: "success", }); expect(compute).toHaveBeenCalledTimes(1); }); it("does not restart a pass when an inline compute callback is recreated by its result render", () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); let computeCalls = 0; let renders = 0; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook( () => { renders += 1; return useToolcraftPipelinePass( registration.getPass("preview"), { value: "inline" }, () => { computeCalls += 1; return "model-inline"; }, ); }, { wrapper }, ); expect(result.current).toEqual({ result: "model-inline", status: "success", }); expect(computeCalls).toBe(1); expect(renders).toBeLessThanOrEqual(2); expect(runtime.getSnapshot().passes.preview.executions).toBe(1); }); it("computes synchronously without a registered pipeline client", () => { const registration = createRegistration(); const compute = () => "fallback-model"; const { result } = renderHook(() => useToolcraftPipelinePass( registration.getPass("preview"), { value: "fallback" }, compute, ), ); expect(result.current).toEqual({ result: "fallback-model", status: "success", }); }); it("suppresses stale async results after request identity changes", async () => { const registration = createRegistration(); const first = createDeferred(); const second = createDeferred(); const { result, rerender } = renderHook( ({ cacheInput, compute }) => useToolcraftPipelinePass( registration.getPass("preview"), cacheInput, compute, ), { initialProps: { cacheInput: { value: "a" }, compute: () => first.promise, }, }, ); rerender({ cacheInput: { value: "b" }, compute: () => second.promise, }); await act(async () => first.resolve("stale-model")); expect(result.current.status).toBe("pending"); await act(async () => second.resolve("current-model")); expect(result.current).toEqual({ result: "current-model", status: "success", }); }); it("returns an explicit error result for rejected work", async () => { const registration = createRegistration(); const failure = new Error("render failed"); const compute = () => Promise.reject(failure); const { result } = renderHook(() => useToolcraftPipelinePass( registration.getPass("preview"), { value: "failure" }, compute, ), ); await act(() => Promise.resolve()); expect(result.current).toEqual({ error: failure, status: "error", }); }); it("keeps the resolved model when compute identity changes without descriptor changes", async () => { const registration = createRegistration(); const runtime = createToolcraftRendererPipelineRuntime(registration); const firstCompute = vi.fn(() => "cached-model"); const secondCompute = vi.fn(() => "unexpected-model"); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result, rerender } = renderHook( ({ cacheInput, compute }) => useToolcraftPipelinePass( registration.getPass("preview"), cacheInput, compute, ), { initialProps: { cacheInput: { value: "same" }, compute: firstCompute, }, wrapper, }, ); rerender({ cacheInput: { value: "same" }, compute: secondCompute, }); await act(() => Promise.resolve()); expect(result.current).toEqual({ result: "cached-model", status: "success", }); expect(firstCompute).toHaveBeenCalledTimes(1); expect(secondCompute).not.toHaveBeenCalled(); expect(runtime.getSnapshot().passes.preview.cacheHits).toBe(0); expect(runtime.getSnapshot().passes.preview.executions).toBe(1); }); });