import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { createToolcraftRuntimeSceneVisibility } from "../scene"; import { toolcraftImageExportFormatTarget, toolcraftImageExportResolutionTarget, } from "./artifact-export-settings"; import { exportToolcraftImageArtifact } from "./image-artifact-export"; function createState( format: "jpg" | "png" = "png", mode: "finite" | "infinite" = "finite", ) { return createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { canvas: { mode }, values: { [toolcraftImageExportFormatTarget]: format, [toolcraftImageExportResolutionTarget]: "2k", }, }, ); } function createCanvas(blob: Blob | null) { const context = { clearRect: vi.fn(), fillRect: vi.fn(), fillStyle: "", restore: vi.fn(), save: vi.fn(), scale: vi.fn(), setTransform: vi.fn(), translate: vi.fn(), }; const canvas = { getContext: vi.fn(() => context), height: 0, toBlob: vi.fn((callback: BlobCallback) => callback(blob)), width: 0, } as unknown as HTMLCanvasElement; return { canvas, context }; } const visibility = createToolcraftRuntimeSceneVisibility({ renderDefaultImages: true, suppressedModelTargets: [], }); describe("exportToolcraftImageArtifact", () => { it("renders and downloads the selected real image format and dimensions", async () => { const { canvas } = createCanvas(new Blob(["jpeg"], { type: "image/jpeg" })); const downloadArtifact = vi.fn(() => ({ fileName: "scene.jpg" })); const renderFrame = vi.fn(); const renderRuntimeScene = vi.fn(async () => undefined); const result = await exportToolcraftImageArtifact({ boundsProvider: undefined, canvasFactory: () => canvas, downloadArtifact, exportRenderer: { baseFileName: "scene", renderFrame, }, renderRuntimeScene, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state: createState("jpg"), visibility, }); expect(result).toEqual({ byteLength: 4, extension: ".jpg", height: 1152, mediaType: "image/jpeg", width: 2048, }); expect(canvas.width).toBe(1); expect(canvas.height).toBe(1); expect(canvas.toBlob).toHaveBeenCalledWith( expect.any(Function), "image/jpeg", 0.95, ); expect(downloadArtifact).toHaveBeenCalledWith( expect.objectContaining({ extension: ".jpg", rawBaseFileName: "scene" }), ); expect(renderRuntimeScene).toHaveBeenCalledWith( canvas, { height: 1080, width: 1920, x: -960, y: -540 }, expect.anything(), ); expect(renderFrame).toHaveBeenCalledWith( expect.objectContaining({ frame: { height: 1080, width: 1920, x: -960, y: -540 }, }), ); }); it("fails visibly when canvas encoding returns no blob", async () => { const { canvas } = createCanvas(null); await expect( exportToolcraftImageArtifact({ boundsProvider: undefined, canvasFactory: () => canvas, downloadArtifact: vi.fn(), exportRenderer: undefined, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state: createState(), visibility, }), ).rejects.toMatchObject({ feedback: { code: "image-encode-failed" }, }); }); it("ignores an unused throwing provider for runtime-only image export", async () => { const { canvas } = createCanvas(new Blob(["png"], { type: "image/png" })); const boundsProvider = vi.fn(() => { throw new Error("unused product provider"); }); const renderRuntimeScene = vi.fn(async () => undefined); await expect( exportToolcraftImageArtifact({ boundsProvider, canvasFactory: () => canvas, downloadArtifact: vi.fn(), exportRenderer: undefined, renderRuntimeScene, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state: createState(), visibility, }), ).resolves.toMatchObject({ extension: ".png" }); expect(boundsProvider).not.toHaveBeenCalled(); expect(renderRuntimeScene).toHaveBeenCalledOnce(); }); it("requires bounds for a renderer-present infinite image export", async () => { const canvasFactory = vi.fn(() => createCanvas(new Blob()).canvas); await expect( exportToolcraftImageArtifact({ boundsProvider: undefined, canvasFactory, downloadArtifact: vi.fn(), exportRenderer: { baseFileName: "scene", renderFrame: vi.fn() }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state: createState("png", "infinite"), visibility, }), ).rejects.toMatchObject({ feedback: { code: "scene-bounds-unavailable" }, }); expect(canvasFactory).not.toHaveBeenCalled(); }); it.each([ () => { throw new Error("provider failed"); }, () => [{ height: 10, width: Number.NaN, x: 0, y: 0 }], ])("rejects a throwing or invalid product bounds provider", async (boundsProvider) => { const { canvas } = createCanvas(new Blob(["png"])); await expect( exportToolcraftImageArtifact({ boundsProvider, canvasFactory: () => canvas, downloadArtifact: vi.fn(), exportRenderer: { baseFileName: "scene", renderFrame: vi.fn() }, renderRuntimeScene: async () => undefined, rendererPipeline: null, signal: new AbortController().signal, reportProgress: vi.fn(), state: createState(), visibility, }), ).rejects.toMatchObject({ feedback: { code: "scene-bounds-unavailable" }, }); }); });