import { expect, it, vi } from "vitest"; import { captureToolcraftDefaultResources } from "./default-resource-capture"; import { getToolcraftDefaultResourcePath } from "./default-resource-path"; import { withToolcraftDefaultResources } from "./default-resource-repository"; import { parseToolcraftDefaultResources } from "./default-resource-manifest"; import { createMemoryToolcraftBinaryAssetRepository } from "./repository/memory-binary-asset-repository"; import { createToolcraftMediaResourceRef } from "./media-resource-ref"; import { createHydrationModelAsset, createHydrationStateHarness, createHydrationWorkerClient } from "../model-import/model-persistence-hydration-test-support"; import { encodeToolcraftModelDocument } from "../model-import/canonical/model-document-codec"; import { digestToolcraftModelDocument } from "../model-import/canonical/model-document-digest"; import { createModelTexture, createValidModelDocumentV2 } from "../model-import/canonical/model-document-test-support"; import { createToolcraftModelAppearanceResourceRef } from "../model-import/canonical/model-appearance-resource"; import { createToolcraftModelDocumentResourceRef, TOOLCRAFT_MODEL_DOCUMENT_CONTENT_TYPE } from "../model-import/model-source-asset-handler-resources"; import { createToolcraftModelSourceBundleDescriptorResource } from "../model-import/model-source-bundle-codec"; import { createToolcraftModelSourceBundleSnapshot } from "../model-import/model-source-bundle"; import { TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY } from "../model-import/formats/production-model-format-registry"; import { hydrateToolcraftPersistedModels } from "../model-import/model-persistence-hydration"; import type { ToolcraftImageAsset } from "../state/types"; import { createToolcraftAppDefaults, applyToolcraftAppDefaults } from "../schema/app-defaults"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState, toolcraftReducer } from "../composition/public-state"; import { createToolcraftSourceAssetCoordinator } from "../composition/source-asset-coordinator"; import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; const bytes = new Uint8Array([1, 2, 3, 4]); const image: ToolcraftImageAsset = { assetKind: "image", id: "image", layerId: "image-layer", lifecycle: "ready", fileName: "image.png", mimeType: "image/png", resourceRef: createToolcraftMediaResourceRef("image", bytes), sourcePaths: ["pictures/image.png"], sourceTarget: "source.image", position: { x: 42, y: 17 }, size: { width: 100, height: 200, unit: "px" }, sourceSize: { width: 100, height: 200, unit: "px" }, transform: { rotationDeg: 90, flipHorizontal: true }, }; async function repository(entries: [string, Uint8Array, string[]][]) { const repo = createMemoryToolcraftBinaryAssetRepository(); const lease = await repo.beginLease("seed"); for (const [ref, data, dependencies] of entries) await lease.put(ref, data, { contentType: "application/octet-stream", dependencies, durable: true }); await lease.commit(); return repo; } it("packages model documents, source bundles and transitive textures exactly once", async () => { const model = createHydrationModelAsset("document", { sourceBundleRef: "bundle", lifecycle: "clean" }); const repo = await repository([["document", bytes, ["texture"]], ["bundle", bytes, ["source", "texture"]], ["source", bytes, []], ["texture", bytes, []]]); const release = vi.fn(); const retain = vi.fn(() => release); const uploads = await captureToolcraftDefaultResources([model], repo, retain); expect(uploads.map(item => item.resource.ref).sort()).toEqual(["bundle", "document", "source", "texture"]); expect(parseToolcraftDefaultResources(uploads.map(item => item.resource), [model])).toHaveLength(4); expect(release).toHaveBeenCalledTimes(2); await expect(captureToolcraftDefaultResources([model], await repository([]), retain)).rejects.toThrow("unavailable"); expect(release).toHaveBeenCalledTimes(4); await expect(captureToolcraftDefaultResources([{ ...model, lifecycle: "restoring" }], repo, retain)).rejects.toThrow("finish loading"); await repo.dispose(); }); it("restores packaged image bytes, bindings and transforms into an empty repository and full Reset", async () => { const schema = defineToolcraft({ base: { identity: { id: "full-media-defaults", title: "Media" }, canvas: { enabled: true }, panels: { controls: { title: "Settings", sections: [{ id: "source", controls: { image: { type: "fileDrop", label: "Image", target: "source.image", defaultValue: null, applicability: { mode: "always" } }, } }] } } }, modules: [mediaSourceModule()] }); const repo = await repository([[image.resourceRef, bytes, []]]); const uploads = await captureToolcraftDefaultResources([image], repo, () => () => {}); const current = createToolcraftState(schema, { mediaAssets: [image], selectedLayerId: null }); const saved = createToolcraftAppDefaults(current, uploads.map(item => item.resource)); expect(saved.resources[0]!.path).toBe(`toolcraft-defaults/images/${saved.resources[0]!.sha256}.bin`); const legacy = saved.resources.map(resource => ({ ...resource, path: `toolcraft-defaults/${resource.sha256}.bin` })); expect(parseToolcraftDefaultResources(legacy, saved.state.mediaAssets)).toEqual(legacy); const nextSchema = applyToolcraftAppDefaults(schema, saved); let state = createToolcraftState(nextSchema); const fetcher = vi.fn(async () => new Response(bytes)); const emptyRepo = withToolcraftDefaultResources(await repository([]), saved.resources, fetcher); const coordinator = createToolcraftSourceAssetCoordinator({ repository: emptyRepo, getState: () => state, dispatch: command => { state = toolcraftReducer(state, command); } }); await coordinator.hydrateBinaryMedia([]); expect(state.mediaAssets[0]).toMatchObject({ ...image, lifecycle: "ready" }); expect(await emptyRepo.get(image.resourceRef)).toMatchObject({ bytes }); state = toolcraftReducer(state, { type: "media.delete", mediaId: image.id }); state = toolcraftReducer(state, { type: "controls.reset" }); await coordinator.hydrateBinaryMedia([]); expect(state.mediaAssets[0]).toEqual(image); expect(state.selectedLayerId).toBeNull(); expect(fetcher).toHaveBeenCalledTimes(1); expect(fetcher.mock.calls[0]?.[0]).toBe(`/${saved.resources[0]!.path}`); state = toolcraftReducer(state, { type: "media.delete", mediaId: image.id }); state = toolcraftReducer(state, { type: "controls.resetTargets", targets: ["source.image"] }); await coordinator.hydrateBinaryMedia([]); expect(state.mediaAssets[0]).toEqual(image); await coordinator.dispose(); await repo.dispose(); }); it("rejects incomplete manifests, foreign paths and corrupted packaged bytes", async () => { const repo = await repository([[image.resourceRef, bytes, []]]); const uploads = await captureToolcraftDefaultResources([image], repo, () => () => {}); expect(() => parseToolcraftDefaultResources([], [image])).toThrow("missing"); expect(() => parseToolcraftDefaultResources([{ ...uploads[0]!.resource, path: "../escape" }], [image])).toThrow("Invalid"); const wrongDigest = "0".repeat(64); expect(() => parseToolcraftDefaultResources([{ ...uploads[0]!.resource, sha256: wrongDigest, path: `toolcraft-defaults/${wrongDigest}.bin` }], [image])).toThrow("identity"); const emptyRepo = withToolcraftDefaultResources(await repository([]), uploads.map(item => item.resource), async () => new Response("corrupt")); await expect(emptyRepo.get(image.resourceRef)).rejects.toThrow("integrity"); await emptyRepo.dispose(); await repo.dispose(); }); it("keeps uploads writable after restoring source defaults through a class-based repository", async () => { const original = await repository([[image.resourceRef, bytes, []]]); const defaults = await captureToolcraftDefaultResources([image], original, () => () => {}); const restored = withToolcraftDefaultResources(original, defaults.map(item => item.resource)); const replacementBytes = new Uint8Array([5, 6, 7]); const resourceRef = createToolcraftMediaResourceRef("image", replacementBytes); const lease = await restored.beginLease("replacement-image"); await lease.put(resourceRef, replacementBytes, { contentType: "image/png", durable: true }); await lease.commit(); const replacement = await captureToolcraftDefaultResources([{ ...image, resourceRef }], restored, () => () => {}); expect(replacement).toHaveLength(1); expect(replacement[0]!.bytes).toEqual(replacementBytes); await restored.collect(new Set([resourceRef])); expect(await restored.get(image.resourceRef)).not.toBeNull(); expect(await restored.get(resourceRef)).toMatchObject({ bytes: replacementBytes }); await restored.dispose(); }); it("hydrates a packaged canonical model and its authored texture in an empty repository", async () => { const contentDigest = image.resourceRef.slice("media:image:".length); const texture = createModelTexture({ contentDigest, resourceRef: createToolcraftModelAppearanceResourceRef(contentDigest) }); const document = createValidModelDocumentV2({ textures: [texture] }); const documentRef = createToolcraftModelDocumentResourceRef(digestToolcraftModelDocument(document)); const source = await createToolcraftModelSourceBundleSnapshot([new File([bytes], "cube.glb", { type: "model/gltf-binary" })], { registry: TOOLCRAFT_PRODUCTION_MODEL_FORMAT_ADAPTER_REGISTRY, }); const descriptor = createToolcraftModelSourceBundleDescriptorResource(source.bundle); const original = await repository([]); const lease = await original.beginLease("model"); for (const file of source.bundle.sourceFiles) { const transfer = source.transfer.sourceFiles.find(item => item.path === file.path)!; await lease.put(file.resourceRef, new Uint8Array(transfer.bytes), { contentType: file.mimeType, durable: true }); } await lease.put(descriptor.sourceBundleRef, descriptor.bytes, descriptor.stageOptions); await lease.put(texture.resourceRef, bytes, { contentType: texture.mimeType, durable: false }); await lease.put(documentRef, encodeToolcraftModelDocument(document), { contentType: TOOLCRAFT_MODEL_DOCUMENT_CONTENT_TYPE, dependencies: [texture.resourceRef], durable: false }); await lease.commit(); const model = createHydrationModelAsset(documentRef, { lifecycle: "clean", sourceBundleRef: descriptor.sourceBundleRef, sourceBundleDigest: source.bundle.aggregateDigest }); const uploads = await captureToolcraftDefaultResources([model], original, () => () => {}); const harness = createHydrationStateHarness(model); const defaults = createToolcraftAppDefaults(harness.getState(), uploads.map(item => item.resource)); let state = createToolcraftState(applyToolcraftAppDefaults(harness.getState().schema, defaults)); const fetcher = vi.fn(async url => { const upload = uploads.find(item => `/${item.resource.path}` === url)!; return new Response(new Uint8Array(upload.bytes)); }); const restored = withToolcraftDefaultResources(await repository([]), defaults.resources, fetcher); const workerClient = createHydrationWorkerClient(); await hydrateToolcraftPersistedModels({ getState: () => state, dispatch: command => { state = toolcraftReducer(state, command); }, repository: restored, workerClient }).promise; expect(state.mediaAssets[0]).toMatchObject({ lifecycle: "clean", activeDocumentRef: documentRef }); expect(await restored.get(texture.resourceRef)).toMatchObject({ bytes, contentType: texture.mimeType }); expect(workerClient.importModel).not.toHaveBeenCalled(); expect(defaults.resources).toHaveLength(4); expect(defaults.resources.map(resource => resource.path.split("/").slice(0, -1).join("/")).sort()).toEqual([ "toolcraft-defaults/models/bundles", "toolcraft-defaults/models/documents", "toolcraft-defaults/models/sources", "toolcraft-defaults/models/textures", ]); await restored.dispose(); await original.dispose(); }); it("uses a fixed type-specific folder for every canonical resource identity", () => { for (const [ref, folder] of [ ["media:image:sha256:abc", "images"], ["media:file:sha256:abc", "files"], ["toolcraft:model-source:abc", "models/sources"], ["toolcraft:model-source-bundle:abc", "models/bundles"], ["toolcraft:model-document:abc", "models/documents"], ["toolcraft:model-repair-plan:abc", "models/repairs"], ["toolcraft:model-appearance:abc", "models/textures"], ]) expect(getToolcraftDefaultResourcePath(ref!, "a".repeat(64))).toBe(`toolcraft-defaults/${folder}/${"a".repeat(64)}.bin`); });