import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; import { layersModule } from "../modules/built-ins/layers/layers-module"; import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { isToolcraftCanvasStateHistoryPatch, isToolcraftControlsResetHistoryPatch, } from "../state/history-patch-metadata"; import { toolcraftReducer } from "../composition/public-state"; import { importImageMedia } from "../state/reducer-media-test-utils"; import { createToolcraftArtifactSnapshot } from "./artifact-export-snapshot"; function liveState() { return createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), ); } function collectObjects( value: unknown, objects = new Set(), ): Set { if (value === null || typeof value !== "object" || objects.has(value)) return objects; objects.add(value); for (const child of Object.values(value)) collectObjects(child, objects); return objects; } describe("artifact snapshot", () => { it("captures Reset, Undo, and Redo render state without cloning the live journal", () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true, upload: true }, panels: { controls: { title: "Controls", sections: [ { id: "fixture-section-1", controls: { amount: { applicability: { mode: "always" as const }, type: "slider", target: "amount", defaultValue: 50, }, }, }, ], }, }, }, modules: [layersModule(), mediaSourceModule()], }); const imported = importImageMedia(createToolcraftState(schema)); const changed = toolcraftReducer(imported, { type: "controls.setValue", target: "amount", value: 75, }); const reset = toolcraftReducer(changed, { type: "controls.reset" }); const undone = toolcraftReducer(reset, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect( isToolcraftControlsResetHistoryPatch(reset.history.undo.at(-1)), ).toBe(true); for (const [live, amount] of [ [reset, 50], [undone, 75], [redone, 50], ] as const) { const history = live.history; const undo = [...history.undo]; const redo = [...history.redo]; const snapshot = createToolcraftArtifactSnapshot(live); expect(snapshot.values.amount).toBe(amount); expect(snapshot.history).toEqual({ undo: [], redo: [] }); expect(Object.isFrozen(snapshot.history.undo)).toBe(true); expect(snapshot.mediaAssets).toEqual(imported.mediaAssets); expect(snapshot.mediaAssets).not.toBe(live.mediaAssets); expect(Object.isFrozen(snapshot.mediaAssets[0])).toBe(true); expect(live.history).toBe(history); expect(history.undo).toEqual(undo); expect(history.redo).toEqual(redo); for (const [index, patch] of undo.entries()) expect(history.undo[index]).toBe(patch); for (const [index, patch] of redo.entries()) expect(history.redo[index]).toBe(patch); expect(Object.isFrozen(history)).toBe(false); expect(() => { (snapshot.values as Record).amount = 1; }).toThrow(TypeError); expect(live.values.amount).toBe(amount); } }); it("captures actual canvas history patches with the current frame intact", () => { const changed = toolcraftReducer(liveState(), { type: "canvas.applySettings", mode: "finite", size: { width: 640, height: 480, unit: "px" }, }); expect( isToolcraftCanvasStateHistoryPatch(changed.history.undo.at(-1)), ).toBe(true); const undone = toolcraftReducer(changed, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); for (const live of [changed, undone, redone]) { const history = live.history; const snapshot = createToolcraftArtifactSnapshot(live); expect(snapshot.canvas).toEqual(live.canvas); expect(snapshot.values).toEqual(live.values); expect(snapshot.history).toEqual({ undo: [], redo: [] }); expect(live.history).toBe(history); expect(Object.isFrozen(history.undo)).toBe(false); } }); it("still rejects current render metadata symbols even when they match history provenance", () => { const live = toolcraftReducer(liveState(), { type: "controls.reset" }); live.values.invalid = { [Symbol.for("toolcraft/history-patch-metadata/v1")]: { source: "controls.reset", }, }; expect(() => createToolcraftArtifactSnapshot(live)).toThrowError( expect.objectContaining({ feedback: expect.objectContaining({ code: "export-snapshot-invalid" }), }), ); }); it("never invokes root render metadata accessors while creating the projection", () => { const live = liveState(); let accessed = false; Object.defineProperty(live, "values", { enumerable: true, get() { accessed = true; return {}; }, }); expect(() => createToolcraftArtifactSnapshot(live)).toThrow(); expect(accessed).toBe(false); }); it.each(["symbol", "hidden"] as const)( "does not discard invalid root %s properties", (kind) => { const live = liveState(); Object.defineProperty( live, kind === "symbol" ? Symbol("render") : "hidden", { enumerable: kind === "symbol", value: "invalid", }, ); expect(() => createToolcraftArtifactSnapshot(live)).toThrowError( expect.objectContaining({ feedback: expect.objectContaining({ code: "export-snapshot-invalid", }), }), ); }, ); it("owns and freezes the complete metadata graph without freezing live state", () => { const live = liveState(); const compound = { channels: [{ color: "#123456", opacity: 75 }] }; live.values["audit.compound"] = compound; const originals = collectObjects(live); const snapshot = createToolcraftArtifactSnapshot(live); expect(snapshot).toEqual(live); expect(snapshot.values).not.toBe(live.values); expect(snapshot.values["audit.compound"]).not.toBe(compound); expect(snapshot.mediaAssets).not.toBe(live.mediaAssets); expect(snapshot.timeline).not.toBe(live.timeline); expect(snapshot.layers).not.toBe(live.layers); for (const object of collectObjects(snapshot)) { expect(originals.has(object)).toBe(false); expect(Object.isFrozen(object)).toBe(true); } expect(Object.isFrozen(compound)).toBe(false); compound.channels[0]!.color = "#654321"; expect(snapshot.values["audit.compound"]).toEqual({ channels: [{ color: "#123456", opacity: 75 }], }); }); it("rejects a renderer's nested mutation without changing live metadata", () => { const live = liveState(); live.values["audit.compound"] = { channels: [{ opacity: 75 }] }; const snapshot = createToolcraftArtifactSnapshot(live); const mutation = () => { const value = snapshot.values["audit.compound"] as { channels: { opacity: number; }[]; }; value.channels[0]!.opacity = 5; }; expect(mutation).toThrow(TypeError); expect(live.values["audit.compound"]).toEqual({ channels: [{ opacity: 75 }], }); }); it.each([ ["Map", new Map([["value", 1]])], ["Set", new Set([1])], ["typed array", new Uint8Array([1])], ["Date", new Date(0)], [ "class", new (class Metadata { value = 1; })(), ], [ "array subclass", new (class MetadataList extends Array { label() { return "metadata"; } })(1), ], ["function", () => undefined], ["symbol", Symbol("invalid")], ])("fails explicitly for unsupported %s metadata", (_name, value) => { const live = liveState(); live.values["invalid"] = value; expect(() => createToolcraftArtifactSnapshot(live)).toThrowError( expect.objectContaining({ feedback: expect.objectContaining({ code: "export-snapshot-invalid" }), }), ); expect(live.values["invalid"]).toBe(value); }); it("preserves shared metadata identity inside the clone and handles undefined", () => { const live = liveState(); const shared = { reference: "resource:source", optional: undefined }; live.values["a"] = shared; live.values["b"] = shared; const snapshot = createToolcraftArtifactSnapshot(live); expect(snapshot.values["a"]).toBe(snapshot.values["b"]); expect(snapshot.values["a"]).not.toBe(shared); expect(snapshot.values["a"]).toEqual(shared); }); it("does not invoke metadata accessors or accept cyclic values", () => { const live = liveState(); let accessed = false; live.values["accessor"] = { get value() { accessed = true; return 1; }, }; expect(() => createToolcraftArtifactSnapshot(live)).toThrow(); expect(accessed).toBe(false); delete live.values["accessor"]; const cyclic: { self?: unknown; } = {}; cyclic.self = cyclic; live.values["cycle"] = cyclic; expect(() => createToolcraftArtifactSnapshot(live)).toThrowError( expect.objectContaining({ feedback: expect.objectContaining({ code: "export-snapshot-invalid" }), }), ); }); });