import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { createToolcraftDefaultModelPlaceholder } from "../model-import/default-model-source-assets"; import { model3dModule } from "../modules/built-ins/model-3d/model-3d-module"; import { defineToolcraft } from "../schema/define-toolcraft"; import type { ToolcraftImageAsset, ToolcraftModelAsset } from "./types"; import { createToolcraftState } from "./create-template-state"; import { createToolcraftPersistenceSnapshot } from "../composition/public-persistence"; import { readMediaAssets } from "./persistence-reader-media"; import { parseToolcraftPersistenceSnapshotResult } from "../composition/public-persistence"; import { createToolcraftPersistenceController } from "../composition/public-persistence"; function persistentMediaSchema() { return defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, upload: true }, panels: { controls: { sections: [ { id: "test-section-1", controls: { source: { applicability: { mode: "always" as const }, assetKind: "model", defaultValue: null, target: "source.media", type: "fileDrop", }, }, }, ], title: "Controls", }, }, persistence: { additionalValueTargets: ["audit.selection"], storage: "localStorage", }, }, modules: [model3dModule()], }); } function modelAsset(): ToolcraftModelAsset { return { activeDocumentRef: "model-document:active", analysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 12, vertices: 8, }, assetKind: "model", fileName: "cube.glb", id: "model-1", layerId: "model-layer-1", lifecycle: "clean", mimeType: "model/gltf-binary", originalAnalysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 12, vertices: 8, }, originalDocumentRef: "model-document:active", position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: "sha256:bundle", sourceBundleRef: "model-bundle:source", sourceTarget: "source.media", topologyProfile: "realtime-mesh", }; } function durableModelAsset( lifecycle: "clean" | "repairable" | "fixed", ): ToolcraftModelAsset { const asset = modelAsset(); if (lifecycle === "clean") return asset; const repairableAnalysis = { ...asset.analysis, boundaryEdges: 2, outcome: "repairable" as const, repairPlanRef: "model-repair-plan:original", }; return lifecycle === "repairable" ? { ...asset, analysis: repairableAnalysis, originalAnalysis: repairableAnalysis, lifecycle, } : { ...asset, activeDocumentRef: "model-document:repaired", appliedRepairRecipeId: "model-repair-recipe:original", lifecycle, originalAnalysis: repairableAnalysis, repairedDocumentRef: "model-document:repaired", }; } describe("media discriminated union", () => { it("requires complete scene and source geometry in canonical image state", () => { type ImageWithoutSceneSize = Omit; type ImageWithoutSourceSize = Omit; expectTypeOf().not.toMatchTypeOf(); expectTypeOf().not.toMatchTypeOf(); }); it("keeps default model source bytes out of synchronous live state", () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, upload: true }, media: { defaultAssets: [ { assetKind: "model", fileName: "default.glb", sourceFiles: [ { dataUrl: "data:model/gltf-binary;base64,AAAA", path: "default.glb", }, ], sourceTarget: "source.model", }, ], }, panels: { controls: { sections: [ { id: "test-section-2", controls: { model: { applicability: { mode: "always" as const }, assetKind: "model", defaultValue: null, target: "source.model", type: "fileDrop", }, }, title: "Model", }, ], title: "Controls", }, }, }, modules: [model3dModule()], }); expect(schema.media.defaultAssets).toHaveLength(1); const [placeholder] = createToolcraftState(schema).mediaAssets; expect(placeholder).toMatchObject({ assetKind: "model", fileName: "default.glb", lifecycle: "restoring", sourceTarget: "source.model", }); expect(placeholder).not.toHaveProperty("dataUrl"); expect(placeholder).not.toHaveProperty("sourceFiles"); }); it("rejects persisted media without an explicit current assetKind", () => { expect( readMediaAssets([ { dataUrl: "data:image/png;base64,AAAA", fileName: "legacy.png", id: "legacy-image", layerId: "legacy-layer", mimeType: "image/png", position: { x: 0, y: 0 }, }, ]), ).toBeUndefined(); }); it("reads a model record without requiring image-only fields", () => { expect(readMediaAssets([modelAsset()])).toEqual([ { ...modelAsset(), lifecycle: "restoring" }, ]); }); for (const durableLifecycle of ["clean", "repairable", "fixed"] as const) { it.each(["restoring", "unavailable"] as const)( `roundtrips durable ${durableLifecycle} provenance while the live model is %s`, (lifecycle) => { const schema = persistentMediaSchema(); const durable = { ...durableModelAsset(durableLifecycle), position: { x: -120, y: 75 }, size: { width: 320, height: 240, unit: "px" as const }, }; const transient: ToolcraftModelAsset = { ...durable, lifecycle, ...(lifecycle === "unavailable" ? { lastRepairError: { category: "resource-unavailable" as const, code: "model-resource-unavailable", message: "Resource storage is temporarily unavailable.", }, } : {}), }; const state = createToolcraftState(schema, { mediaAssets: [transient], values: { "audit.selection": "keep-me" }, }); const snapshot = createToolcraftPersistenceSnapshot( state, schema.persistence, ); expect(snapshot?.state.mediaAssets).toEqual([durable]); const parsed = parseToolcraftPersistenceSnapshotResult( schema, JSON.stringify(snapshot), ); expect(parsed).toMatchObject({ kind: "current", state: { canvas: state.canvas, mediaAssets: [{ ...durable, lifecycle: "restoring" }], panels: state.panels, values: { "audit.selection": "keep-me" }, }, }); expect(state.mediaAssets).toEqual([transient]); }, ); } it("preserves durable repair feedback while restoring a repairable model", () => { const schema = persistentMediaSchema(); const durable = { ...durableModelAsset("repairable"), lastRepairError: { category: "repair" as const, code: "repair-failed", message: "Repair failed.", }, }; const state = createToolcraftState(schema, { mediaAssets: [{ ...durable, lifecycle: "restoring" }], }); const snapshot = createToolcraftPersistenceSnapshot( state, schema.persistence, ); expect(snapshot?.state.mediaAssets).toEqual([durable]); expect( parseToolcraftPersistenceSnapshotResult(schema, JSON.stringify(snapshot)), ).toMatchObject({ kind: "current", state: { mediaAssets: [{ ...durable, lifecycle: "restoring" }] }, }); }); it("retains the saved workspace until transient models have valid durable provenance", () => { const schema = persistentMediaSchema(); let state = createToolcraftState(schema, { mediaAssets: [modelAsset()] }); let saved: string | null = null; const setItem = vi.fn((_key: string, value: string) => { saved = value; }); const controller = createToolcraftPersistenceController({ getCommittedState: () => state, schema, storage: { getItem: () => saved, setItem }, }); expect(controller.flush()).toEqual({ status: "success" }); const previous = saved; const placeholder = createToolcraftDefaultModelPlaceholder({ asset: { assetKind: "model", fileName: "default.glb", sourceFiles: [ { path: "default.glb", dataUrl: "data:model/gltf-binary;base64,AAAA", }, ], sourceTarget: "source.media", }, index: 0, topologyProfile: "realtime-mesh", }); const contradictory = { ...durableModelAsset("fixed"), activeDocumentRef: "model-document:unrelated", }; for (const model of [placeholder, contradictory]) { for (const lifecycle of ["restoring", "unavailable"] as const) { state = createToolcraftState(schema, { mediaAssets: [{ ...model, lifecycle }], }); controller.schedule(); expect(controller.flush()).toEqual({ reason: "unavailable", status: "failed", }); expect(saved).toBe(previous); expect(setItem).toHaveBeenCalledTimes(1); expect( parseToolcraftPersistenceSnapshotResult(schema, saved).kind, ).toBe("current"); } } state = createToolcraftState(schema, { mediaAssets: [durableModelAsset("fixed")], }); controller.schedule(); expect(controller.flush()).toEqual({ status: "success" }); expect(setItem).toHaveBeenCalledTimes(2); expect( parseToolcraftPersistenceSnapshotResult(schema, saved), ).toMatchObject({ kind: "current", state: { mediaAssets: [ { ...durableModelAsset("fixed"), lifecycle: "restoring" }, ], }, }); controller.dispose(); }); it("serializes only compact model metadata and resource refs", () => { const schema = persistentMediaSchema(); const unsafeModel = { ...modelAsset(), analysis: { ...modelAsset().analysis, diagnostics: [ { affectedCount: 1, binaryPayload: new Uint8Array([1, 2, 3]), code: "unsafe", explanation: "Unsafe extra data", severity: "warning", }, ], }, dataUrl: "data:model/gltf-binary;base64,AAAA", indices: new Uint32Array([0, 1, 2]), lastRepairError: { binaryPayload: new Uint8Array([4, 5, 6]), category: "repair", code: "unsafe-error", message: "Unsafe error payload", }, originalAnalysis: { ...modelAsset().originalAnalysis, diagnostics: [ { affectedCount: 1, code: "unsafe-original", explanation: "Unsafe original data", rawPath: ["source", "mesh"], severity: "warning", }, ], }, position: { x: 1, y: 2 }, positions: new Float32Array([0, 0, 0]), } as unknown as ToolcraftModelAsset; const state = createToolcraftState(schema, { mediaAssets: [unsafeModel], }); const serialized = JSON.stringify( createToolcraftPersistenceSnapshot(state, schema.persistence), ); expect(serialized).toContain('"assetKind":"model"'); expect(serialized).toContain('"activeDocumentRef":"model-document:active"'); expect(serialized).toContain('"position":{"x":1,"y":2}'); expect(serialized).not.toMatch( /binaryPayload|dataUrl|indices|positions|rawPath/u, ); }); });