import { describe, expect, it } from "vitest"; import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; import { createNormalizedControlsRecord } from "./control-schema-normalization"; import { defineToolcraft } from "./define-toolcraft"; import type { ToolcraftControlSchema } from "./types"; function createAppSchema(control: ToolcraftControlSchema) { return { base: { identity: { id: "slider-marker-fixture", title: "Slider marker fixture" }, canvas: { enabled: true }, panels: { controls: { sections: [ { id: "fixture-section-1", controls: { tested: control }, title: "Test", }, ], title: "Controls", }, }, }, modules: control.type === "fileDrop" ? [mediaSourceModule()] : [], }; } function createVisualDiscreteControl( type: "rangeSlider" | "slider", max = 31, overrides: Record = {}, ): ToolcraftControlSchema { return { applicability: { mode: "always" }, defaultValue: type === "slider" ? 16 : [8, 24], markerCount: 2, max, min: 0, sliderValueKind: "discrete", step: 1, target: `test.${type}`, type, variant: "discrete", ...overrides, } as ToolcraftControlSchema; } function getTestedControl(app: ReturnType) { return app.panels.controls?.sections .flatMap((section) => Object.values(section.controls)) .find((control) => control.target.startsWith("test.")); } function normalizeTestControl(control: ToolcraftControlSchema) { return createNormalizedControlsRecord([["tested", control]]).tested; } function normalizeNonPlainTestControl(control: ToolcraftControlSchema) { // Public definitions reject executable or inherited data before the internal // normalizer's own defensive snapshotting semantics are exercised. expect(() => defineToolcraft(createAppSchema(control))).toThrow( /serializable plain data/u, ); return normalizeTestControl(control); } describe("defineToolcraft discrete slider marker policy", () => { it.each(["slider", "rangeSlider"] as const)( "normalizes the valid 32-position %s boundary", (type) => { const authored = createVisualDiscreteControl(type); const snapshot = structuredClone(authored); const resolved = getTestedControl( defineToolcraft(createAppSchema(authored)), ); expect(resolved?.markerCount).toBe(32); expect(authored).toEqual(snapshot); }, ); it.each(["slider", "rangeSlider"] as const)( "rejects a 33-position %s before render", (type) => { expect(() => defineToolcraft(createAppSchema(createVisualDiscreteControl(type, 32))), ).toThrow( `Toolcraft control validation [slider-marker-budget] for target "test.${type}": variant "discrete" has 33 positions; maximum is 32. Keep sliderValueKind "discrete" and step, but use variant "continuous" or another built-in control.`, ); }, ); it.each([ ["missing min", { min: undefined }], ["missing max", { max: undefined }], ["missing step", { step: undefined }], ["non-finite min", { min: Number.NEGATIVE_INFINITY }], ["non-finite max", { max: Number.POSITIVE_INFINITY }], ["non-finite step", { step: Number.NaN }], ["reversed range", { min: 5, max: 0 }], ["zero step", { step: 0 }], ["negative step", { step: -1 }], ])("rejects a visual discrete slider with %s", (_name, overrides) => { const control = createVisualDiscreteControl("slider", 31, overrides); expect(() => normalizeTestControl(control)).toThrow( 'Toolcraft control validation [slider-marker-domain] for target "test.slider": variant "discrete" requires finite min and max plus a finite positive step with max greater than min.', ); expect(() => defineToolcraft(createAppSchema(control))).toThrow( _name.startsWith("missing") ? /serializable plain data; undefined values are forbidden/u : _name.startsWith("non-finite") ? /serializable plain data; non-finite numbers are forbidden/u : /Toolcraft control validation \[slider-marker-domain\]/u, ); }); it("keeps a 65-position discrete value domain visually continuous", () => { const { markerCount: _markerCount, ...control } = createVisualDiscreteControl("slider", 64); const resolved = getTestedControl( defineToolcraft( createAppSchema({ ...control, variant: "continuous", } as ToolcraftControlSchema), ), ); expect(resolved?.sliderValueKind).toBe("discrete"); expect(resolved?.step).toBe(1); expect(resolved?.variant).toBe("continuous"); expect(resolved?.markerCount).toBeUndefined(); }); it("normalizes a valid sourceCollection slider item without mutating input", () => { const parent = { applicability: { mode: "always" as const }, defaultValue: [3], itemControl: { defaultValue: 3, markerCount: 2, max: 5, min: 0, sliderValueKind: "discrete", step: 1, type: "slider", variant: "discrete", }, itemLabel: "Layer", target: "test.layers", type: "sourceCollection", } as ToolcraftControlSchema; const snapshot = structuredClone(parent); const resolved = getTestedControl(defineToolcraft(createAppSchema(parent))); expect(resolved?.itemControl?.markerCount).toBe(6); expect(parent).toEqual(snapshot); }); it("rejects inherited nested slider domain fields after snapshotting", () => { const itemControl = Object.assign( Object.create({ max: 5, min: 0, step: 1 }) as Record, { defaultValue: 3, sliderValueKind: "discrete", type: "slider", variant: "discrete", }, ); expect(() => normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [3], itemControl, target: "test.layers", type: "sourceCollection", } as unknown as ToolcraftControlSchema), ).toThrow( 'Toolcraft control validation [slider-marker-domain] for target "test.layers" itemControl: variant "discrete" requires finite min and max plus a finite positive step with max greater than min.', ); }); it("rejects an inherited homogeneous nested control type", () => { const itemControl = Object.assign( Object.create({ type: "slider" }) as Record, { defaultValue: 3, max: 5, min: 0, sliderValueKind: "discrete", step: 1, variant: "discrete", }, ); expect(() => normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [3], itemControl, target: "test.layers", type: "sourceCollection", } as unknown as ToolcraftControlSchema), ).toThrow( /Toolcraft control validation \[collection-item-control\].*unsupported collection item control "undefined"/u, ); }); it("reads a homogeneous nested control type accessor once", () => { let typeReads = 0; const itemControl: Record = { defaultValue: 3, max: 5, min: 0, sliderValueKind: "discrete", step: 1, variant: "discrete", }; Object.defineProperty(itemControl, "type", { enumerable: true, get() { typeReads += 1; return typeReads === 1 ? "slider" : "imaginary"; }, }); const resolved = normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [3], itemControl, target: "test.layers", type: "sourceCollection", } as unknown as ToolcraftControlSchema); expect(resolved?.itemControl?.type).toBe("slider"); expect(resolved?.itemControl?.markerCount).toBe(6); expect(typeReads).toBe(1); }); it("rejects an inherited compound nested control type", () => { const strength = Object.assign( Object.create({ type: "slider" }) as Record, { defaultValue: 3, max: 5, min: 0, sliderValueKind: "discrete", step: 1, variant: "discrete", }, ); expect(() => normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [{ enabled: true, strength: 3 }], itemControls: { enabled: { defaultValue: true, type: "switch" }, strength, }, target: "test.layers", type: "collectionActions", } as unknown as ToolcraftControlSchema), ).toThrow( /Toolcraft control validation \[collection-item-controls\].*itemControls\.strength: unsupported built-in field/u, ); }); it("reads a compound nested control type accessor once", () => { let typeReads = 0; const strength: Record = { defaultValue: 3, max: 5, min: 0, sliderValueKind: "discrete", step: 1, variant: "discrete", }; Object.defineProperty(strength, "type", { enumerable: true, get() { typeReads += 1; return typeReads === 1 ? "slider" : "imaginary"; }, }); const resolved = normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [{ enabled: true, strength: 3 }], itemControls: { enabled: { defaultValue: true, type: "switch" }, strength, }, target: "test.layers", type: "collectionActions", } as unknown as ToolcraftControlSchema); expect(resolved?.itemControls?.strength?.type).toBe("slider"); expect(resolved?.itemControls?.strength?.markerCount).toBe(6); expect(typeReads).toBe(1); }); it("reads each compound nested slider domain accessor once", () => { const reads = { max: 0, min: 0, step: 0 }; const itemControl: Record = { defaultValue: 3, sliderValueKind: "discrete", type: "slider", variant: "discrete", }; Object.defineProperties(itemControl, { max: { enumerable: true, get() { reads.max += 1; return 5; }, }, min: { enumerable: true, get() { reads.min += 1; return 0; }, }, step: { enumerable: true, get() { reads.step += 1; return 1; }, }, }); const resolved = normalizeNonPlainTestControl({ applicability: { mode: "always" }, defaultValue: [{ enabled: true, strength: 3 }], itemControls: { enabled: { defaultValue: true, type: "switch" }, strength: itemControl, }, target: "test.layers", type: "collectionActions", } as unknown as ToolcraftControlSchema); expect(resolved?.itemControls?.strength?.markerCount).toBe(6); expect(reads).toEqual({ max: 1, min: 1, step: 1 }); }); it("normalizes a valid collectionActions rangeSlider item", () => { const resolved = getTestedControl( defineToolcraft( createAppSchema({ applicability: { mode: "always" as const }, defaultValue: [[1, 4]], itemControl: { defaultValue: [1, 4], markerCount: 2, max: 5, min: 0, sliderValueKind: "discrete", step: 1, type: "rangeSlider", variant: "discrete", }, target: "test.ranges", type: "collectionActions", }), ), ); expect(resolved?.itemControl?.markerCount).toBe(6); }); it("rejects an overloaded homogeneous collection item slider", () => { expect(() => defineToolcraft( createAppSchema({ applicability: { mode: "always" as const }, defaultValue: [16], itemControl: { defaultValue: 16, max: 64, min: 0, sliderValueKind: "discrete", step: 1, type: "slider", variant: "discrete", }, itemLabel: "Layer", target: "test.layers", type: "sourceCollection", }), ), ).toThrow( 'Toolcraft control validation [slider-marker-budget] for target "test.layers" itemControl: variant "discrete" has 65 positions; maximum is 32. Keep sliderValueKind "discrete" and step, but use variant "continuous" or another built-in control.', ); }); it("rejects an overloaded compound collection item slider with its field path", () => { expect(() => defineToolcraft( createAppSchema({ applicability: { mode: "always" as const }, defaultValue: [{ enabled: true, strength: 16 }], itemControls: { enabled: { defaultValue: true, type: "switch" }, strength: { defaultValue: 16, max: 64, min: 0, sliderValueKind: "discrete", step: 1, type: "slider", variant: "discrete", }, }, target: "test.layers", type: "collectionActions", }), ), ).toThrow( 'Toolcraft control validation [slider-marker-budget] for target "test.layers" itemControls.strength: variant "discrete" has 65 positions; maximum is 32. Keep sliderValueKind "discrete" and step, but use variant "continuous" or another built-in control.', ); }); it("rejects an overloaded per-file collection item slider", () => { expect(() => defineToolcraft( createAppSchema({ applicability: { mode: "always" as const }, accept: ".svg", assetKind: "file", defaultValue: [], itemControls: { strength: { defaultValue: 16, max: 64, min: 0, sliderValueKind: "discrete", step: 1, type: "slider", variant: "discrete", }, }, multiple: true, target: "test.files", type: "fileDrop", variant: "collection-actions", }), ), ).toThrow( 'Toolcraft control validation [slider-marker-budget] for target "test.files" itemControls.strength: variant "discrete" has 65 positions; maximum is 32. Keep sliderValueKind "discrete" and step, but use variant "continuous" or another built-in control.', ); }); it("does not inspect itemControl on an unrelated top-level control", () => { const control = { applicability: { mode: "always" as const }, defaultValue: "Title", itemControl: { defaultValue: 16, max: 64, min: 0, sliderValueKind: "discrete", step: 1, type: "slider", variant: "discrete", }, target: "test.title", type: "text" as const, }; const resolved = getTestedControl( // @ts-expect-error Runtime normalization must ignore unrelated itemControl data. defineToolcraft(createAppSchema(control)), ); expect(resolved?.itemControl?.markerCount).toBeUndefined(); }); });