import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import type { ToolcraftModelAsset } from "./types"; import { createToolcraftState } from "./create-template-state"; import { toolcraftReducer } from "./reducer"; function repairableAnalysis(): ToolcraftModelAsset["analysis"] { return { boundaryEdges: 2, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "repairable", repairPlanRef: "repair-plan:one", triangles: 12, vertices: 8, }; } function modelAsset( overrides: Partial = {}, ): ToolcraftModelAsset { return { activeDocumentRef: "document:original", analysis: repairableAnalysis(), assetKind: "model", fileName: "cube.glb", id: "model-1", layerId: "layer-1", lifecycle: "repairable", mimeType: "model/gltf-binary", originalAnalysis: repairableAnalysis(), originalDocumentRef: "document:original", position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: "sha256:source", sourceBundleRef: "bundle:source", topologyProfile: "realtime-mesh", ...overrides, }; } function modelState(asset = modelAsset()) { const schema = defineToolcraft({ canvas: { enabled: true }, panels: {} }); return createToolcraftState(schema, { mediaAssets: [asset] }); } function modelUploadState() { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [{ controls: { model: { assetKind: "model", modelFormats: ["glb"], target: "source.model", type: "fileDrop", }, }, title: "Model", }], title: "Controls", }, }, }); return createToolcraftState(schema); } describe("model media lifecycle commands", () => { it("commits a successful repair through one undoable state patch", () => { const state = modelState(); const repaired = toolcraftReducer(state, { activeDocumentRef: "document:repaired", analysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 10, vertices: 8, }, appliedRepairRecipeId: "recipe:one", assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedRepairPlanRef: "repair-plan:one", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const repairedAsset = repaired.mediaAssets[0]; const undone = toolcraftReducer(repaired, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(repairedAsset).toMatchObject({ activeDocumentRef: "document:repaired", appliedRepairRecipeId: "recipe:one", assetKind: "model", lifecycle: "fixed", originalAnalysis: repairableAnalysis(), repairedDocumentRef: "document:repaired", }); expect(repaired.history.undo).toHaveLength(1); expect(undone.mediaAssets).toEqual(state.mediaAssets); expect(redone.mediaAssets).toEqual(repaired.mediaAssets); }); it("resets a repaired model through undo and redo", () => { const { id: _id, layerId: _layerId, ...draft } = modelAsset({ sourceTarget: "source.model", }); const imported = toolcraftReducer(modelUploadState(), { assets: [draft], replaceExisting: true, type: "media.importBatch", }); const importedAsset = imported.mediaAssets[0]; if (!importedAsset || importedAsset.assetKind !== "model") { throw new Error("Expected imported model"); } const repaired = toolcraftReducer(imported, { activeDocumentRef: "document:repaired", analysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 10, vertices: 8, }, appliedRepairRecipeId: "recipe:one", assetId: importedAsset.id, expectedActiveDocumentRef: "document:original", expectedRepairPlanRef: "repair-plan:one", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const reset = toolcraftReducer(repaired, { type: "controls.reset" }); const undone = toolcraftReducer(reset, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(repaired.mediaAssets[0]).toMatchObject({ lifecycle: "fixed" }); expect(reset.mediaAssets).toEqual([]); expect(undone.mediaAssets).toEqual(repaired.mediaAssets); expect(redone.mediaAssets).toEqual([]); }); it("stores repair failures without adding history", () => { const state = modelState(); const failed = toolcraftReducer(state, { assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedSourceBundleDigest: "sha256:source", feedback: { category: "repair", code: "repair-failed", message: "The model could not be repaired.", }, type: "media.setModelRepairError", }); expect(failed.history).toEqual(state.history); expect(failed.mediaAssets[0]).toMatchObject({ lastRepairError: { code: "repair-failed" }, }); }); it("hydrates only restoring models without replacing their identity", () => { const state = modelState(modelAsset({ lifecycle: "restoring" })); const hydrated = toolcraftReducer(state, { asset: modelAsset({ activeDocumentRef: "document:restored", fileName: "hostile-name.glb", id: "model-1", layerId: "hostile-layer", lifecycle: "clean", }), expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", type: "media.hydrateModel", }); expect(hydrated.history).toEqual(state.history); expect(hydrated.mediaAssets[0]).toMatchObject({ activeDocumentRef: "document:restored", fileName: "cube.glb", layerId: "layer-1", lifecycle: "clean", }); }); it("ignores stale repair, feedback, and hydration results", () => { const state = modelState(); const staleRepair = toolcraftReducer(state, { activeDocumentRef: "document:repaired", analysis: repairableAnalysis(), appliedRepairRecipeId: "recipe:one", assetId: "model-1", expectedActiveDocumentRef: "document:stale", expectedRepairPlanRef: "repair-plan:one", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const staleRepairPlan = toolcraftReducer(state, { activeDocumentRef: "document:repaired", analysis: repairableAnalysis(), appliedRepairRecipeId: "recipe:one", assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedRepairPlanRef: "repair-plan:stale", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const staleRepairProfile = toolcraftReducer(state, { activeDocumentRef: "document:repaired", analysis: repairableAnalysis(), appliedRepairRecipeId: "recipe:one", assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedRepairPlanRef: "repair-plan:one", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "solid-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const incompleteRepair = toolcraftReducer(state, { activeDocumentRef: "document:repaired", analysis: repairableAnalysis(), appliedRepairRecipeId: "recipe:one", assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedRepairPlanRef: "repair-plan:one", expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", repairedDocumentRef: "document:repaired", type: "media.commitModelRepair", }); const staleFeedback = toolcraftReducer(state, { assetId: "model-1", expectedActiveDocumentRef: "document:original", expectedSourceBundleDigest: "sha256:stale", feedback: { category: "repair", code: "stale", message: "Stale result", }, type: "media.setModelRepairError", }); const staleHydration = toolcraftReducer(state, { asset: modelAsset({ lifecycle: "clean" }), expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "realtime-mesh", type: "media.hydrateModel", }); expect(staleRepair).toBe(state); expect(staleRepairPlan).toBe(state); expect(staleRepairProfile).toBe(state); expect(incompleteRepair).toBe(state); expect(staleFeedback).toBe(state); expect(staleHydration).toBe(state); }); it("ignores hydration results analyzed for another topology profile", () => { const state = modelState(modelAsset({ lifecycle: "restoring" })); const staleHydration = toolcraftReducer(state, { asset: modelAsset({ analysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 12, vertices: 8, }, lifecycle: "clean", originalAnalysis: { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean", triangles: 12, vertices: 8, }, topologyProfile: "solid-mesh", }), expectedSourceBundleDigest: "sha256:source", expectedTopologyProfile: "solid-mesh", type: "media.hydrateModel", }); expect(staleHydration).toBe(state); }); });