import { describe, expect, it, vi } from "vitest"; import { Mesh, MeshStandardMaterial } from "three"; import { createToolcraftModelAppearanceResourceRef } from "../../model-import/canonical/model-appearance-resource"; import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec"; import { digestToolcraftModelDocument } from "../../model-import/canonical/model-document-digest"; import type { ToolcraftModelDocument } from "../../model-import/canonical/model-document"; import { createModelTexture, createValidModelDocument, createValidModelDocumentV2, } from "../../model-import/canonical/model-document-test-support"; import { createToolcraftModelDocumentResourceRef, } from "../../model-import/model-source-asset-handler-resources"; import { digestModelSourceBytes } from "../../model-import/model-source-digest"; import type { ToolcraftModelResourceResolver } from "./model-render-binding"; import { createToolcraftModelPresentationResourceManager } from "./model-presentation-resource-manager"; function bitmap(): ImageBitmap & { close: ReturnType } { return { close: vi.fn(), height: 2, width: 2, } as unknown as ImageBitmap & { close: ReturnType }; } function documentResource(document: ToolcraftModelDocument) { const bytes = encodeToolcraftModelDocument(document); const ref = createToolcraftModelDocumentResourceRef( digestToolcraftModelDocument(document), ); return { bytes, ref }; } describe("Toolcraft model presentation resource manager", () => { it("deduplicates concurrent loads, clones roots, shares GPU resources, and disposes on last release", async () => { const textureBytes = new Uint8Array([137, 80, 78, 71]); const textureDigest = digestModelSourceBytes(textureBytes); const textureRef = createToolcraftModelAppearanceResourceRef(textureDigest); const document = createValidModelDocumentV2({ textures: [createModelTexture({ contentDigest: textureDigest, resourceRef: textureRef, })], }); const canonical = documentResource(document); const resolver: ToolcraftModelResourceResolver = vi.fn(async (ref) => ref === canonical.ref ? canonical.bytes : ref === textureRef ? textureBytes : null ); const decodedBitmap = bitmap(); const releaseReachability = vi.fn(); const retainResourceRef = vi.fn(() => releaseReachability); const manager = createToolcraftModelPresentationResourceManager({ createImageBitmap: vi.fn(async () => decodedBitmap), resolveResource: resolver, retainResourceRef, }); const controller = new AbortController(); const [first, second] = await Promise.all([ manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: controller.signal, }), manager.acquirePresentation(canonical.ref, { purpose: "export", signal: controller.signal, }), ]); const firstMesh = first.root.getObjectByName("primitive-1") as Mesh; const secondMesh = second.root.getObjectByName("primitive-1") as Mesh; const geometryDispose = vi.spyOn(firstMesh.geometry, "dispose"); const material = firstMesh.material as MeshStandardMaterial; const materialDispose = vi.spyOn(material, "dispose"); const textureDispose = vi.spyOn( material.map!, "dispose", ); expect(first.root).not.toBe(second.root); expect(firstMesh.geometry).toBe(secondMesh.geometry); expect(firstMesh.material).toBe(secondMesh.material); expect(firstMesh.geometry.getAttribute("uv")?.count).toBe(3); expect(firstMesh.geometry.getAttribute("color")?.itemSize).toBe(4); expect(resolver).toHaveBeenCalledTimes(2); expect(retainResourceRef).toHaveBeenCalledTimes(1); expect(manager.inspectResourceCounters()).toEqual({ acquired: 2, active: 2, cacheHits: 1, disposed: 0, released: 0, }); first.release(); first.release(); expect(manager.inspectResourceCounters()).toEqual({ acquired: 2, active: 1, cacheHits: 1, disposed: 0, released: 1, }); expect(geometryDispose).not.toHaveBeenCalled(); second.release(); expect(manager.inspectResourceCounters()).toEqual({ acquired: 2, active: 0, cacheHits: 1, disposed: 1, released: 2, }); expect(geometryDispose).toHaveBeenCalledTimes(1); expect(materialDispose).toHaveBeenCalledTimes(1); expect(textureDispose).toHaveBeenCalledTimes(1); expect(decodedBitmap.close).toHaveBeenCalledTimes(1); expect(releaseReachability).toHaveBeenCalledTimes(1); }); it("accounts for forced release and disposal during manager unmount", async () => { const canonical = documentResource(createValidModelDocument()); const manager = createToolcraftModelPresentationResourceManager({ createImageBitmap: vi.fn(), resolveResource: vi.fn(async () => canonical.bytes), }); const lease = await manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: new AbortController().signal, }); manager.dispose(); expect(manager.inspectResourceCounters()).toEqual({ acquired: 1, active: 0, cacheHits: 0, disposed: 1, released: 1, }); lease.release(); expect(manager.inspectResourceCounters().released).toBe(1); }); it("aborts around image decode, releases decoded bytes, and retries after failure", async () => { const textureBytes = new Uint8Array([137, 80, 78, 71]); const digest = digestModelSourceBytes(textureBytes); const textureRef = createToolcraftModelAppearanceResourceRef(digest); const document = createValidModelDocumentV2({ textures: [createModelTexture({ contentDigest: digest, resourceRef: textureRef })], }); const canonical = documentResource(document); const resolver: ToolcraftModelResourceResolver = vi.fn(async (ref) => ref === canonical.ref ? canonical.bytes : textureBytes ); let finishDecode: ((value: ImageBitmap) => void) | undefined; const decodedBitmap = bitmap(); const createImageBitmap = vi.fn(() => new Promise((resolve) => { finishDecode = resolve; })); const manager = createToolcraftModelPresentationResourceManager({ createImageBitmap, resolveResource: resolver, }); const controller = new AbortController(); const pending = manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: controller.signal, }); await vi.waitFor(() => expect(finishDecode).toBeDefined()); controller.abort(); finishDecode?.(decodedBitmap); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); expect(decodedBitmap.close).toHaveBeenCalledTimes(1); const retryBitmap = bitmap(); createImageBitmap.mockResolvedValueOnce(retryBitmap); const retry = await manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: new AbortController().signal, }); retry.release(); expect(createImageBitmap).toHaveBeenCalledTimes(2); expect(retryBitmap.close).toHaveBeenCalledTimes(1); }); it("rejects pre-aborted acquisition without resolving and does not cache failures", async () => { const canonical = documentResource(createValidModelDocument()); const resolver = vi.fn() .mockResolvedValueOnce(null) .mockResolvedValueOnce(canonical.bytes); const manager = createToolcraftModelPresentationResourceManager({ createImageBitmap: vi.fn(), resolveResource: resolver, }); const aborted = new AbortController(); aborted.abort(); await expect(manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: aborted.signal, })).rejects.toMatchObject({ name: "AbortError" }); expect(resolver).not.toHaveBeenCalled(); await expect(manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: new AbortController().signal, })).rejects.toThrow(/unavailable/u); const lease = await manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: new AbortController().signal, }); lease.release(); expect(resolver).toHaveBeenCalledTimes(2); }); it("keeps geometry available when one presentation texture is missing", async () => { const document = createValidModelDocumentV2(); const canonical = documentResource(document); const resolver: ToolcraftModelResourceResolver = vi.fn(async (ref) => ref === canonical.ref ? canonical.bytes : null ); const onAppearanceResourceFailure = vi.fn(); const manager = createToolcraftModelPresentationResourceManager({ createImageBitmap: vi.fn(), onAppearanceResourceFailure, resolveResource: resolver, }); const lease = await manager.acquirePresentation(canonical.ref, { purpose: "preview", signal: new AbortController().signal, }); const mesh = lease.root.getObjectByName("primitive-1") as Mesh; const material = mesh.material as MeshStandardMaterial; expect(material.map).toBeNull(); expect(material.color.toArray()).toEqual([0.8, 0.7, 0.6]); expect(onAppearanceResourceFailure).toHaveBeenCalledWith({ code: "appearance-resource-missing", resourceRef: document.textures[0]!.resourceRef, textureId: document.textures[0]!.id, }); lease.release(); }); });