import { describe, expect, it, vi } from "vitest"; import { createValidModelDocument } from "../../model-import/canonical/model-document-test-support"; import { createToolcraftModelPresentation, type ToolcraftModelRenderBinding, } from "./model-render-binding"; import { createToolcraftLazyThreeModelRenderBinding } from "./lazy-three-model-render-binding"; import { createToolcraftModelRenderHost } from "./model-render-host"; import { deferred, exportContext, exportRequest } from "./model-render-lifetime.test-support"; import { createToolcraftModelRenderRegistry } from "./model-render-registry"; describe("lazy Three model render binding", () => { it("does not duplicate a handled startup failure as an unhandled teardown rejection", async () => { const failure = new Error("renderer module loading failed"); const lazy = createToolcraftLazyThreeModelRenderBinding(async () => { throw failure; }); const host = createToolcraftModelRenderHost( createToolcraftModelRenderRegistry({ standardBinding: lazy }), vi.fn(async () => null), { clear: vi.fn(), report: vi.fn() }, ); const unhandled: unknown[] = []; const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; process.on("unhandledRejection", onUnhandled); try { const release = host.retain(); await expect(host.renderExport(exportRequest, exportContext())).rejects.toBe(failure); host.dispose(); release(); // An event-loop turn delivers detached promise rejections, not just microtasks. await new Promise((resolve) => setTimeout(resolve, 0)); expect(unhandled).toEqual([]); } finally { process.off("unhandledRejection", onUnhandled); } }); it("preserves disposal requested while the startup binding is still loading", async () => { const pending = deferred>(); const inner: ToolcraftModelRenderBinding = { create: vi.fn(), dispose: vi.fn(), disposePreparedPreview: vi.fn(), hitTest: vi.fn(() => false), preparePreview: vi.fn(), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; const lazy = createToolcraftLazyThreeModelRenderBinding(() => pending.promise); const preparation = lazy.preparePreview?.({ height: 200, host: document.createElement("div"), pixelRatio: 1, target: "source.model", width: 300, }); lazy.disposePreparedPreview?.(); pending.resolve(inner); await preparation; expect(inner.preparePreview).toHaveBeenCalledTimes(1); expect(inner.disposePreparedPreview).toHaveBeenCalledTimes(1); }); it("propagates loaded renderer cleanup failure synchronously through the retained host", async () => { const failure = new Error("prepared renderer cleanup failed"); const inner: ToolcraftModelRenderBinding = { create: vi.fn(() => ({})), dispose: vi.fn(), disposePreparedPreview: vi.fn(() => { throw failure; }), hitTest: vi.fn(() => false), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; const lazy = createToolcraftLazyThreeModelRenderBinding(async () => inner); const host = createToolcraftModelRenderHost( createToolcraftModelRenderRegistry({ standardBinding: lazy }), vi.fn(async () => null), { clear: vi.fn(), report: vi.fn() }, ); const release = host.retain(); await host.renderExport(exportRequest, exportContext()); host.dispose(); expect(inner.disposePreparedPreview).not.toHaveBeenCalled(); expect(release).toThrow(failure); expect(inner.disposePreparedPreview).toHaveBeenCalledTimes(1); expect(release).not.toThrow(); }); it("loads once on first resource creation and delegates the complete binding", async () => { const resource = { id: "resource" }; const inner: ToolcraftModelRenderBinding = { create: vi.fn(() => resource), dispose: vi.fn(), hitTest: vi.fn(() => true), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), }; const load = vi.fn(async () => inner as ToolcraftModelRenderBinding); const binding = createToolcraftLazyThreeModelRenderBinding(load); const canonicalDocument = createValidModelDocument(); const presentation = createToolcraftModelPresentation( { activeDocumentRef: "document-1", id: "model-1", layerId: "layer-1", lifecycle: "clean", }, { bounds: canonicalDocument.bounds, phase: "clean", purpose: "preview", target: "source.model", viewport: { height: 100, width: 100 }, }, ); const context = { acquirePresentation: vi.fn(), signal: new AbortController().signal, }; const first = await binding.create(presentation, context); const second = await binding.create(presentation, context); binding.renderPreview(first, { height: 100, host: document.createElement("div"), pixelRatio: 1, selected: false, width: 100, }); expect(binding.hitTest(first, { clientX: 1, clientY: 2 })).toBe(true); binding.dispose(first); expect(load).toHaveBeenCalledTimes(1); expect(inner.create).toHaveBeenCalledTimes(2); expect(inner.renderPreview).toHaveBeenCalledTimes(1); expect(inner.hitTest).toHaveBeenCalledTimes(1); expect(inner.dispose).toHaveBeenCalledWith(resource); binding.dispose(second); }); it("loads and delegates preview preparation before the first resource", async () => { const inner = { create: vi.fn(), dispose: vi.fn(), disposePreparedPreview: vi.fn(), hitTest: vi.fn(), preparePreview: vi.fn(), renderExport: vi.fn(), renderPreview: vi.fn(), update: vi.fn(), } satisfies ToolcraftModelRenderBinding; const load = vi.fn(async () => inner as ToolcraftModelRenderBinding); const binding = createToolcraftLazyThreeModelRenderBinding(load); await binding.preparePreview?.({ height: 720, host: document.createElement("div"), pixelRatio: 1, target: "source.model", width: 1280, }); binding.disposePreparedPreview?.(); await Promise.resolve(); expect(load).toHaveBeenCalledTimes(1); expect(inner.preparePreview).toHaveBeenCalledTimes(1); expect(inner.disposePreparedPreview).toHaveBeenCalledTimes(1); }); });