import { afterEach, describe, expect, it, vi } from "vitest"; import { createHydrationModelAsset } from "../../model-import/model-persistence-hydration-test-support"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import type { ToolcraftModelRenderHost } from "../model-rendering/model-render-binding"; import { renderToolcraftRuntimeSceneToCanvas } from "./runtime-scene-export"; afterEach(() => { vi.unstubAllGlobals(); }); function createContext(events: string[]) { return { beginPath: vi.fn(), clip: vi.fn(), drawImage: vi.fn((source: { kind?: string }) => { events.push(source.kind ?? "unknown"); }), rect: vi.fn(), restore: vi.fn(), rotate: vi.fn(), save: vi.fn(), scale: vi.fn(), translate: vi.fn(), }; } function createHost(source: HTMLCanvasElement): ToolcraftModelRenderHost { return { acquirePresentation: vi.fn(), dispose: vi.fn(), getPreparationStatus: vi.fn(() => "ready" as const), hitTest: vi.fn(), prepare: vi.fn(async () => undefined), release: vi.fn(), renderExport: vi.fn(async (_request, context) => { await context.onRendered?.(source); }), renderPreview: vi.fn(), subscribePreparation: vi.fn(() => () => undefined), }; } describe("runtime scene export", () => { it("resolves current binary image resources for export", async () => { class LoadedImage { onerror: (() => void) | null = null; onload: (() => void) | null = null; set src(_value: string) { queueMicrotask(() => this.onload?.()); } } const createObjectURL = vi.fn(() => "blob:toolcraft-export"); const revokeObjectURL = vi.fn(); vi.stubGlobal("Image", LoadedImage); vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); const resourceRef = `media:image:sha256:${"a".repeat(64)}`; const state = createToolcraftState( defineToolcraft({ canvas: { enabled: true }, panels: {} }), { mediaAssets: [ { assetKind: "image", fileName: "resource.png", id: "resource-image", layerId: "resource-layer", lifecycle: "ready", mimeType: "image/png", position: { x: 0, y: 0 }, resourceRef, size: { height: 20, unit: "px", width: 20 }, }, ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const resolveImageResource = vi.fn(async () => new Uint8Array([1, 2, 3])); await expect( renderToolcraftRuntimeSceneToCanvas({ canvas, canvasFrame: { kind: "finite", size: state.canvas.size }, exportFrame: { height: 100, width: 100, x: 0, y: 0 }, host: null, resolveImageResource, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), ).resolves.toEqual({ imageCount: 1, modelCount: 0 }); expect(resolveImageResource).toHaveBeenCalledWith(resourceRef, { signal: expect.any(AbortSignal), }); expect(createObjectURL).toHaveBeenCalledOnce(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:toolcraft-export"); expect(context.drawImage).toHaveBeenCalledOnce(); }); it("draws runtime models before images in preview order", async () => { const schema = defineToolcraft({ canvas: { enabled: true }, panels: {} }); const state = createToolcraftState(schema, { canvas: { mode: "infinite" }, layers: [ { displayName: "Model", id: "model-layer", kind: "layer", name: "model", visible: true, }, { displayName: "Image", id: "image-layer", kind: "layer", name: "image", visible: true, }, ], mediaAssets: [ createHydrationModelAsset("document:model", { id: "model", layerId: "model-layer", lifecycle: "clean", position: { x: 0, y: 0 }, size: { height: 20, unit: "px", width: 20 }, sourceTarget: "source.model", }), { assetKind: "image", dataUrl: "data:image/png;base64,test", fileName: "image.png", id: "image", layerId: "image-layer", mimeType: "image/png", position: { x: 50, y: 25 }, size: { height: 10, unit: "px", width: 20 }, }, ], }); const events: string[] = []; const context = createContext(events); const canvas = { getContext: vi.fn(() => context), height: 200, width: 400, } as unknown as HTMLCanvasElement; const modelSource = { kind: "model" } as unknown as HTMLCanvasElement; const imageSource = { kind: "image" } as unknown as CanvasImageSource; await expect( renderToolcraftRuntimeSceneToCanvas({ canvas, canvasFrame: { kind: "infinite" }, exportFrame: { height: 50, width: 100, x: 0, y: 0 }, host: createHost(modelSource), loadImage: vi.fn(async () => imageSource), state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), ).resolves.toEqual({ imageCount: 1, modelCount: 1 }); expect(events).toEqual(["model", "image"]); expect(context.translate).toHaveBeenCalledWith(200, 100); expect(context.drawImage).toHaveBeenLastCalledWith( imageSource, -40, -20, 80, 40, ); }); it("honors one visibility snapshot for default images and custom models", async () => { const state = createToolcraftState( defineToolcraft({ canvas: { enabled: true }, panels: {} }), { canvas: { mode: "infinite" }, mediaAssets: [ createHydrationModelAsset("document:model", { lifecycle: "clean", sourceTarget: "source.model", }), ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const loadImage = vi.fn(); const host = createHost({ kind: "model" } as unknown as HTMLCanvasElement); await expect( renderToolcraftRuntimeSceneToCanvas({ canvas, canvasFrame: { kind: "infinite" }, exportFrame: { height: 100, width: 100, x: 0, y: 0 }, host, loadImage, state, visibility: { renderDefaultImages: false, suppressedModelTargets: ["source.model"], }, }), ).resolves.toEqual({ imageCount: 0, modelCount: 0 }); expect(host.renderExport).not.toHaveBeenCalled(); expect(loadImage).not.toHaveBeenCalled(); }); it("excludes unavailable images before resolving export resources", async () => { const resourceRef = `media:image:sha256:${"b".repeat(64)}`; const state = createToolcraftState( defineToolcraft({ canvas: { enabled: true }, panels: {} }), { mediaAssets: [ { assetKind: "image", error: { code: "binary-resource-unavailable", message: "The image resource is unavailable.", }, fileName: "unavailable.png", id: "unavailable-image", layerId: "unavailable-layer", lifecycle: "unavailable", mimeType: "image/png", position: { x: 0, y: 0 }, resourceRef, size: { height: 20, unit: "px", width: 20 }, }, ], }, ); const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const resolveImageResource = vi.fn(async () => new Uint8Array([1, 2, 3])); await expect( renderToolcraftRuntimeSceneToCanvas({ canvas, canvasFrame: { kind: "finite", size: state.canvas.size }, exportFrame: { height: 100, width: 100, x: 0, y: 0 }, host: null, resolveImageResource, state, visibility: { renderDefaultImages: true, suppressedModelTargets: [], }, }), ).resolves.toEqual({ imageCount: 0, modelCount: 0 }); expect(resolveImageResource).not.toHaveBeenCalled(); expect(context.drawImage).not.toHaveBeenCalled(); }); });