import { describe, expect, it } from "vitest"; import { createTimelineSchema } from "../react/timeline/timeline-panel-test-utils"; import type { ResolvedToolcraftAppSchema } from "../schema/resolved-app-schema"; import { decodeToolcraftCollectionItemControlAddress, getToolcraftCollectionItemControlAddress, toolcraftCollectionItemControlAddressPrefix, } from "./collection-control-address"; import { createToolcraftState } from "./create-template-state"; import { toolcraftReducer } from "../composition/public-state"; function createState() { const base = createTimelineSchema(); const controls = { sections: [ { controls: { notes: { applicability: { mode: "always", origin: "explicit" }, defaultValue: [], hardMaxItems: 2, itemControls: { color: { defaultValue: "#14b8a6", label: "Color", type: "color", }, opacity: { defaultValue: 60, keyframeable: true, label: "Opacity", max: 100, min: 0, type: "slider", }, position: { defaultValue: { x: 0, y: 0 }, keyframeable: true, label: "Position", type: "vector", }, }, minItems: 0, selectionTarget: "storyboard.selectedNote", target: "storyboard.notes", type: "collectionActions", }, }, id: "notes", }, ], title: "Controls", } satisfies NonNullable; const schema: ResolvedToolcraftAppSchema = { ...base, panels: { ...base.panels, controls }, }; return createToolcraftState(schema); } describe("Toolcraft collection item address codec", () => { it("round-trips Unicode targets without collisions and rejects noncanonical input", () => { const address = getToolcraftCollectionItemControlAddress( "storyboard.注釈", 12, "position:x", ); expect(address.startsWith(toolcraftCollectionItemControlAddressPrefix)).toBe( true, ); expect(decodeToolcraftCollectionItemControlAddress(address)).toEqual({ collectionTarget: "storyboard.注釈", fieldId: "position:x", index: 12, }); expect( decodeToolcraftCollectionItemControlAddress( address.replace(":12:", ":012:"), ), ).toBeNull(); expect(decodeToolcraftCollectionItemControlAddress(`${address}:tail`)).toBeNull(); }); it("rejects non-well-formed Unicode before UTF-8 address encoding", () => { expect(() => getToolcraftCollectionItemControlAddress( "storyboard.\uD800", 0, "field.opacity", ), ).toThrow(/well-formed Unicode/u); expect(() => getToolcraftCollectionItemControlAddress( "storyboard.notes", 0, "field.\uDC00", ), ).toThrow(/well-formed Unicode/u); }); }); describe("Toolcraft collection commands", () => { it.each([ ["authored itemControl default", { defaultValue: "#123456", type: "color" }, undefined, "#123456"], ["legacy itemDefaultValue", { type: "color" }, "#abcdef", "#ABCDEF"], ["legacy itemDefaultValue precedence", { defaultValue: "#123456", type: "color" }, "#abcdef", "#ABCDEF"], ["legacy implicit color without itemControl", undefined, undefined, "#C1FF00"], ["implicit color", { type: "color" }, undefined, "#C1FF00"], ["implicit color opacity", { type: "colorOpacity" }, undefined, { hex: "#C1FF00", opacity: 100 }], ["implicit checkbox", { type: "checkbox" }, undefined, false], ["implicit font picker", { type: "fontPicker" }, undefined, { color: "#FFFFFF", fontId: "inter", fontSize: 16, fontWeight: "400", letterSpacing: "normal", lineHeight: "normal", opacity: 100, textCase: "original", }], ["implicit range input", { type: "rangeInput" }, undefined, { end: "100%", start: "0%" }], ["implicit range slider", { max: 80, min: 20, type: "rangeSlider" }, undefined, [20, 80]], ["implicit select", { options: [{ label: "Soft", value: "soft" }], type: "select" }, undefined, "soft"], ["implicit segmented", { options: [{ label: "Soft", value: "soft" }], type: "segmented" }, undefined, "soft"], ["implicit slider", { min: 5, type: "slider" }, undefined, 5], ["implicit switch", { type: "switch" }, undefined, false], ["implicit text", { type: "text" }, undefined, ""], ["implicit vector", { type: "vector" }, undefined, { x: 0, y: 0 }], ])("adds the canonical scalar fallback for %s", (_label, itemControl, itemDefaultValue, expected) => { const base = createTimelineSchema(); const schema = { ...base, panels: { ...base.panels, controls: { sections: [ { controls: { items: { applicability: { mode: "always", origin: "explicit" }, defaultValue: [], ...(itemControl === undefined ? {} : { itemControl }), ...(itemDefaultValue === undefined ? {} : { itemDefaultValue }), target: "collection.items", type: "collectionActions", }, }, id: "items", }, ], title: "Controls", }, }, } as ResolvedToolcraftAppSchema; const added = toolcraftReducer(createToolcraftState(schema), { target: "collection.items", type: "controls.addCollectionItem", }); expect(added.values["collection.items"]).toEqual([expected]); }); it("adds canonical defaults, selects, edits, removes, and restores atomically", () => { const initial = createState(); const added = toolcraftReducer(initial, { label: "Add note", target: "storyboard.notes", type: "controls.addCollectionItem", }); expect(added.values).toMatchObject({ "storyboard.notes": [ { color: "#14B8A6", opacity: 60, position: { x: 0, y: 0 } }, ], "storyboard.selectedNote": 0, }); const changed = toolcraftReducer(added, { fieldId: "opacity", itemIndex: 0, label: "Note 1 Opacity", target: "storyboard.notes", type: "controls.setCollectionItemField", value: 42, }); expect(changed.values["storyboard.notes"]).toEqual([ { color: "#14B8A6", opacity: 42, position: { x: 0, y: 0 } }, ]); const removed = toolcraftReducer(changed, { target: "storyboard.notes", type: "controls.removeCollectionItem", }); expect(removed.values["storyboard.notes"]).toEqual([]); expect(removed.values["storyboard.selectedNote"]).toBeNull(); const restored = toolcraftReducer(removed, { type: "history.undo" }); expect(restored.values["storyboard.notes"]).toEqual( changed.values["storyboard.notes"], ); expect(restored.values["storyboard.selectedNote"]).toBe(0); }); it("fails closed for invalid indices/fields and keeps explicit selection nonhistorical", () => { const added = toolcraftReducer(createState(), { target: "storyboard.notes", type: "controls.addCollectionItem", }); const undoCount = added.history.undo.length; const invalid = toolcraftReducer(added, { fieldId: "missing", itemIndex: 0, target: "storyboard.notes", type: "controls.setCollectionItemField", value: 1, }); expect(invalid).toBe(added); const selected = toolcraftReducer(added, { itemIndex: null, target: "storyboard.notes", type: "controls.selectCollectionItem", }); expect(selected.values["storyboard.selectedNote"]).toBeNull(); expect(selected.history.undo).toHaveLength(undoCount); expect( toolcraftReducer(selected, { itemIndex: 2, target: "storyboard.notes", type: "controls.selectCollectionItem", }), ).toBe(selected); }); it("updates a selected-time nested keyframe in the same history transaction", () => { const base = toolcraftReducer(createState(), { target: "storyboard.notes", type: "controls.addCollectionItem", }); const address = getToolcraftCollectionItemControlAddress( "storyboard.notes", 0, "opacity", ); const seeded = toolcraftReducer(base, { controlId: address, controlLabel: "Note 1 Opacity", timeSeconds: 3, type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); const undoCount = seeded.history.undo.length; const changed = toolcraftReducer(seeded, { fieldId: "opacity", itemIndex: 0, target: "storyboard.notes", type: "controls.setCollectionItemField", value: 25, }); expect(changed.history.undo).toHaveLength(undoCount + 1); expect( changed.timeline.keyframeGroups[0]?.keyframes.find( (keyframe) => keyframe.timeSeconds === 3, )?.value, ).toBe(25); expect(changed.values["storyboard.notes"]).toEqual([ { color: "#14B8A6", opacity: 25, position: { x: 0, y: 0 } }, ]); }); it("formats structured nested keyframes through control semantics", () => { const base = createTimelineSchema(); const schema = { ...base, panels: { ...base.panels, controls: { sections: [ { controls: { items: { applicability: { mode: "always", origin: "explicit" }, defaultValue: [{ mode: "soft", opacity: 60, position: { x: 0, y: 0 } }], itemControls: { mode: { defaultValue: "soft", keyframeable: true, options: [ { label: "Soft", value: "soft" }, { label: "Hard", value: "hard" }, ], type: "select", }, opacity: { defaultValue: 60, keyframeable: true, max: 100, min: 0, type: "slider", unit: "%", }, position: { defaultValue: { x: 0, y: 0 }, keyframeable: true, type: "vector", }, }, target: "collection.items", type: "collectionActions", }, }, id: "items", }, ], title: "Controls", }, }, } as ResolvedToolcraftAppSchema; let state = createToolcraftState(schema); const cases = [ ["position", { x: 4, y: 5 }, "4, 5"], ["opacity", 25, "25%"], ["mode", "hard", "Hard"], ] as const; for (const [fieldId, value, expectedLabel] of cases) { const controlId = getToolcraftCollectionItemControlAddress( "collection.items", 0, fieldId, ); state = toolcraftReducer(state, { controlId, controlLabel: fieldId, type: "timeline.toggleControlKeyframes", value, valueLabel: "seed", }); state = toolcraftReducer(state, { fieldId, itemIndex: 0, target: "collection.items", type: "controls.setCollectionItemField", value, }); expect( state.timeline.keyframeGroups.find( (group) => group.controlId === controlId, )?.keyframes[0]?.valueLabel, ).toBe(expectedLabel); } }); it("rejects live nested tracks for non-keyframeable or out-of-range fields", () => { const state = toolcraftReducer(createState(), { target: "storyboard.notes", type: "controls.addCollectionItem", }); for (const address of [ getToolcraftCollectionItemControlAddress( "storyboard.notes", 0, "color", ), getToolcraftCollectionItemControlAddress( "storyboard.notes", 3, "opacity", ), ]) { expect( toolcraftReducer(state, { controlId: address, controlLabel: "Invalid nested field", type: "timeline.toggleControlKeyframes", value: 50, valueLabel: "50", }), ).toBe(state); } }); it("preserves stable tracks on append, prunes only a removed suffix, and prunes all on replacement", () => { const one = toolcraftReducer(createState(), { target: "storyboard.notes", type: "controls.addCollectionItem", }); const two = toolcraftReducer(one, { target: "storyboard.notes", type: "controls.addCollectionItem", }); const firstAddress = getToolcraftCollectionItemControlAddress( "storyboard.notes", 0, "opacity", ); const secondAddress = getToolcraftCollectionItemControlAddress( "storyboard.notes", 1, "opacity", ); const firstTrack = toolcraftReducer(two, { controlId: firstAddress, controlLabel: "First opacity", type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); const bothTracks = toolcraftReducer(firstTrack, { controlId: secondAddress, controlLabel: "Second opacity", type: "timeline.toggleControlKeyframes", value: 60, valueLabel: "60", }); const removed = toolcraftReducer(bothTracks, { target: "storyboard.notes", type: "controls.removeCollectionItem", }); expect(removed.timeline.keyframeGroups.map((group) => group.controlId)).toEqual([ firstAddress, ]); const replaced = toolcraftReducer(removed, { target: "storyboard.notes", type: "controls.setValue", value: removed.values["storyboard.notes"], }); expect(replaced.timeline.keyframeGroups).toEqual([]); expect(replaced.values["storyboard.selectedNote"]).toBe(0); const restored = toolcraftReducer(replaced, { type: "history.undo" }); expect(restored.timeline.keyframeGroups[0]?.controlId).toBe(firstAddress); expect(restored.values["storyboard.selectedNote"]).toBe(0); }); it("applies a complete imported collection batch and selection normalization atomically", () => { const seeded = toolcraftReducer(createState(), { target: "storyboard.notes", type: "controls.addCollectionItem", }); const imported = toolcraftReducer(seeded, { label: "Import settings", type: "controls.apply", values: { "storyboard.notes": [ { color: "#FFFFFF", opacity: 45, position: { x: 4, y: 5 } }, ], "storyboard.selectedNote": 0, }, }); expect(imported.history.undo).toHaveLength(seeded.history.undo.length + 1); expect(imported.values["storyboard.notes"]).toEqual([ { color: "#FFFFFF", opacity: 45, position: { x: 4, y: 5 } }, ]); expect(imported.values["storyboard.selectedNote"]).toBe(0); const invalidSelection = toolcraftReducer(imported, { type: "controls.apply", values: { "storyboard.notes": imported.values["storyboard.notes"], "storyboard.selectedNote": 4, }, }); expect(invalidSelection.values["storyboard.selectedNote"]).toBeNull(); }); });