import { describe, expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState, toolcraftReducer } from "../composition/public-state"; import { createToolcraftPersistenceSnapshot, parseToolcraftPersistenceSnapshot } from "../composition/public-persistence"; import { createToolcraftAppDefaults, parseToolcraftAppDefaults } from "../schema/app-defaults"; import { sliderRangeDefinition } from "./slider-range.test-support"; import { resolveToolcraftSliderEdit } from "./control-ranges"; import { assertToolcraftEditableSliderRange } from "../schema/slider-range"; import { timelineModule } from "../modules/built-ins/timeline/timeline-module"; const schema = defineToolcraft(sliderRangeDefinition); const edit = (state = createToolcraftState(schema), value: number | readonly number[] = [0, 40], target = "shape.band") => toolcraftReducer(state, { type: "controls.editSlider", target, value }); describe("editable numeric scale", () => { it("expands and shrinks both boundaries atomically and preserves siblings", () => { const initial = createToolcraftState(schema); const expanded = edit(initial); expect(expanded.values["shape.band"]).toEqual([0, 40]); expect(expanded.controlRanges["shape.band"]).toEqual({ min: 0, max: 40 }); expect(expanded.values["shape.radius"]).toBe(20); const shrunk = edit(expanded, [0, 10]); expect(shrunk.controlRanges["shape.band"]).toEqual({ min: 0, max: 10 }); const undone = toolcraftReducer(shrunk, { type: "history.undo" }); expect(undone.controlRanges).toEqual(expanded.controlRanges); expect(undone.values).toEqual(expanded.values); expect(toolcraftReducer(undone, { type: "history.redo" }).controlRanges).toEqual(shrunk.controlRanges); const moved = toolcraftReducer(expanded, { type: "controls.setValue", target: "shape.band", value: [5, 15] }); expect(moved.controlRanges).toBe(expanded.controlRanges); expect(edit(initial, [-10, 20], "shape.position").controlRanges["shape.position"]).toEqual({ min: -10, max: 20 }); }); it("allows radius 1000, inward endpoint edits, and interior edits without moving the scale", () => { const expanded = edit(undefined, 1000, "shape.radius"); const reduced = edit(expanded, 10, "shape.radius"); expect(reduced.controlRanges["shape.radius"]).toEqual({ min: 0, max: 10 }); const interior = toolcraftReducer(expanded, { type: "controls.setValue", target: "shape.radius", value: 500 }); expect(edit(interior, 100, "shape.radius").controlRanges["shape.radius"]).toEqual({ min: 0, max: 1000 }); const zero = edit(reduced, 0, "shape.radius"); expect(zero.values["shape.radius"]).toBe(0); expect(zero.controlRanges["shape.radius"]).toEqual({ min: 0, max: 10 }); expect(edit(zero, 10, "shape.radius").controlRanges["shape.radius"]).toEqual({ min: 0, max: 10 }); }); it("restores an out-of-scale thumb default without clamping or shrinking the other boundary", () => { const changed = edit(undefined, [5, 10]); const reset = toolcraftReducer(changed, { type: "controls.editSlider", target: "shape.band", value: [5, 20], reason: "reset" }); expect(reset.controlRanges["shape.band"]).toEqual({ min: 5, max: 20 }); expect(reset.values["shape.band"]).toEqual([5, 20]); expect(toolcraftReducer(reset, { type: "history.undo" }).controlRanges).toEqual(changed.controlRanges); }); it("updates the selected keyframe, value and scale in one undo transaction", () => { const animatedSchema = defineToolcraft({ ...sliderRangeDefinition, modules: [timelineModule({ mode: "keyframes" })] }); const initial = createToolcraftState(animatedSchema, { timeline: { expanded: true } }); const tracked = toolcraftReducer(initial, { type: "timeline.upsertControlKeyframe", controlId: "shape.radius", controlLabel: "Radius", timeSeconds: 2, value: 20, valueLabel: "20" }); const changed = edit(tracked, 40, "shape.radius"); expect(changed.timeline.keyframeGroups[0]?.keyframes).toHaveLength(1); expect(changed.timeline.keyframeGroups[0]?.keyframes[0]).toMatchObject({ timeSeconds: 2, value: 40 }); expect(changed.history.undo).toHaveLength(tracked.history.undo.length + 1); const undone = toolcraftReducer(changed, { type: "history.undo" }); expect(undone.timeline).toEqual(tracked.timeline); expect(undone.values).toEqual(tracked.values); expect(undone.controlRanges).toEqual(tracked.controlRanges); expect(toolcraftReducer(undone, { type: "history.redo" }).timeline).toEqual(changed.timeline); }); it.each([[-10, 20], [0, 1001], [10, 0], [10, 10], [0, Infinity], [0, NaN]])("rejects invalid pair %j without partial state", (...values) => { const initial = createToolcraftState(schema); expect(edit(initial, values)).toBe(initial); }); it("keeps fixed controls and existing keyframes protected", () => { const initial = createToolcraftState(schema); expect(edit(initial, 100, "shape.fixed")).toBe(initial); expect(toolcraftReducer(initial, { type: "controls.setValue", target: "shape.radius", value: -1 })).toBe(initial); const withKeyframes = { ...initial, timeline: { ...initial.timeline, keyframeGroups: [{ controlId: "shape.band", label: "Band", keyframes: [{ id: "k", controlId: "shape.band", controlLabel: "Band", value: [0, 15], valueLabel: "0–15", timeSeconds: 0 }] }] } }; expect(resolveToolcraftSliderEdit(withKeyframes, "shape.band", [0, 10])).toEqual({ accepted: false, error: "Range must include existing keyframe values." }); expect(edit(withKeyframes, [0, 10])).toBe(withKeyframes); }); it("restores range-only changes from local persistence and discards malformed overrides", () => { const changed = toolcraftReducer(edit(), { type: "controls.setValue", target: "shape.band", value: [0, 20] }); const payload = createToolcraftPersistenceSnapshot(changed, schema.persistence)!; const restored = createToolcraftState(schema, parseToolcraftPersistenceSnapshot(schema, JSON.stringify(payload))); expect(restored.controlRanges).toEqual(changed.controlRanges); expect(restored.values).toEqual(changed.values); payload.state.controlRanges = { "shape.band": { min: -10, max: 40 }, "shape.radius": { min: 0, max: 1000 } }; expect(createToolcraftState(schema, parseToolcraftPersistenceSnapshot(schema, JSON.stringify(payload))).controlRanges).toEqual({ "shape.radius": { min: 0, max: 1000 } }); }); it("writes complete source defaults, restores fresh/reset scales, and rejects invalid source ranges", () => { const changed = edit(edit(), 1000, "shape.radius"); const saved = createToolcraftAppDefaults(changed); const nextSchema = defineToolcraft({ ...sliderRangeDefinition, defaults: JSON.parse(JSON.stringify(saved)) }); const fresh = createToolcraftState(nextSchema); expect(fresh.controlRanges).toEqual(changed.controlRanges); expect(fresh.values).toEqual(changed.values); const other = edit(fresh, [0, 10]); for (const command of [{ type: "controls.reset" }, { type: "controls.resetTargets", targets: ["shape.band"] }] as const) { const reset = toolcraftReducer(other, command.type === "controls.reset" ? command : { ...command, targets: [...command.targets] }); expect(reset.controlRanges).toEqual(fresh.controlRanges); expect(toolcraftReducer(reset, { type: "history.undo" }).controlRanges).toEqual(other.controlRanges); } saved.state.controlRanges = { "shape.band": { min: -1, max: 40 } }; expect(() => parseToolcraftAppDefaults(schema, saved)).toThrow(); }); it("resets range-only differences even when the value already equals its default", () => { const changed = toolcraftReducer(edit(), { type: "controls.setValue", target: "shape.band", value: [0, 20] }); expect(toolcraftReducer(changed, { type: "controls.resetTargets", targets: ["shape.band"] }).controlRanges).toEqual({}); expect(toolcraftReducer(changed, { type: "controls.reset" }).controlRanges).toEqual({}); }); it.each([ { min: 10, max: 0 }, { step: 0 }, { defaultValue: 21 }, { editableRange: { hardMin: 1 } }, { editableRange: { hardMax: 19 } }, { editableRange: { hardMax: Infinity } }, ])("rejects inconsistent schema %j at creation", patch => { expect(() => assertToolcraftEditableSliderRange({ ...sliderRangeDefinition.base.panels.controls.sections[0].controls.radius, ...patch }, "radius")).toThrow(); }); it("snapshots hard limits at schema creation and rejects unsupported owners", () => { const definition = JSON.parse(JSON.stringify(sliderRangeDefinition)); const created = defineToolcraft(definition); definition.base.panels.controls.sections[0].controls.radius.editableRange.hardMin = -100; expect(edit(createToolcraftState(created), -1, "shape.radius").values["shape.radius"]).toBe(20); definition.base.panels.controls.sections[0].controls.radius.min = 30; expect(() => defineToolcraft(definition)).toThrow(/editableRange/); definition.base.panels.controls.sections[0].controls = { items: { type: "sourceCollection", target: "shape.items", defaultValue: [10], itemControl: { type: "slider", defaultValue: 10, min: 0, max: 20, editableRange: {} }, applicability: { mode: "always" }, } }; expect(() => defineToolcraft(definition)).toThrow(/top-level product slider target/); }); });