@hyperframes/studio 0.7.46 → 0.7.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- package/dist/assets/index-DoVLXke0.css +0 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Bind selected element" card for the Variables panel — the promote-a-
|
|
3
|
+
* property-to-variable gesture. Each action declares a variable whose default
|
|
4
|
+
* is the element's current value, so binding to a NEW id never changes the
|
|
5
|
+
* render. Binding to an EXISTING id instead wires the element to that
|
|
6
|
+
* variable's current default (which came from another element) — the card
|
|
7
|
+
* warns before committing, since that does change the render. The binding the
|
|
8
|
+
* runtime resolves is written as a data-var-src / data-var-text attribute, or
|
|
9
|
+
* a `<prop>: var(--id)` style.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useMemo, useState } from "react";
|
|
13
|
+
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
|
|
14
|
+
import type { DomEditSelection } from "../editor/domEditingTypes";
|
|
15
|
+
|
|
16
|
+
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
|
|
17
|
+
|
|
18
|
+
// <source> is deliberately excluded: rewriting a <source> child's src after
|
|
19
|
+
// the parent media element ran resource selection is a spec no-op.
|
|
20
|
+
const MEDIA_TAGS = new Set(["img", "video", "audio"]);
|
|
21
|
+
|
|
22
|
+
export interface BindAction {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
/** Binding channel: data-var-src / data-var-text attribute, or a style prop. */
|
|
26
|
+
kind: "src" | "text" | "style";
|
|
27
|
+
styleProp?: string;
|
|
28
|
+
suggestedId: string;
|
|
29
|
+
declaration: (id: string) => CompositionVariable;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitizeId(raw: string): string {
|
|
33
|
+
const cleaned = raw
|
|
34
|
+
.trim()
|
|
35
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "");
|
|
37
|
+
return cleaned || "variable";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* "rgb(0, 195, 255)" / "rgba(0, 195, 255, 0.4)" → "#00c3ff". Alpha is dropped
|
|
42
|
+
* (color variables are hex) — a fully transparent computed color maps to
|
|
43
|
+
* #000000, which the picker can at least display. Unrecognized formats pass
|
|
44
|
+
* through verbatim.
|
|
45
|
+
*/
|
|
46
|
+
export function rgbToHex(value: string): string {
|
|
47
|
+
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
|
|
48
|
+
if (!m) return value;
|
|
49
|
+
return `#${m
|
|
50
|
+
.slice(1, 4)
|
|
51
|
+
.map((n) => Number(n).toString(16).padStart(2, "0"))
|
|
52
|
+
.join("")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function firstFontFamily(value: string): string {
|
|
56
|
+
const first = value.split(",")[0] ?? "";
|
|
57
|
+
return first.trim().replace(/^["']|["']$/g, "") || "sans-serif";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// fallow-ignore-next-line complexity
|
|
61
|
+
export function buildBindActions(
|
|
62
|
+
selection: DomEditSelection,
|
|
63
|
+
sdkSession: Composition,
|
|
64
|
+
): BindAction[] {
|
|
65
|
+
const hfId = selection.hfId;
|
|
66
|
+
if (!hfId) return [];
|
|
67
|
+
// No snapshot = the session can't resolve this element (e.g. a sub-comp
|
|
68
|
+
// element that missed the source map) — a bind would target the wrong
|
|
69
|
+
// document or dead-end, so offer nothing.
|
|
70
|
+
const snapshot = sdkSession.getElement(hfId);
|
|
71
|
+
if (!snapshot) return [];
|
|
72
|
+
const base = sanitizeId(snapshot.attributes.id ?? selection.label ?? hfId);
|
|
73
|
+
const actions: BindAction[] = [];
|
|
74
|
+
|
|
75
|
+
const tag = selection.tagName.toLowerCase();
|
|
76
|
+
if (MEDIA_TAGS.has(tag)) {
|
|
77
|
+
const currentSrc = snapshot.attributes.src ?? "";
|
|
78
|
+
actions.push({
|
|
79
|
+
key: "src",
|
|
80
|
+
label: tag === "img" ? "Image source" : "Media source",
|
|
81
|
+
kind: "src",
|
|
82
|
+
suggestedId: base,
|
|
83
|
+
declaration: (id) =>
|
|
84
|
+
tag === "img"
|
|
85
|
+
? { id, type: "image", label: `${selection.label} image`, default: currentSrc }
|
|
86
|
+
: { id, type: "string", label: `${selection.label} source`, default: currentSrc },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Text binds only on leaf elements: the runtime preserves children, but a
|
|
91
|
+
// container's "own text" default rarely matches what the user sees, so the
|
|
92
|
+
// promote-never-changes-the-render guarantee only holds for leaves.
|
|
93
|
+
const text = (snapshot.text ?? "").trim();
|
|
94
|
+
if (text && snapshot.children.length === 0 && !selection.isCompositionHost) {
|
|
95
|
+
actions.push({
|
|
96
|
+
key: "text",
|
|
97
|
+
label: "Text",
|
|
98
|
+
kind: "text",
|
|
99
|
+
suggestedId: `${base}-text`,
|
|
100
|
+
declaration: (id) => ({
|
|
101
|
+
id,
|
|
102
|
+
type: "string",
|
|
103
|
+
label: `${selection.label} text`,
|
|
104
|
+
default: text,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (selection.capabilities.canEditStyles) {
|
|
110
|
+
const computed = selection.computedStyles;
|
|
111
|
+
actions.push(
|
|
112
|
+
{
|
|
113
|
+
key: "color",
|
|
114
|
+
label: "Text color",
|
|
115
|
+
kind: "style",
|
|
116
|
+
styleProp: "color",
|
|
117
|
+
suggestedId: `${base}-color`,
|
|
118
|
+
declaration: (id) => ({
|
|
119
|
+
id,
|
|
120
|
+
type: "color",
|
|
121
|
+
label: `${selection.label} color`,
|
|
122
|
+
default: rgbToHex(computed["color"] ?? "#000000"),
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: "background",
|
|
127
|
+
label: "Background",
|
|
128
|
+
kind: "style",
|
|
129
|
+
styleProp: "background-color",
|
|
130
|
+
suggestedId: `${base}-bg`,
|
|
131
|
+
declaration: (id) => ({
|
|
132
|
+
id,
|
|
133
|
+
type: "color",
|
|
134
|
+
label: `${selection.label} background`,
|
|
135
|
+
default: rgbToHex(computed["background-color"] ?? "#000000"),
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: "font",
|
|
140
|
+
label: "Font",
|
|
141
|
+
kind: "style",
|
|
142
|
+
styleProp: "font-family",
|
|
143
|
+
suggestedId: `${base}-font`,
|
|
144
|
+
declaration: (id) => ({
|
|
145
|
+
id,
|
|
146
|
+
type: "font",
|
|
147
|
+
label: `${selection.label} font`,
|
|
148
|
+
default: firstFontFamily(computed["font-family"] ?? "sans-serif"),
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return actions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One batched schema edit: declare (unless the id already exists) + bind. */
|
|
158
|
+
export function applyBind(
|
|
159
|
+
session: Composition,
|
|
160
|
+
hfId: string,
|
|
161
|
+
action: BindAction,
|
|
162
|
+
id: string,
|
|
163
|
+
): void {
|
|
164
|
+
session.batch(() => {
|
|
165
|
+
if (!session.getVariableDeclarations().some((d) => d.id === id)) {
|
|
166
|
+
session.declareVariable(action.declaration(id));
|
|
167
|
+
}
|
|
168
|
+
if (action.kind === "style" && action.styleProp) {
|
|
169
|
+
session.setStyle(hfId, { [action.styleProp]: `var(--${id})` });
|
|
170
|
+
} else {
|
|
171
|
+
session.setAttribute(hfId, `data-var-${action.kind}`, id);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function VariablesBindElement({
|
|
177
|
+
selection,
|
|
178
|
+
sdkSession,
|
|
179
|
+
onBind,
|
|
180
|
+
}: {
|
|
181
|
+
selection: DomEditSelection;
|
|
182
|
+
sdkSession: Composition;
|
|
183
|
+
onBind: (action: BindAction, id: string) => void;
|
|
184
|
+
}) {
|
|
185
|
+
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
186
|
+
const [idDraft, setIdDraft] = useState("");
|
|
187
|
+
const actions = useMemo(() => buildBindActions(selection, sdkSession), [selection, sdkSession]);
|
|
188
|
+
if (actions.length === 0) return null;
|
|
189
|
+
const active = actions.find((a) => a.key === activeKey) ?? null;
|
|
190
|
+
|
|
191
|
+
const trimmedId = sanitizeId(idDraft);
|
|
192
|
+
const idIsEmpty = idDraft.trim().length === 0;
|
|
193
|
+
// Binding to an id that already exists wires the element to THAT variable's
|
|
194
|
+
// existing default (declared from a different element), so the render changes
|
|
195
|
+
// silently. Surface it before the user commits rather than after.
|
|
196
|
+
const existingDecl = active
|
|
197
|
+
? sdkSession.getVariableDeclarations().find((d) => d.id === trimmedId)
|
|
198
|
+
: undefined;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="space-y-1.5 rounded-lg border border-studio-accent/30 bg-neutral-900/40 p-2">
|
|
202
|
+
<p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
|
|
203
|
+
Bind selected: <span className="normal-case text-neutral-300">{selection.label}</span>
|
|
204
|
+
</p>
|
|
205
|
+
{active ? (
|
|
206
|
+
<div className="space-y-1.5">
|
|
207
|
+
<label className="text-[9px] font-medium text-neutral-500">
|
|
208
|
+
Variable id for {active.label.toLowerCase()}
|
|
209
|
+
</label>
|
|
210
|
+
<input
|
|
211
|
+
type="text"
|
|
212
|
+
value={idDraft}
|
|
213
|
+
onChange={(e) => setIdDraft(e.target.value)}
|
|
214
|
+
onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)}
|
|
215
|
+
className={`${VARIABLES_INPUT_CLASS} font-mono`}
|
|
216
|
+
/>
|
|
217
|
+
{existingDecl && (
|
|
218
|
+
<p className="text-[9px] leading-snug text-amber-400/90">
|
|
219
|
+
"{trimmedId}" already exists. This element will use its current value
|
|
220
|
+
{existingDecl.default !== undefined && (
|
|
221
|
+
<span className="font-mono"> ({String(existingDecl.default)})</span>
|
|
222
|
+
)}
|
|
223
|
+
, not the element's own — binding won't change "{trimmedId}".
|
|
224
|
+
</p>
|
|
225
|
+
)}
|
|
226
|
+
<div className="flex items-center justify-end gap-2">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={() => setActiveKey(null)}
|
|
230
|
+
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
|
|
231
|
+
>
|
|
232
|
+
Cancel
|
|
233
|
+
</button>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
disabled={idIsEmpty}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
setActiveKey(null);
|
|
239
|
+
onBind(active, trimmedId);
|
|
240
|
+
}}
|
|
241
|
+
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
242
|
+
>
|
|
243
|
+
{existingDecl ? "Bind anyway" : "Bind"}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
<div className="flex flex-wrap gap-1.5">
|
|
249
|
+
{actions.map((action) => (
|
|
250
|
+
<button
|
|
251
|
+
key={action.key}
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => {
|
|
254
|
+
setActiveKey(action.key);
|
|
255
|
+
setIdDraft(action.suggestedId);
|
|
256
|
+
}}
|
|
257
|
+
className="h-6 rounded-md border border-neutral-800 px-2 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
|
258
|
+
>
|
|
259
|
+
{action.label} → variable
|
|
260
|
+
</button>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure form-logic guards for the Variables declaration form:
|
|
3
|
+
* mergeDeclarationEdit (unmodeled-key passthrough on same-type edits) and
|
|
4
|
+
* declarationFromDraft (per-type parsing + validation).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
mergeDeclarationEdit,
|
|
10
|
+
declarationFromDraft,
|
|
11
|
+
draftFromDeclaration,
|
|
12
|
+
EMPTY_DRAFT,
|
|
13
|
+
} from "./VariablesDeclarationForm.js";
|
|
14
|
+
import type { CompositionVariable } from "@hyperframes/core/variables";
|
|
15
|
+
|
|
16
|
+
describe("mergeDeclarationEdit", () => {
|
|
17
|
+
it("preserves unmodeled keys on a same-type edit", () => {
|
|
18
|
+
// The form owns id/label/type/default/description/min/max/step/options; every
|
|
19
|
+
// other key (source, brandRole, unit, maxLength, …) must ride through a Save.
|
|
20
|
+
const original = {
|
|
21
|
+
id: "brand",
|
|
22
|
+
type: "font",
|
|
23
|
+
label: "Brand font",
|
|
24
|
+
default: "Inter",
|
|
25
|
+
source: "brand-kit",
|
|
26
|
+
default_name: "Inter",
|
|
27
|
+
brandRole: "heading",
|
|
28
|
+
} as unknown as CompositionVariable;
|
|
29
|
+
const edited = {
|
|
30
|
+
id: "brand",
|
|
31
|
+
type: "font",
|
|
32
|
+
label: "Brand heading font",
|
|
33
|
+
default: "Inter",
|
|
34
|
+
} as CompositionVariable;
|
|
35
|
+
|
|
36
|
+
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
|
|
37
|
+
expect(merged.label).toBe("Brand heading font");
|
|
38
|
+
expect(merged.source).toBe("brand-kit");
|
|
39
|
+
expect(merged.default_name).toBe("Inter");
|
|
40
|
+
expect(merged.brandRole).toBe("heading");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("drops old type-specific metadata when the type changes", () => {
|
|
44
|
+
const original = {
|
|
45
|
+
id: "count",
|
|
46
|
+
type: "number",
|
|
47
|
+
label: "Count",
|
|
48
|
+
default: 3,
|
|
49
|
+
min: 0,
|
|
50
|
+
max: 10,
|
|
51
|
+
} as CompositionVariable;
|
|
52
|
+
const edited = {
|
|
53
|
+
id: "count",
|
|
54
|
+
type: "string",
|
|
55
|
+
label: "Count",
|
|
56
|
+
default: "3",
|
|
57
|
+
} as CompositionVariable;
|
|
58
|
+
|
|
59
|
+
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
|
|
60
|
+
expect(merged).toEqual(edited);
|
|
61
|
+
expect(merged.min).toBeUndefined();
|
|
62
|
+
expect(merged.max).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("declarationFromDraft", () => {
|
|
67
|
+
it("requires a non-empty id", () => {
|
|
68
|
+
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("parses a string variable verbatim and defaults label to id", () => {
|
|
72
|
+
const decl = declarationFromDraft({ ...EMPTY_DRAFT, id: "title", defaultRaw: "Hello" });
|
|
73
|
+
expect(decl).toEqual({ id: "title", label: "title", type: "string", default: "Hello" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects a non-numeric number default", () => {
|
|
77
|
+
expect(
|
|
78
|
+
declarationFromDraft({ ...EMPTY_DRAFT, id: "n", type: "number", defaultRaw: "abc" }),
|
|
79
|
+
).toBe("Default must be a number.");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("parses a number variable with min/max/step and drops blank constraints", () => {
|
|
83
|
+
const decl = declarationFromDraft({
|
|
84
|
+
...EMPTY_DRAFT,
|
|
85
|
+
id: "n",
|
|
86
|
+
type: "number",
|
|
87
|
+
defaultRaw: "5",
|
|
88
|
+
min: "0",
|
|
89
|
+
max: "",
|
|
90
|
+
step: "0.5",
|
|
91
|
+
});
|
|
92
|
+
expect(decl).toEqual({ id: "n", label: "n", type: "number", default: 5, min: 0, step: 0.5 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects an enum with no options and an off-list default", () => {
|
|
96
|
+
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: "e", type: "enum", optionsRaw: "" })).toBe(
|
|
97
|
+
"Enum needs at least one option (one per line, value:Label).",
|
|
98
|
+
);
|
|
99
|
+
expect(
|
|
100
|
+
declarationFromDraft({
|
|
101
|
+
...EMPTY_DRAFT,
|
|
102
|
+
id: "e",
|
|
103
|
+
type: "enum",
|
|
104
|
+
optionsRaw: "wide:Wide\ntall:Tall",
|
|
105
|
+
defaultRaw: "square",
|
|
106
|
+
}),
|
|
107
|
+
).toBe("Default must be one of the options.");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("parses a boolean from the 'true' sentinel", () => {
|
|
111
|
+
expect(
|
|
112
|
+
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "true" }),
|
|
113
|
+
).toMatchObject({
|
|
114
|
+
type: "boolean",
|
|
115
|
+
default: true,
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "" }),
|
|
119
|
+
).toMatchObject({
|
|
120
|
+
default: false,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("round-trips a declaration through draftFromDeclaration → declarationFromDraft", () => {
|
|
125
|
+
const original: CompositionVariable = {
|
|
126
|
+
id: "count",
|
|
127
|
+
type: "number",
|
|
128
|
+
label: "Count",
|
|
129
|
+
default: 3,
|
|
130
|
+
min: 0,
|
|
131
|
+
max: 10,
|
|
132
|
+
};
|
|
133
|
+
expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
|
|
134
|
+
});
|
|
135
|
+
});
|