import { act, render, waitFor } from "@testing-library/react"; import * as React from "react"; import { describe, expect, it, vi } from "vitest"; import { Mesh, MeshStandardMaterial, type BufferGeometry } from "three"; import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec"; import { createValidModelDocument } from "../../model-import/canonical/model-document-test-support"; import type { ToolcraftSourceAssetFeedback } from "../../source-assets/source-asset-types"; import type { ToolcraftModelPresentationLease, ToolcraftModelRenderBinding, ToolcraftModelRenderHost, ToolcraftModelResourceResolver, } from "./model-render-binding"; import { ToolcraftModelRenderProvider, useToolcraftModelRenderHost, useToolcraftModelRenderPreparationStatus, } from "./model-render-provider"; type TestResource = { id: number }; function createBinding() { let sequence = 0; const binding: ToolcraftModelRenderBinding = { create: vi.fn(() => ({ id: ++sequence })), dispose: vi.fn(), hitTest: vi.fn((_resource, point) => point.clientX < 100), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; return binding; } const asset = { activeDocumentRef: "document:a", id: "model-1", layerId: "layer-1", lifecycle: "clean" as const, sourceTarget: "source.model", }; const previewContext = { height: 200, host: document.createElement("div"), pixelRatio: 1, selected: false, width: 300, }; function HostProbe({ onHost, }: { onHost: (host: ToolcraftModelRenderHost) => void; }): null { const host = useToolcraftModelRenderHost(); React.useEffect(() => onHost(host), [host, onHost]); return null; } function PreparationProbe(): React.JSX.Element { const status = useToolcraftModelRenderPreparationStatus(); return {status}; } function createResolver(): ToolcraftModelResourceResolver { const bytes = encodeToolcraftModelDocument(createValidModelDocument()); return vi.fn(async () => bytes); } async function renderHost( binding = createBinding(), feedback: Readonly<{ clear?: (sourceTarget: string) => void; report?: ( sourceTarget: string, feedback: ToolcraftSourceAssetFeedback, ) => void; }> = {}, ) { let host: ToolcraftModelRenderHost | undefined; const view = render( { host = value; }} /> , ); await waitFor(() => expect(host).toBeDefined()); return { binding, host: host!, ...view }; } describe("ToolcraftModelRenderProvider", () => { it("prepares the standard renderer once and publishes observable readiness", async () => { let finishPreparation: (() => void) | undefined; const standardBinding = createBinding(); standardBinding.preparePreview = vi.fn( () => new Promise((resolve) => { finishPreparation = resolve; }), ); standardBinding.disposePreparedPreview = vi.fn(); let host: ToolcraftModelRenderHost | undefined; const view = render( { host = value; }} /> , ); await waitFor(() => expect(host).toBeDefined()); void host!.prepare({ height: 720, host: document.createElement("div"), pixelRatio: 1, target: "source.model", width: 1280, }); await waitFor(() => { expect(view.getByTestId("model-render-preparation").textContent).toBe( "preparing", ); expect(finishPreparation).toBeDefined(); }); act(() => finishPreparation?.()); await waitFor(() => { expect(view.getByTestId("model-render-preparation").textContent).toBe( "ready", ); }); expect(standardBinding.preparePreview).toHaveBeenCalledTimes(1); view.unmount(); await waitFor(() => { expect(standardBinding.disposePreparedPreview).toHaveBeenCalledTimes(1); }); }); it("keeps the host active across the StrictMode effect replay", async () => { const binding = createBinding(); let host: ToolcraftModelRenderHost | undefined; const view = render( { host = value; }} /> , ); await waitFor(() => expect(host).toBeDefined()); await Promise.resolve(); await act(async () => { await host!.renderPreview( "strict-layer", { asset, phase: "clean", target: "source.model" }, previewContext, ); }); expect( host!.hitTest("strict-layer", { clientX: 50, clientY: 40 }), ).toBe(true); view.unmount(); await waitFor(() => expect(binding.dispose).toHaveBeenCalledTimes(1)); }); it("creates, updates, renders, and hit-tests a target resource", async () => { const { binding, host } = await renderHost(); await act(async () => { await host.renderPreview( "layer-1", { asset, phase: "clean", target: "source.model", viewport: { height: 200, width: 300 }, }, previewContext, ); await host.renderPreview( "layer-1", { asset, phase: "repairing", target: "source.model", viewport: { height: 200, width: 300 }, }, previewContext, ); }); expect(binding.create).toHaveBeenCalledTimes(1); expect(binding.update).toHaveBeenCalledTimes(1); expect(binding.renderPreview).toHaveBeenCalledTimes(2); expect(host.hitTest("layer-1", { clientX: 50, clientY: 40 })).toBe(true); expect(host.hitTest("layer-1", { clientX: 150, clientY: 40 })).toBe(false); }); it("uses a separate full-opacity export resource", async () => { const { binding, host } = await renderHost(); await act(async () => { await host.renderPreview( "layer-1", { asset, phase: "repairing", target: "source.model", viewport: { height: 200, width: 300 }, }, previewContext, ); await host.renderExport( { asset, phase: "repairing", target: "source.model", viewport: { height: 1080, width: 1920 }, }, { height: 1080, pixelRatio: 1, width: 1920 }, ); }); expect(binding.create).toHaveBeenCalledTimes(2); expect(binding.renderExport).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ height: 1080, width: 1920 }), ); expect(binding.create).toHaveBeenLastCalledWith( expect.objectContaining({ presentationOpacity: 1, purpose: "export", }), expect.anything(), ); expect(binding.dispose).toHaveBeenCalledTimes(1); }); it("shares the canonical presentation cache across preview and export leases", async () => { const resources: ToolcraftModelPresentationLease[] = []; const geometries: BufferGeometry[] = []; const binding: ToolcraftModelRenderBinding< ToolcraftModelPresentationLease > = { create: vi.fn(async (presentation, context) => { const lease = await context.acquirePresentation( presentation.canonicalDocumentRef, { purpose: presentation.purpose, signal: context.signal }, ); resources.push(lease); geometries.push( (lease.root.getObjectByName("primitive-1") as Mesh).geometry, ); return lease; }), dispose: vi.fn((lease) => lease.release()), hitTest: vi.fn(() => false), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; const resolveResource = createResolver(); let host: ToolcraftModelRenderHost | undefined; const view = render( { host = value; }} /> , ); await waitFor(() => expect(host).toBeDefined()); await act(async () => { await host!.renderPreview( "layer-1", { asset, phase: "clean", target: "source.model" }, previewContext, ); await host!.renderExport( { asset, phase: "clean", target: "source.model" }, { height: 1080, pixelRatio: 1, width: 1920 }, ); }); expect(binding.create).toHaveBeenNthCalledWith( 1, expect.objectContaining({ canonicalDocumentRef: "document:a" }), expect.anything(), ); expect(binding.create).toHaveBeenNthCalledWith( 2, expect.objectContaining({ canonicalDocumentRef: "document:a" }), expect.anything(), ); expect(resolveResource).toHaveBeenCalledTimes(1); expect(resources).toHaveLength(2); expect(resources[0]!.root).not.toBe(resources[1]!.root); expect(geometries[0]).toBe(geometries[1]); expect(binding.dispose).toHaveBeenCalledTimes(1); const geometryDispose = vi.spyOn(geometries[0]!, "dispose"); view.unmount(); await waitFor(() => { expect(binding.dispose).toHaveBeenCalledTimes(2); expect(geometryDispose).toHaveBeenCalledTimes(1); }); }); it("reuses resources across canonical document replacement and disposes on reset and unmount", async () => { const { binding, host, unmount } = await renderHost(); const renderDocument = async (canonicalDocumentRef: string) => { await host.renderPreview( "layer-1", { asset, canonicalDocumentRef, phase: "clean", target: "source.model", viewport: { height: 200, width: 300 }, }, previewContext, ); }; await act(async () => renderDocument("document:a")); await act(async () => renderDocument("document:b")); expect(binding.create).toHaveBeenCalledTimes(1); expect(binding.update).toHaveBeenCalledTimes(1); expect(binding.update).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ canonicalDocumentRef: "document:b" }), expect.anything(), ); expect(binding.dispose).not.toHaveBeenCalled(); act(() => host.release("layer-1")); expect(binding.dispose).toHaveBeenCalledTimes(1); await act(async () => renderDocument("document:c")); unmount(); await waitFor(() => expect(binding.dispose).toHaveBeenCalledTimes(2)); }); it("disposes a newly created resource when preview rendering fails", async () => { const binding = createBinding(); vi.mocked(binding.renderPreview).mockImplementation(() => { throw new Error("preview failed"); }); const reportPresentationFeedback = vi.fn(); const { host } = await renderHost(binding, { report: reportPresentationFeedback, }); await expect( host.renderPreview( "layer-1", { asset, phase: "clean", target: "source.model" }, previewContext, ), ).rejects.toThrow("preview failed"); expect(binding.dispose).toHaveBeenCalledTimes(1); expect(reportPresentationFeedback).toHaveBeenCalledWith( "source.model", expect.objectContaining({ code: "model-presentation-unavailable" }), ); expect(host.hitTest("layer-1", { clientX: 50, clientY: 40 })).toBe(false); }); it("keeps resources isolated across provider instances", async () => { const firstBinding = createBinding(); const secondBinding = createBinding(); let firstHost: ToolcraftModelRenderHost | undefined; let secondHost: ToolcraftModelRenderHost | undefined; const view = render( <> { firstHost = value; }} /> { secondHost = value; }} /> , ); await waitFor(() => { expect(firstHost).toBeDefined(); expect(secondHost).toBeDefined(); }); await act(async () => { await Promise.all([ firstHost!.renderPreview( "shared-key", { asset, phase: "clean", target: "source.model" }, previewContext, ), secondHost!.renderPreview( "shared-key", { asset, phase: "clean", target: "source.model" }, previewContext, ), ]); }); act(() => firstHost!.release("shared-key")); expect(firstBinding.dispose).toHaveBeenCalledTimes(1); expect(secondBinding.dispose).not.toHaveBeenCalled(); view.unmount(); await waitFor(() => expect(secondBinding.dispose).toHaveBeenCalledTimes(1)); }); it("disposes active shared presentation resources with the provider", async () => { const { host, unmount } = await renderHost(); const lease = await host.acquirePresentation("document:a", { purpose: "preview", signal: new AbortController().signal, }); const mesh = lease.root.getObjectByName("primitive-1") as Mesh; const geometryDispose = vi.spyOn(mesh.geometry, "dispose"); const materialDispose = vi.spyOn( mesh.material as MeshStandardMaterial, "dispose", ); unmount(); await waitFor(() => { expect(geometryDispose).toHaveBeenCalledTimes(1); expect(materialDispose).toHaveBeenCalledTimes(1); }); expect(lease.root.children).toHaveLength(0); lease.release(); expect(geometryDispose).toHaveBeenCalledTimes(1); }); });