import { afterEach, describe, expect, it, vi } from "vitest"; import { mediaSourceModule } from "../../modules/built-ins/media-source/media-source-module"; import { timelineModule } from "../../modules/built-ins/timeline/timeline-module"; import { defineToolcraft } from "../../schema/define-toolcraft"; import { createToolcraftState } from "../../state/create-template-state"; import { createToolcraftExternalStore } from "../../composition/public-state"; import type { ToolcraftMediaAsset } from "../../state/types"; import { createToolcraftMediaResourceRef } from "../../source-assets/media-resource-ref"; import { createMemoryToolcraftBinaryAssetRepository } from "../../source-assets/repository/memory-binary-asset-repository"; import { resolveToolcraftSettingsAsset } from "../../source-assets/settings-media"; import { createToolcraftSettingsPayload, importToolcraftSettings, } from "./settings-transfer"; function store() { return createToolcraftExternalStore( createToolcraftState( defineToolcraft({ base: { identity: { id: "settings-import", title: "Settings" }, canvas: { enabled: true }, panels: { controls: { title: "Sources", sections: [ { id: "files", controls: { files: { applicability: { mode: "always" }, type: "fileDrop", assetKind: "file", variant: "collection-actions", multiple: true, target: "source.files", defaultValue: [], itemControls: { weight: { type: "slider", defaultValue: 1, min: 0, max: 10, }, }, }, }, }, ], }, }, settingsTransfer: { enabled: true, additionalValueTargets: ["strength", "mediaAssets"], }, }, modules: [mediaSourceModule(), timelineModule({ mode: "playback" })], }), { values: { strength: 1, mediaAssets: "product value" } }, ), ); } function attachment(id: string): ToolcraftMediaAsset { return { assetKind: "file", fileName: `${id}.csv`, sourcePaths: [`inputs/${id}.csv`], id, sourceTarget: "source.files", layerId: `${id}-layer`, lifecycle: "ready", mimeType: "text/csv", position: { x: 2, y: 3 }, resourceRef: createToolcraftMediaResourceRef( "file", new TextEncoder().encode(id), ), }; } function chooseJson(value: unknown, beforeRead?: () => void) { vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( this: HTMLInputElement, ) { Object.defineProperty(this, "files", { value: { item: () => ({ text: async () => { beforeRead?.(); return JSON.stringify(value); }, }), }, }); this.dispatchEvent(new Event("change")); }); } afterEach(() => { vi.restoreAllMocks(); document.body.replaceChildren(); }); describe("settings JSON import", () => { it("drops skipped file item values and undoes the complete import in one operation", async () => { const runtime = store(); const before = runtime.getState(); const payload = createToolcraftSettingsPayload({ ...before, mediaAssets: [attachment("one"), attachment("missing")], }); chooseJson({ ...payload, values: { "source.files": [ { mediaId: "one", weight: 5 }, { mediaId: "missing", weight: 9 }, ], }, }); await importToolcraftSettings({ ...runtime, sourceAssetCoordinator: { resolveSettingsAsset: async (asset) => asset.id === "one" ? attachment("one") : null, }, }); expect(runtime.getState().values["source.files"]).toEqual([ { mediaId: "one", weight: 5 }, ]); expect(runtime.getState().history.undo).toHaveLength(1); runtime.dispatch({ type: "history.undo" }); expect(runtime.getState().values).toEqual(before.values); expect(runtime.getState().mediaAssets).toEqual(before.mediaAssets); }); it("allows one import per store and releases the slot after cancellation", async () => { const runtime = store(); const click = vi .spyOn(HTMLInputElement.prototype, "click") .mockImplementation(() => {}); const context = { ...runtime, sourceAssetCoordinator: {} }; const first = importToolcraftSettings(context); await importToolcraftSettings(context); expect(click).toHaveBeenCalledOnce(); document .querySelector('input[type="file"]')! .dispatchEvent(new Event("cancel")); await first; const next = importToolcraftSettings(context); expect(click).toHaveBeenCalledTimes(2); document .querySelector('input[type="file"]')! .dispatchEvent(new Event("cancel")); await next; expect(runtime.getState().history.undo).toEqual([]); }); it("restores settings and available attachments in order, skipping missing/invalid/throwing records", async () => { const runtime = store(); const one = attachment("one"); const two = attachment("two"); const repository = createMemoryToolcraftBinaryAssetRepository(); const lease = await repository.beginLease("resources"); for (const asset of [one, two]) { if (asset.assetKind !== "file") throw new Error("Expected file fixture"); await lease.put(asset.resourceRef, new TextEncoder().encode(asset.id), { contentType: asset.mimeType, durable: true, }); } await lease.commit(); const payload = createToolcraftSettingsPayload({ ...runtime.getState(), mediaAssets: [two, attachment("missing"), one, attachment("throws")], }); chooseJson({ ...payload, attachments: [...payload.attachments, { broken: true }], values: { ...payload.values, strength: 42 }, }); const alert = vi.spyOn(window, "alert").mockImplementation(() => {}); const release = vi.fn(); const retainResourceRef = vi.fn(() => release); const resolveSettingsAsset = vi.fn(async (asset: ToolcraftMediaAsset) => { expect(retainResourceRef).toHaveBeenCalled(); if (asset.id === "throws") throw new Error("storage read failed"); return resolveToolcraftSettingsAsset(asset, repository); }); await importToolcraftSettings({ ...runtime, sourceAssetCoordinator: { retainResourceRef, resolveSettingsAsset }, }); expect(runtime.getState().values.strength).toBe(42); expect(runtime.getState().values.mediaAssets).toBe("product value"); expect(runtime.getState().mediaAssets).toEqual([two, one]); expect(release).toHaveBeenCalledTimes(4); expect(alert).not.toHaveBeenCalled(); expect(document.querySelector('input[type="file"]')).toBeNull(); }); it("imports normal settings when every attachment is unavailable and uses live timeline state", async () => { const runtime = store(); const payload = createToolcraftSettingsPayload({ ...runtime.getState(), mediaAssets: [attachment("missing")], }); chooseJson({ ...payload, values: { strength: 99 } }, () => runtime.dispatch({ type: "timeline.toggleLoop" }), ); await importToolcraftSettings({ ...runtime, sourceAssetCoordinator: { resolveSettingsAsset: async () => null }, }); expect(runtime.getState().values.strength).toBe(99); expect(runtime.getState().timeline.isLooping).toBe( payload.timeline.isLooping, ); expect(runtime.getState().mediaAssets).toEqual([]); }); it("cancellation settles and cleans up without touching settings", async () => { const runtime = store(); const before = runtime.getState(); vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function ( this: HTMLInputElement, ) { this.dispatchEvent(new Event("cancel")); }); await importToolcraftSettings({ ...runtime, sourceAssetCoordinator: {} }); expect(runtime.getState()).toBe(before); expect(document.querySelector('input[type="file"]')).toBeNull(); }); it("invalid settings fail before attachments or values change", async () => { const runtime = store(); const before = runtime.getState(); chooseJson({ ...createToolcraftSettingsPayload(before), version: 2 }); const alert = vi.spyOn(window, "alert").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); const resolveSettingsAsset = vi.fn(); await importToolcraftSettings({ ...runtime, sourceAssetCoordinator: { resolveSettingsAsset }, }); expect(runtime.getState()).toBe(before); expect(resolveSettingsAsset).not.toHaveBeenCalled(); expect(alert).toHaveBeenCalledOnce(); }); it("attachment replacement preserves product layers, stable ids and undo without value-name collisions", () => { const runtime = store(); const before = runtime.getState(); const snapshot = createToolcraftSettingsPayload(before); const observe = vi.fn(); runtime.subscribe(observe); runtime.dispatch({ type: "settings.apply", settings: { ...snapshot, assets: [attachment("one")], values: { strength: 8 }, }, }); expect(observe).toHaveBeenCalledOnce(); expect(runtime.getState().history.undo).toHaveLength(1); expect(runtime.getState().values.strength).toBe(8); expect(runtime.getState().mediaAssets[0]?.id).toBe("one"); expect(runtime.getState().values.mediaAssets).toBe("product value"); runtime.dispatch({ type: "history.undo" }); expect(runtime.getState().mediaAssets).toEqual(before.mediaAssets); expect(runtime.getState().layers).toEqual(before.layers); expect(runtime.getState().values.strength).toBe(1); runtime.dispatch({ type: "history.redo" }); expect(runtime.getState().mediaAssets).toEqual([attachment("one")]); }); });