import { act, cleanup, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec"; import { createValidModelDocument } from "../../model-import/canonical/model-document-test-support"; import { createHydrationModelAsset } from "../../model-import/model-persistence-hydration-test-support"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { ToolcraftRoot } from "../app-shell/toolcraft-root"; import type { ToolcraftModelRenderBinding } from "./model-render-binding"; import { ToolcraftModelCanvasLayer, ToolcraftModelCanvasLayers, } from "./model-canvas-layer"; import { ToolcraftModelRenderProvider } from "./model-render-provider"; type TestResource = { id: number }; afterEach(() => { cleanup(); }); function createBinding(): ToolcraftModelRenderBinding { let sequence = 0; return { create: vi.fn(() => ({ id: ++sequence })), dispose: vi.fn(), disposePreparedPreview: vi.fn(), hitTest: vi.fn(() => true), preparePreview: vi.fn(), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; } function createUploadSchema( controls: Record, ) { return defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [{ controls, title: "Source" }], title: "Controls", }, }, }); } const asset = { activeDocumentRef: "document:a", fileName: "cube.glb", id: "model-1", layerId: "layer-1", lifecycle: "clean" as const, sourceTarget: "source.model", }; describe("ToolcraftModelCanvasLayer", () => { it("renders through the target binding and preserves layer selection semantics", async () => { const binding = createBinding(); const dispatch = vi.fn(); const interactionHandlers = { onLostPointerCapture: vi.fn(), onPointerCancel: vi.fn(), onPointerDown: vi.fn(), onPointerMove: vi.fn(), onPointerUp: vi.fn(), }; const bytes = encodeToolcraftModelDocument(createValidModelDocument()); const resolveResource = vi.fn(async () => bytes); const bindings = { "source.model": binding }; const standardBinding = createBinding(); const view = render( , ); await waitFor(() => expect(binding.renderPreview).toHaveBeenCalledTimes(1)); await waitFor(() => expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-render-status"), ).toBe("ready"), ); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-phase"), ).toBe("analyzing"); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-target"), ).toBe("source.model"); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-toolcraft-model-orbit-surface"), ).toBe("true"); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-orientation"), ).toBe('{"position":[0,0,5],"up":[0,1,0]}'); expect(binding.create).toHaveBeenCalledWith( expect.objectContaining({ presentationOpacity: 0.4 }), expect.objectContaining({ acquirePresentation: expect.any(Function) }), ); fireEvent.click(screen.getByRole("button", { name: "Select cube.glb" })); expect(dispatch).toHaveBeenCalledWith({ layerId: "layer-1", type: "layers.select", }); view.rerender( , ); await waitFor(() => { expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-selected"), ).toBe("true"); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-phase"), ).toBe("clean"); expect( screen .getByRole("button", { name: "Select cube.glb" }) .getAttribute("data-canvas-model-orientation"), ).toBe('{"position":[5,0,0],"up":[0,1,0]}'); expect(binding.update).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ presentationOpacity: 1 }), expect.anything(), ); }); act(() => view.unmount()); expect(binding.dispose).toHaveBeenCalledTimes(1); }); it("never exposes stale render readiness while a changed request is pending", async () => { let resolveUpdate: (() => void) | undefined; const updatePending = new Promise((resolve) => { resolveUpdate = resolve; }); const binding = createBinding(); binding.update = vi.fn(() => updatePending); const bytes = encodeToolcraftModelDocument(createValidModelDocument()); const bindings = { "source.model": binding }; const resolveResource = async () => bytes; const standardBinding = createBinding(); const view = render( , ); const layer = screen.getByRole("button", { name: "Select cube.glb" }); await waitFor(() => expect(layer.getAttribute("data-canvas-model-render-status")).toBe( "ready", ), ); view.rerender( , ); expect(layer.getAttribute("data-canvas-model-render-status")).toBe( "pending", ); resolveUpdate?.(); await waitFor(() => expect(layer.getAttribute("data-canvas-model-render-status")).toBe( "ready", ), ); }); it("renders a first staged direct-canvas document before an asset is committed", async () => { const binding = createBinding(); const bytes = encodeToolcraftModelDocument(createValidModelDocument()); const operation = Object.freeze({ phase: "analyzing" as const, stagedDocumentRef: "document:staged", target: "canvas.direct", }); const schema = defineToolcraft({ canvas: { enabled: true }, panels: {}, }); const view = render( bytes} standardBinding={createBinding()} > operation, subscribe: () => () => {}, }} operationTargets={["canvas.direct"]} /> , ); await waitFor(() => expect(binding.renderPreview).toHaveBeenCalledTimes(1)); expect(binding.create).toHaveBeenCalledWith( expect.objectContaining({ canonicalDocumentRef: "document:staged", presentationOpacity: 0.4, }), expect.anything(), ); const modelLayer = document.querySelector( '[data-canvas-model-target="canvas.direct"]', ); expect(modelLayer?.getAttribute("role")).toBeNull(); expect(modelLayer?.getAttribute("data-canvas-model-phase")).toBe( "analyzing", ); await waitFor(() => expect(modelLayer?.getAttribute("data-canvas-model-render-status")).toBe( "ready", ), ); }); it("prewarms only configured model controls and ignores generic upload operation targets", async () => { const imageBinding = createBinding(); const modelBinding = createBinding(); const schema = createUploadSchema({ image: { assetKind: "image", target: "source.image", type: "fileDrop", }, model: { assetKind: "model", target: "source.model", type: "fileDrop", }, }); const view = render( null} standardBinding={createBinding()} > , ); const modelPrewarm = await waitFor(() => { const element = view.container.querySelector( '[data-canvas-model-prewarm-target="source.model"]', ); expect(element?.getAttribute("data-canvas-model-render-status")).toBe( "ready", ); return element; }); expect(modelPrewarm).not.toBeNull(); expect(modelPrewarm?.hasAttribute("data-toolcraft-model-orbit-surface")) .toBe(false); expect(modelBinding.preparePreview).toHaveBeenCalledTimes(1); expect(imageBinding.preparePreview).not.toHaveBeenCalled(); expect( view.container.querySelector( '[data-canvas-model-prewarm-target="source.image"]', ), ).toBeNull(); }); it("prepares an isolated renderer for every configured model target", async () => { const primaryBinding = createBinding(); const secondaryBinding = createBinding(); const schema = createUploadSchema({ primary: { assetKind: "model", target: "models.primary", type: "fileDrop", }, secondary: { assetKind: "model", target: "models.secondary", type: "fileDrop", }, }); const view = render( null} standardBinding={createBinding()} > , ); await waitFor(() => { expect( view.container.querySelectorAll( '[data-canvas-model-prewarm-target][data-canvas-model-render-status="ready"]', ), ).toHaveLength(2); }); expect(primaryBinding.preparePreview).toHaveBeenCalledTimes(1); expect(secondaryBinding.preparePreview).toHaveBeenCalledTimes(1); }); it("uses each model frame as the render and orbit surface in infinite mode", async () => { const binding = createBinding(); const bytes = encodeToolcraftModelDocument(createValidModelDocument()); const schema = defineToolcraft({ canvas: { enabled: true }, panels: {} }); const view = render( bytes} standardBinding={createBinding()} > , ); const layer = await waitFor(() => { const element = view.container.querySelector( '[data-canvas-model-layer="layer-1"]', ); expect(element).not.toBeNull(); return element as HTMLElement; }); expect(layer.style.left).toBe("-70px"); expect(layer.style.top).toBe("-140px"); expect(layer.style.width).toBe("300px"); expect(layer.style.height).toBe("200px"); await waitFor(() => expect(binding.create).toHaveBeenCalledWith( expect.objectContaining({ displayFit: expect.objectContaining({ viewport: { height: 200, width: 300 }, }), }), expect.anything(), ), ); }); });