import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { renderToolcraftArtifactFrame } from "./artifact-frame-renderer"; function deferred() { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function createContext(events: string[]) { return { clearRect: vi.fn(() => events.push("clear")), fillRect: vi.fn(() => events.push("background")), fillStyle: "", restore: vi.fn(() => events.push("restore")), save: vi.fn(() => events.push("save")), scale: vi.fn(() => events.push("scale")), setTransform: vi.fn( (a: number, _b: number, _c: number, d: number, e: number, f: number) => events.push( a === 1 && d === 1 && e === 0 && f === 0 ? "reset-transform" : "scene-transform", ), ), translate: vi.fn(() => events.push("translate")), }; } describe("renderToolcraftArtifactFrame", () => { it("keeps the scene transform active until async product rendering settles", async () => { const events: string[] = []; const pending = deferred(); const context = createContext(events); const canvas = { getContext: vi.fn(() => context), height: 200, width: 400, } as unknown as HTMLCanvasElement; const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ); const rendering = renderToolcraftArtifactFrame({ signal: new AbortController().signal, backgroundColor: "#123456", canvas, includeBackground: true, outputFrame: { height: 100, width: 200, x: -10, y: -20 }, pixelRatio: 2, productFrame: { kind: "ready", rect: { height: 40, width: 60, x: 30, y: 25 }, }, renderProductFrame: async () => { events.push("product-start"); await pending.promise; events.push("product-end"); }, renderRuntimeScene: async () => { events.push("runtime-scene"); }, rendererPipeline: null, state, }); await Promise.resolve(); expect(events).toEqual([ "reset-transform", "clear", "background", "save", "scene-transform", "runtime-scene", "product-start", ]); expect(context.setTransform).toHaveBeenLastCalledWith(2, 0, 0, 2, 20, 40); pending.resolve(); await rendering; expect(events.at(-2)).toBe("product-end"); expect(events.at(-1)).toBe("restore"); expect(context.translate).not.toHaveBeenCalled(); }); it("restores the context and types an unknown renderer failure", async () => { const events: string[] = []; const context = createContext(events); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ); await expect( renderToolcraftArtifactFrame({ signal: new AbortController().signal, backgroundColor: "#000000", canvas, includeBackground: false, outputFrame: { height: 100, width: 100, x: 0, y: 0 }, pixelRatio: 1, productFrame: { kind: "ready", rect: { height: 20, width: 20, x: 5, y: 5 }, }, renderProductFrame: () => { throw new Error("broken product"); }, renderRuntimeScene: async () => undefined, rendererPipeline: null, state, }), ).rejects.toMatchObject({ feedback: { code: "product-frame-render-failed" }, }); expect(context.restore).toHaveBeenCalledOnce(); }); it("passes the exact product rect under the shared world transform", async () => { const events: string[] = []; const context = createContext(events); const canvas = { getContext: vi.fn(() => context), height: 100, width: 200, } as unknown as HTMLCanvasElement; const state = createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ); const renderProductFrame = vi.fn(); const renderRuntimeScene = vi.fn(async () => undefined); await renderToolcraftArtifactFrame({ signal: new AbortController().signal, backgroundColor: "#000000", canvas, includeBackground: false, outputFrame: { height: 100, width: 200, x: -80, y: -30 }, pixelRatio: 1, productFrame: { kind: "ready", rect: { height: 20, width: 40, x: 15, y: 10 }, }, renderProductFrame, renderRuntimeScene, rendererPipeline: null, state, }); expect(renderRuntimeScene).toHaveBeenCalledWith( canvas, { height: 100, width: 200, x: -80, y: -30 }, state, ); expect(renderProductFrame).toHaveBeenCalledWith( expect.objectContaining({ frame: { height: 20, width: 40, x: 15, y: 10 }, }), ); expect(context.setTransform).toHaveBeenLastCalledWith(1, 0, 0, 1, 80, 30); expect(context.translate).not.toHaveBeenCalled(); }); it.each(["empty", "unavailable"] as const)( "does not call the product renderer for a %s product frame", async (kind) => { const context = createContext([]); const canvas = { getContext: vi.fn(() => context), height: 100, width: 100, } as unknown as HTMLCanvasElement; const renderProductFrame = vi.fn(); await renderToolcraftArtifactFrame({ signal: new AbortController().signal, backgroundColor: "#000000", canvas, includeBackground: false, outputFrame: { height: 100, width: 100, x: 0, y: 0 }, pixelRatio: 1, productFrame: { kind, rect: null }, renderProductFrame, renderRuntimeScene: async () => undefined, rendererPipeline: null, state: createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ), }); expect(renderProductFrame).not.toHaveBeenCalled(); expect(context.save).toHaveBeenCalledOnce(); expect(context.restore).toHaveBeenCalledOnce(); }, ); });