import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { isToolcraftControlsResetHistoryPatch, tagToolcraftControlsResetHistoryPatch, } from "./history-patch-metadata"; import { commitToolcraftValuePatch } from "./history-patches"; import { toolcraftReducer } from "./reducer"; import { createState } from "./reducer-test-utils"; describe("toolcraftReducer controls", () => { it("canonicalizes Color writes before equality and history", () => { const schema = defineToolcraft({ canvas: { enabled: false }, panels: { controls: { sections: [ { controls: { fill: { defaultValue: "#112233", target: "shape.fill", type: "color", }, }, title: "Appearance", }, ], title: "Controls", }, }, }); const seeded = createToolcraftState(schema, { values: { "shape.fill": { hex: "#223344" } }, }); const changed = toolcraftReducer(seeded, { label: "Fill", target: "shape.fill", type: "controls.setValue", value: { hex: "#334455" }, }); expect(changed.values["shape.fill"]).toBe("#334455"); expect(changed.history.undo.at(-1)).toMatchObject({ after: { "shape.fill": "#334455" }, before: { "shape.fill": "#223344" }, }); const invalid = toolcraftReducer(changed, { target: "shape.fill", type: "controls.setValue", value: { hex: "not-a-color" }, }); expect(invalid).toBe(changed); }); it("resets controls to defaults and records history", () => { const changed = toolcraftReducer(createState(), { target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); const state = toolcraftReducer(changed, { type: "controls.reset" }); expect(state.values["selectedLayer.opacity"]).toBe(75); expect(state.history.undo.at(-1)?.label).toBe("Reset controls"); expect( isToolcraftControlsResetHistoryPatch(state.history.undo.at(-1)), ).toBe(true); }); it("keeps reset provenance out of public patch keys and serialization", () => { const changed = toolcraftReducer(createState(), { target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); const state = toolcraftReducer(changed, { type: "controls.reset" }); const patch = state.history.undo.at(-1); expect(Object.keys(patch ?? {}).sort()).toEqual([ "after", "before", "label", ]); expect(Object.prototype.hasOwnProperty.call(patch, "source")).toBe(false); expect(JSON.parse(JSON.stringify(patch))).toEqual({ after: patch?.after, before: patch?.before, label: patch?.label, }); expect( isToolcraftControlsResetHistoryPatch(patch ? { ...patch } : undefined), ).toBe(true); }); it("does not infer reset provenance from an ordinary edit label or group", () => { const state = toolcraftReducer(createState(), { history: "merge", historyGroup: "Reset controls", label: "Reset controls", target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); expect(state.history.undo.at(-1)).toMatchObject({ group: "Reset controls", label: "Reset controls", }); expect( isToolcraftControlsResetHistoryPatch(state.history.undo.at(-1)), ).toBe(false); }); it("preserves typed provenance through history merge, undo, and redo", () => { const base = createState(); const first = commitToolcraftValuePatch( base, tagToolcraftControlsResetHistoryPatch({ after: { "selectedLayer.opacity": 64 }, before: { "selectedLayer.opacity": 75 }, label: "First reset update", }), { ...base.values, "selectedLayer.opacity": 64 }, { group: "reset-merge", mode: "merge" }, ); const second = commitToolcraftValuePatch( first, tagToolcraftControlsResetHistoryPatch({ after: { "selectedLayer.opacity": 42 }, before: { "selectedLayer.opacity": 64 }, label: "Second reset update", }), { ...first.values, "selectedLayer.opacity": 42 }, { group: "reset-merge", mode: "merge" }, ); const patch = second.history.undo.at(-1); const undone = toolcraftReducer(second, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(second.history.undo).toHaveLength(1); expect(isToolcraftControlsResetHistoryPatch(patch)).toBe(true); expect(undone.history.redo.at(-1)).toBe(patch); expect(redone.history.undo.at(-1)).toBe(patch); }); it("does not inherit reset provenance when an ordinary edit reuses its group", () => { const base = createState(); const sourced = commitToolcraftValuePatch( base, tagToolcraftControlsResetHistoryPatch({ after: { "selectedLayer.opacity": 64 }, before: { "selectedLayer.opacity": 75 }, label: "Reset update", }), { ...base.values, "selectedLayer.opacity": 64 }, { group: "shared-group", mode: "merge" }, ); const ordinary = toolcraftReducer(sourced, { history: "merge", historyGroup: "shared-group", label: "Reset controls", target: "selectedLayer.opacity", type: "controls.setValue", value: 42, }); expect(ordinary.history.undo).toHaveLength(2); expect(isToolcraftControlsResetHistoryPatch(ordinary.history.undo[0])).toBe( true, ); expect(isToolcraftControlsResetHistoryPatch(ordinary.history.undo[1])).toBe( false, ); }); it("resets selected control targets to defaults and records one history patch", () => { const app = defineToolcraft({ canvas: { enabled: false }, panels: { controls: { sections: [ { controls: { contrast: { defaultValue: 22, target: "style.contrast", type: "slider", }, opacity: { defaultValue: 75, target: "selectedLayer.opacity", type: "slider", }, }, title: "Tone", }, ], title: "Controls", }, }, }); const changedOpacity = toolcraftReducer(createToolcraftState(app), { target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); const changedBoth = toolcraftReducer(changedOpacity, { target: "style.contrast", type: "controls.setValue", value: 9, }); const state = toolcraftReducer(changedBoth, { label: "Reset Tone section", targets: ["selectedLayer.opacity"], type: "controls.resetTargets", }); expect(state.values["selectedLayer.opacity"]).toBe(75); expect(state.values["style.contrast"]).toBe(9); expect(state.history.undo).toHaveLength(3); expect(state.history.undo.at(-1)).toMatchObject({ after: { "selectedLayer.opacity": 75 }, before: { "selectedLayer.opacity": 12 }, label: "Reset Tone section", }); }); it("undoes and redoes value changes", () => { const changed = toolcraftReducer(createState(), { target: "selectedLayer.opacity", type: "controls.setValue", value: 12, }); const undone = toolcraftReducer(changed, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(undone.values["selectedLayer.opacity"]).toBe(75); expect(redone.values["selectedLayer.opacity"]).toBe(12); }); it("merges live control changes from one editor gesture into one undo step", () => { const base = createState(); const first = toolcraftReducer(base, { history: "merge", historyGroup: "opacity-drag-1", target: "selectedLayer.opacity", type: "controls.setValue", value: 76, }); const second = toolcraftReducer(first, { history: "merge", historyGroup: "opacity-drag-1", target: "selectedLayer.opacity", type: "controls.setValue", value: 82, }); const third = toolcraftReducer(second, { history: "merge", historyGroup: "opacity-drag-1", target: "selectedLayer.opacity", type: "controls.setValue", value: 91, }); expect(third.values["selectedLayer.opacity"]).toBe(91); expect(third.history.undo).toHaveLength(1); expect(third.history.undo[0]).toMatchObject({ after: { "selectedLayer.opacity": 91 }, before: { "selectedLayer.opacity": 75 }, group: "opacity-drag-1", }); const undone = toolcraftReducer(third, { type: "history.undo" }); const redone = toolcraftReducer(undone, { type: "history.redo" }); expect(undone.values["selectedLayer.opacity"]).toBe(75); expect(redone.values["selectedLayer.opacity"]).toBe(91); }); it("starts a new undo step for a new live gesture on the same control", () => { const firstGesture = toolcraftReducer(createState(), { history: "merge", historyGroup: "opacity-drag-1", target: "selectedLayer.opacity", type: "controls.setValue", value: 82, }); const secondGesture = toolcraftReducer(firstGesture, { history: "merge", historyGroup: "opacity-drag-2", target: "selectedLayer.opacity", type: "controls.setValue", value: 64, }); expect(secondGesture.history.undo).toHaveLength(2); expect(secondGesture.history.undo[0]?.before).toEqual({ "selectedLayer.opacity": 75, }); expect(secondGesture.history.undo[0]?.after).toEqual({ "selectedLayer.opacity": 82, }); expect(secondGesture.history.undo[1]?.before).toEqual({ "selectedLayer.opacity": 82, }); expect(secondGesture.history.undo[1]?.after).toEqual({ "selectedLayer.opacity": 64, }); }); });