import { describe, expect, it, vi } from "vitest"; import { createToolcraftModelSourceAssetHandler } from "./model-source-asset-handler"; import { TOOLCRAFT_MODEL_SOURCE_BUNDLE_DESCRIPTOR_CONTENT_TYPE, decodeToolcraftModelSourceBundleDescriptor, } from "./model-source-bundle-codec"; import { createModelSourceResourceRef, digestModelSourceBytes, } from "./model-source-digest"; import type { ToolcraftModelWorkerClientObservers } from "./worker/model-import-worker-client"; import { encoder, file, glbFile, modelControl, prepare, SHA_A, workerClient, } from "./model-source-asset-handler.test-support"; describe("Toolcraft model source ingestion", () => { it("places infinite-canvas models at the drop center with the shared default size", async () => { const { prepared } = await prepare("clean", [glbFile()], { canvasFrame: { kind: "infinite" }, position: { x: 120, y: -40 }, }); expect(prepared.assets[0]).toMatchObject({ assetKind: "model", position: { x: 120, y: -40 }, size: { height: 512, unit: "px", width: 512 }, }); }); it("claims only production formats allowed by the model control", () => { const handler = createToolcraftModelSourceAssetHandler({ workerClient: workerClient("clean"), }); const glbBatch = { files: [glbFile()], origin: "panel" as const, }; const objBatch = { files: [ file( "mesh.obj", encoder.encode("v 0 0 0"), "model/obj", ), ], origin: "panel" as const, }; expect(handler.match(glbBatch, modelControl(["glb"]))).toMatchObject({ handlerKind: "model", rootFileNames: ["scene.glb"], }); expect(handler.match(glbBatch, modelControl(["gltf"]))).toBeNull(); expect(handler.match(objBatch, modelControl(["obj"]))).toMatchObject({ handlerKind: "model", rootFileNames: ["mesh.obj"], }); }); it("extracts a single ZIP through the worker and persists only its archive", async () => { const worker = workerClient("clean"); const rootBytes = new Uint8Array([ 0x67, 0x6c, 0x54, 0x46, ]); worker.extractModelPackage.mockImplementation( async ( request: { archiveDigest: string; jobId: string }, observers: ToolcraftModelWorkerClientObservers = {}, ) => { observers.onProgress?.({ generation: 1, jobId: request.jobId, kind: "progress", phase: "extracting", progress: 0.5, }); return { generation: 1, jobId: request.jobId, kind: "package-result" as const, result: { archiveDigest: request.archiveDigest, entries: [ { bytes: rootBytes.slice().buffer, compressedBytes: rootBytes.byteLength, contentDigest: digestModelSourceBytes(rootBytes), mimeType: "model/gltf-binary", path: "models/scene.glb", uncompressedBytes: rootBytes.byteLength, }, ], operation: "extract-model-package" as const, receiptDigest: SHA_A, }, }; }, ); const handler = createToolcraftModelSourceAssetHandler({ workerClient: worker, }); const control = modelControl(); const archive = file( "package.zip", new Uint8Array([1, 2, 3, 4, 5]), "application/zip", ); const batch = { files: [archive], origin: "panel" as const, }; const claim = handler.match(batch, control)!; const stageResource = vi.fn( async ( _ref: string, _bytes: Uint8Array, _options: unknown, ) => undefined, ); const reportOperation = vi.fn(); const prepared = await handler.prepare({ batch, canvasFrame: { kind: "finite", size: { height: 512, unit: "px", width: 512 }, }, claim, control, jobId: "zip-model", plan: handler.plan(batch, control, claim), reportOperation, signal: new AbortController().signal, stageResource, }); expect(worker.extractModelPackage).toHaveBeenCalledTimes(1); expect(reportOperation).toHaveBeenCalledWith({ phase: "extracting", progress: 0, }); expect(reportOperation).toHaveBeenCalledWith({ phase: "extracting", progress: 0.5, }); const archiveDigest = digestModelSourceBytes( new Uint8Array(await archive.arrayBuffer()), ); const archiveRef = createModelSourceResourceRef( archiveDigest, "application/zip", ); expect(stageResource).toHaveBeenCalledWith( archiveRef, expect.any(Uint8Array), { contentType: "application/zip", durable: true, }, ); expect(stageResource).toHaveBeenCalledWith( expect.stringMatching(/^toolcraft:model-source:/u), expect.any(Uint8Array), { contentType: "model/gltf-binary", durable: true }, ); const descriptorCall = stageResource.mock.calls.find(([ref]) => String(ref).startsWith("toolcraft:model-source-bundle:"), ); expect(descriptorCall?.[2]).toEqual({ contentType: TOOLCRAFT_MODEL_SOURCE_BUNDLE_DESCRIPTOR_CONTENT_TYPE, dependencies: [archiveRef], durable: true, }); const descriptor = decodeToolcraftModelSourceBundleDescriptor( descriptorCall?.[1] as Uint8Array, ); expect(descriptor.packageSource).toMatchObject({ archive: { resourceRef: archiveRef }, kind: "zip", }); expect(prepared.assets[0]).toMatchObject({ fileName: "scene.glb", }); }); it("rejects ZIP mixed with selected files before worker extraction", async () => { const worker = workerClient("clean"); const handler = createToolcraftModelSourceAssetHandler({ workerClient: worker, }); const control = modelControl(); const batch = { files: [ file( "package.zip", new Uint8Array([1]), "application/zip", ), glbFile(), ], origin: "panel" as const, }; const claim = handler.match(batch, control)!; await expect( handler.prepare({ batch, canvasFrame: { kind: "finite", size: { height: 512, unit: "px", width: 512 }, }, claim, control, jobId: "mixed-zip-model", plan: handler.plan(batch, control, claim), reportOperation: vi.fn(), signal: new AbortController().signal, stageResource: vi.fn(async () => undefined), }), ).rejects.toMatchObject({ code: "archive-mixed-source-batch", }); expect(worker.extractModelPackage).not.toHaveBeenCalled(); }); });