@nodaro/prompts 1.9.0 → 1.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/prompts",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Nodaro's prompt-engineering layer — person/picker catalogs with prompt hints, identity-lock clauses, entity prompt builders, brand presets, and prompt/reference assembly shared by the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
@@ -20,7 +20,7 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "@nodaro/shared": "^2.13.0"
23
+ "@nodaro/shared": "^2.16.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "tsup": "^8.5.0",
@@ -3,6 +3,10 @@ import { assembleImageInput } from "../assemble-image-input.js"
3
3
  import { buildImagePrompt } from "../prompt-builder.js"
4
4
  import { getFramingPromptHint } from "../framing.js"
5
5
  import { getLightingPromptHint } from "../lighting.js"
6
+ import { getLensPromptHint } from "../lens.js"
7
+ import { getCameraFormatPromptHint } from "../camera-format.js"
8
+ import { getStylePromptHint } from "../style.js"
9
+ import { buildMoodHints } from "../mood.js"
6
10
  import type { ConnectedReference } from "@nodaro/shared"
7
11
 
8
12
  /**
@@ -11,9 +15,9 @@ import type { ConnectedReference } from "@nodaro/shared"
11
15
  * `generate-image` assembly through it. These tests pin BOTH layers:
12
16
  * (a) the id-based composition (direction / structured) — ported from
13
17
  * Studio's `assembly.test.ts` as the oracle, and
14
- * (b) the BY-CONSTRUCTION PARITY the caller refactor relies on: with no
15
- * direction/structured, the wrapper === the old inline `buildImagePrompt`
16
- * call + empty-check, byte-for-byte.
18
+ * (b) the BY-CONSTRUCTION PARITY the caller refactor relies on: for a node
19
+ * that carries no direction/structured, the wrapper === the old inline
20
+ * `buildImagePrompt` call + empty-check, byte-for-byte.
17
21
  */
18
22
 
19
23
  // flux-2-max supports reference images (used to assert refs survive the gate).
@@ -93,6 +97,92 @@ describe("assembleImageInput — id-based composition (Studio oracle)", () => {
93
97
  })
94
98
  expect(result.prompt).toBe("a portrait. Subject: 30 years old, woman, calm expression.")
95
99
  })
100
+
101
+ // ── The direction registry (the fold moved into `direction-registry.ts`) ──
102
+
103
+ it("returns the prompt VERBATIM and UNTRIMMED for a direction that renders nothing", () => {
104
+ // The no-op branch is what the platform-caller parity contract rests on: an
105
+ // empty (or all-empty-valued) `direction` must not trip the join, or the
106
+ // prompt would silently get trimmed.
107
+ for (const direction of [{}, { style: "" }, { mood: [] }, { style: "__no_such_style__" }]) {
108
+ const result = assembleImageInput({
109
+ userPrompt: " a knight \n",
110
+ provider: REF_PROVIDER,
111
+ direction,
112
+ })
113
+ expect(result.prompt).toBe(" a knight \n")
114
+ }
115
+ })
116
+
117
+ it("folds a registry key that predates no legacy field (style) end to end", () => {
118
+ const result = assembleImageInput({
119
+ userPrompt: "a knight",
120
+ provider: REF_PROVIDER,
121
+ direction: { style: "anime" },
122
+ })
123
+ expect(result.prompt).toBe(`a knight. ${getStylePromptHint("anime")}`)
124
+ })
125
+
126
+ it("blends a multi-pick dimension into ONE clause", () => {
127
+ const blended = buildMoodHints({ mood: ["happy", "joyful"] }, "full")
128
+ expect(blended).toHaveLength(1)
129
+ const result = assembleImageInput({
130
+ userPrompt: "a knight",
131
+ provider: REF_PROVIDER,
132
+ direction: { mood: ["happy", "joyful"] },
133
+ })
134
+ expect(result.prompt).toBe(`a knight. ${blended[0]}`)
135
+ })
136
+
137
+ it("folds in TABLE order, not the caller's object-literal order", () => {
138
+ const result = assembleImageInput({
139
+ userPrompt: "a knight",
140
+ provider: REF_PROVIDER,
141
+ direction: { style: "anime", shotSize: "wide-shot" },
142
+ })
143
+ expect(result.prompt).toBe(
144
+ `a knight. ${getFramingPromptHint("wide-shot")}. ${getStylePromptHint("anime")}`,
145
+ )
146
+ })
147
+
148
+ it("keeps the five pre-registry keys byte-identical to the old inlined fold", () => {
149
+ const direction = {
150
+ framingId: "wide-shot",
151
+ framingAngleId: "low-angle",
152
+ lightingId: "golden-hour",
153
+ lensId: "wide-24mm",
154
+ cameraFormatId: "16mm-film",
155
+ }
156
+ const result = assembleImageInput({
157
+ userPrompt: "a knight",
158
+ provider: REF_PROVIDER,
159
+ direction,
160
+ })
161
+ // The exact string the pre-registry `composePromptText` produced: the same
162
+ // five clauses, in the same order, joined with the same ". ".
163
+ expect(result.prompt).toBe(
164
+ [
165
+ "a knight",
166
+ getFramingPromptHint("wide-shot"),
167
+ getFramingPromptHint("low-angle"),
168
+ getLightingPromptHint("golden-hour"),
169
+ getLensPromptHint("wide-24mm"),
170
+ getCameraFormatPromptHint("16mm-film"),
171
+ ].join(". "),
172
+ )
173
+ })
174
+
175
+ it("keeps the structured fragment LAST, after every direction clause", () => {
176
+ const result = assembleImageInput({
177
+ userPrompt: "a portrait",
178
+ provider: REF_PROVIDER,
179
+ direction: { style: "anime" },
180
+ structured: { person: { age: 30, gender: "woman", expression: "calm" } },
181
+ })
182
+ expect(result.prompt).toBe(
183
+ `a portrait. ${getStylePromptHint("anime")}. Subject: 30 years old, woman, calm expression.`,
184
+ )
185
+ })
96
186
  })
97
187
 
98
188
  describe("assembleImageInput — empty-prompt throw (opt-in)", () => {
@@ -0,0 +1,301 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { composeVideoPromptText } from "../assemble-video-input.js"
3
+ import { directionFieldsForSurface } from "../direction-registry.js"
4
+ import { getStylePromptHint, getStyleTerm } from "../style.js"
5
+ import { getTransitionPromptHint, getTransitionTerm } from "../transitions.js"
6
+ import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
7
+ import { getFramingPromptHint } from "../framing.js"
8
+ import { getLightingPromptHint } from "../lighting.js"
9
+ import { buildMoodHints } from "../mood.js"
10
+ import { buildAestheticHints } from "../aesthetic.js"
11
+ import { buildAtmosphereHints } from "../atmosphere.js"
12
+ import { buildPhotographerHints } from "../photographer.js"
13
+ import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
14
+
15
+ /**
16
+ * `composeVideoPromptText` is the video route's ONLY prompt-composition step,
17
+ * so two contracts matter here above everything else:
18
+ *
19
+ * 1. THE NO-OP CONTRACT — with no direction the caller's prompt comes back
20
+ * verbatim and untrimmed, `undefined` included. This is the local
21
+ * restatement of the route-level byte-parity oracle ("backward-compatible:
22
+ * no connectedReferences → prompt + flat refs pass through unchanged" in
23
+ * `backend/src/routes/__tests__/generate-video.test.ts`), and it is what
24
+ * makes this whole leg land dark.
25
+ * 2. THE VERBOSITY POLICY — look dimensions render their full clause, motion
26
+ * dimensions their compact professional term. That split moved from the
27
+ * client to the platform, so it is pinned in both directions.
28
+ *
29
+ * Real catalog ids throughout: every `get*PromptHint` returns `""` on a miss,
30
+ * so a made-up id would make most assertions vacuously pass.
31
+ */
32
+
33
+ // ── Real ids, one per dimension used below ──────────────────────────────────
34
+ const STYLE = "cinematic" // look
35
+ const TRANSITION = "cross-dissolve" // motion
36
+ const CAMERA_MOTION = "handheld" // motion
37
+ const SHOT_SIZE = "wide-shot" // look, framing catalog
38
+ const TIME_OF_DAY = "dawn" // look, lighting catalog (time-of-day category)
39
+ const LIGHTING_STYLE = "three-point" // look, lighting catalog (style category)
40
+ const PHOTOGRAPHER = "tim-walker" // IMAGE-ONLY dimension
41
+ const NO_SUCH_ID = "__no_such_id__"
42
+
43
+ describe("composeVideoPromptText — the no-op contract", () => {
44
+ it("returns a prompt verbatim when no direction is passed", () => {
45
+ expect(composeVideoPromptText("a knight rides at dusk", undefined)).toBe(
46
+ "a knight rides at dusk",
47
+ )
48
+ })
49
+
50
+ it("returns a whitespace-only prompt verbatim and UNTRIMMED", () => {
51
+ expect(composeVideoPromptText(" \n", undefined)).toBe(" \n")
52
+ })
53
+
54
+ it("preserves `undefined` (the video prompt is optional)", () => {
55
+ expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
56
+ })
57
+
58
+ it("treats an empty direction object as no direction", () => {
59
+ expect(composeVideoPromptText("a knight", {})).toBe("a knight")
60
+ expect(composeVideoPromptText(undefined, {})).toBeUndefined()
61
+ })
62
+ })
63
+
64
+ describe("composeVideoPromptText — the verbosity policy", () => {
65
+ it("renders a LOOK dimension as its full clause", () => {
66
+ expect(composeVideoPromptText("a knight", { style: STYLE })).toBe(
67
+ `a knight. ${getStylePromptHint(STYLE)}`,
68
+ )
69
+ })
70
+
71
+ it("renders a MOTION dimension as its compact term, not its full hint", () => {
72
+ const out = composeVideoPromptText("a knight", { transition: TRANSITION })
73
+ expect(out).toBe(`a knight. ${getTransitionTerm(TRANSITION)}`)
74
+ expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
75
+ })
76
+
77
+ it("applies both halves of the split policy in ONE fold", () => {
78
+ const out = composeVideoPromptText("a knight", {
79
+ style: STYLE,
80
+ transition: TRANSITION,
81
+ })
82
+ expect(out).toContain(getStylePromptHint(STYLE))
83
+ expect(out).toContain(getTransitionTerm(TRANSITION))
84
+ expect(out).not.toContain(getTransitionPromptHint(TRANSITION))
85
+ })
86
+
87
+ it("honors a whole-fold `hintMode` override in both directions", () => {
88
+ // "full" promotes the motion family to its full clause…
89
+ expect(
90
+ composeVideoPromptText("a knight", { transition: TRANSITION }, undefined, {
91
+ hintMode: "full",
92
+ }),
93
+ ).toBe(`a knight. ${getTransitionPromptHint(TRANSITION)}`)
94
+ // …and "compact" demotes the look family to its term.
95
+ expect(
96
+ composeVideoPromptText("a knight", { style: STYLE }, undefined, {
97
+ hintMode: "compact",
98
+ }),
99
+ ).toBe(`a knight. ${getStyleTerm(STYLE)}`)
100
+ })
101
+ })
102
+
103
+ describe("composeVideoPromptText — ordering", () => {
104
+ it("puts camera motion first (the order Studio and the orchestrator both emit)", () => {
105
+ const out = composeVideoPromptText("a knight", {
106
+ style: STYLE,
107
+ cameraMotion: CAMERA_MOTION,
108
+ })!
109
+ expect(out.indexOf(getCameraMotionTerm(CAMERA_MOTION))).toBeLessThan(
110
+ out.indexOf(getStylePromptHint(STYLE)),
111
+ )
112
+ // Compact motion again — the camera-motion row is `family: "motion"`.
113
+ expect(out).not.toContain(getCameraMotionPromptHint(CAMERA_MOTION))
114
+ })
115
+
116
+ it("folds in TABLE order, not the caller's object order", () => {
117
+ // `shotSize` (row 2) precedes `style` (row 22) however the object is written.
118
+ const out = composeVideoPromptText("a knight", {
119
+ style: STYLE,
120
+ shotSize: SHOT_SIZE,
121
+ })!
122
+ expect(out.indexOf(getFramingPromptHint(SHOT_SIZE))).toBeLessThan(
123
+ out.indexOf(getStylePromptHint(STYLE)),
124
+ )
125
+ })
126
+
127
+ it("appends the structured fragment AFTER every direction hint", () => {
128
+ const structured = { mood: "wistful" }
129
+ const fragment = renderStructuredFields(structured)
130
+ expect(fragment.length).toBeGreaterThan(0)
131
+ const out = composeVideoPromptText("a knight", { style: STYLE }, structured)!
132
+ expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}. ${fragment}`)
133
+ })
134
+ })
135
+
136
+ describe("composeVideoPromptText — multi-pick doctrine", () => {
137
+ it("BLENDS two moods into ONE clause (not a per-id loop)", () => {
138
+ const blended = buildMoodHints({ mood: ["happy", "serene"] }, "full")
139
+ expect(blended).toHaveLength(1)
140
+ expect(composeVideoPromptText("a knight", { mood: ["happy", "serene"] })).toBe(
141
+ `a knight. ${blended[0]}`,
142
+ )
143
+ })
144
+
145
+ it("BLENDS two aesthetics into ONE clause", () => {
146
+ const blended = buildAestheticHints(["y2k", "cottagecore"], "full")
147
+ expect(blended.length).toBeGreaterThan(0)
148
+ expect(
149
+ composeVideoPromptText("a knight", { aesthetic: ["y2k", "cottagecore"] }),
150
+ ).toBe(`a knight. ${blended}`)
151
+ })
152
+
153
+ it("slices an over-cap array to the dimension's maxPicks (atmosphere = 2)", () => {
154
+ const out = composeVideoPromptText("a knight", {
155
+ atmosphere: ["clear", "cloudy", "overcast"],
156
+ })!
157
+ const kept = buildAtmosphereHints(["clear", "cloudy"], "full")
158
+ expect(kept).toHaveLength(2)
159
+ expect(out).toBe(`a knight. ${kept.join(". ")}`)
160
+ expect(out).not.toContain(buildAtmosphereHints("overcast", "full")[0])
161
+ })
162
+
163
+ it("accepts an ARRAY on a single-pick key and keeps the first id", () => {
164
+ // The legacy `V2LookPicker` shape: a single-pick dimension that stored an
165
+ // array. Must degrade to one hint, never throw and never drop the key.
166
+ const out = composeVideoPromptText("a knight", { style: [STYLE, "anime"] })
167
+ expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}`)
168
+ })
169
+ })
170
+
171
+ describe("composeVideoPromptText — tolerance", () => {
172
+ it("skips an unknown id and leaves the prompt verbatim (no dangling '. ')", () => {
173
+ expect(composeVideoPromptText("a knight", { style: NO_SUCH_ID })).toBe("a knight")
174
+ })
175
+
176
+ it("skips an IMAGE-ONLY dimension sent to a video run", () => {
177
+ // `photographer` is accepted on the wire (surface is a render concern, not
178
+ // a wire concern) and simply contributes nothing here.
179
+ expect(buildPhotographerHints(PHOTOGRAPHER, "full").length).toBeGreaterThan(0)
180
+ expect(composeVideoPromptText("a knight", { photographer: PHOTOGRAPHER })).toBe(
181
+ "a knight",
182
+ )
183
+ })
184
+
185
+ it("skips an unknown wire key entirely", () => {
186
+ expect(
187
+ composeVideoPromptText("a knight", { __not_a_dimension__: "x" } as never),
188
+ ).toBe("a knight")
189
+ })
190
+ })
191
+
192
+ describe("composeVideoPromptText — an empty or absent body", () => {
193
+ it("returns the hints alone for an empty prompt (never a leading '. ')", () => {
194
+ expect(composeVideoPromptText("", { style: STYLE })).toBe(getStylePromptHint(STYLE))
195
+ })
196
+
197
+ it("returns the hints alone for an ABSENT prompt", () => {
198
+ expect(composeVideoPromptText(undefined, { style: STYLE })).toBe(
199
+ getStylePromptHint(STYLE),
200
+ )
201
+ })
202
+ })
203
+
204
+ describe("composeVideoPromptText — the dedupe invariant", () => {
205
+ // The five legacy keys address a WHOLE catalog, so they are not aliases of
206
+ // their canonical counterparts. Overlap is resolved by exact-clause dedupe,
207
+ // which suppresses a repeated clause without suppressing a different id.
208
+ it("emits ONE clause when a legacy and a canonical key carry the SAME id", () => {
209
+ expect(
210
+ composeVideoPromptText("a knight", { framingId: SHOT_SIZE, shotSize: SHOT_SIZE }),
211
+ ).toBe(`a knight. ${getFramingPromptHint(SHOT_SIZE)}`)
212
+ })
213
+
214
+ it("emits BOTH clauses for two DIFFERENT ids of one catalog", () => {
215
+ // The case an alias table would have wrongly collapsed: `lightingId` is
216
+ // whole-catalog, so a time-of-day pick beside a lighting-style pick is two
217
+ // legitimate selections.
218
+ const out = composeVideoPromptText("a knight", {
219
+ lightingStyle: LIGHTING_STYLE,
220
+ lightingId: TIME_OF_DAY,
221
+ })
222
+ expect(out).toBe(
223
+ `a knight. ${getLightingPromptHint(LIGHTING_STYLE)}. ${getLightingPromptHint(TIME_OF_DAY)}`,
224
+ )
225
+ })
226
+ })
227
+
228
+ /**
229
+ * ORDER TOTALITY — every video-surface dimension, folded in one call.
230
+ *
231
+ * The fixture is keyed in `directionFieldsForSurface("video")` order and pinned
232
+ * against it, so adding, removing or reordering a video row fails HERE as well
233
+ * as in the registry test. Each id was chosen to render a clause distinct from
234
+ * every other row's, so the dedupe pass cannot mask a mis-ordering.
235
+ */
236
+ const EVERY_VIDEO_DIMENSION: Record<string, string> = {
237
+ cameraMotion: "static",
238
+ shotSize: "extreme-wide-shot",
239
+ angle: "eye-level",
240
+ coverage: "single",
241
+ composition: "rule-of-thirds",
242
+ vantage: "front-on",
243
+ pose: "standing-upright",
244
+ compositionEffect: "bursting-through-frame",
245
+ cameraFormat: "35mm-film",
246
+ lens: "ultra-wide-14mm",
247
+ timeOfDay: "dawn",
248
+ lightingStyle: "three-point",
249
+ lightingDirection: "front",
250
+ lightingRatio: "ratio-1-1",
251
+ colorTemperature: "temp-2700k",
252
+ colorLook: "warm",
253
+ atmosphere: "clear",
254
+ style: "3d-render",
255
+ mood: "happy",
256
+ aesthetic: "y2k",
257
+ setting: "coffee-shop",
258
+ era: "1920s-flapper",
259
+ backdrop: "white-seamless",
260
+ actionFx: "earthquake-tremor",
261
+ temporalSpeed: "real-time",
262
+ temporalFreeze: "full-freeze",
263
+ temporalDirection: "forward",
264
+ temporalShutter: "long-exposure",
265
+ transition: "none",
266
+ loopSubject: "aurora",
267
+ framingId: "wide-shot",
268
+ framingAngleId: "medium-wide-shot",
269
+ lightingId: "sunrise",
270
+ lensId: "wide-24mm",
271
+ cameraFormatId: "16mm-film",
272
+ }
273
+
274
+ describe("composeVideoPromptText — order totality over every video dimension", () => {
275
+ it("covers exactly the video surface, in table order", () => {
276
+ expect(Object.keys(EVERY_VIDEO_DIMENSION)).toEqual(
277
+ directionFieldsForSurface("video").map((f) => f.key),
278
+ )
279
+ })
280
+
281
+ it("resolves every fixture id to a real clause", () => {
282
+ for (const [key, id] of Object.entries(EVERY_VIDEO_DIMENSION)) {
283
+ expect(composeVideoPromptText("", { [key]: id }), `${key}=${id}`).not.toBe("")
284
+ }
285
+ })
286
+
287
+ it("folds all 35 dimensions in registry order, one clause each", () => {
288
+ // Per-dimension renders, composed in isolation through the same public
289
+ // entry point, then concatenated in table order: the whole fold must equal
290
+ // exactly that. Any reorder, drop or duplicate shows up as a diff.
291
+ const expected = Object.entries(EVERY_VIDEO_DIMENSION).map(
292
+ ([key, id]) => composeVideoPromptText("", { [key]: id })!,
293
+ )
294
+ expect(new Set(expected).size, "fixture ids must render distinct clauses").toBe(
295
+ expected.length,
296
+ )
297
+ expect(composeVideoPromptText("a knight", EVERY_VIDEO_DIMENSION)).toBe(
298
+ ["a knight", ...expected].join(". "),
299
+ )
300
+ })
301
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The character-fx node's three timing parameters (position / duration /
3
+ * intensity) are enumerable catalogs, not free values, so a consumer that can
4
+ * only send ids — Studio, the SDK, MCP — can offer them without composing any
5
+ * prompt text of its own. This is the character-fx twin of
6
+ * `transition-timing-catalogs.test.ts`; the two nodes share ids and shape but
7
+ * NOT wording, and nothing here may couple them.
8
+ *
9
+ * Three properties matter, and each is easy to lose silently:
10
+ *
11
+ * 1. The clause the composer INJECTS and the hint the catalog ADVERTISES are
12
+ * the same string. They are derived from one array in `character-fx.ts`,
13
+ * and these tests fail the moment someone re-introduces a second copy.
14
+ * 2. The prompt text is exactly what shipped before it was enumerable. The
15
+ * parameter-hint golden covers only three of the twelve clauses, so the
16
+ * twelve literals are pinned here: a change is a deliberate diff, never a
17
+ * side effect.
18
+ * 3. The dimensions survive the wire projection — `projectAllCatalogs()` is
19
+ * what an id-only client actually reads, and a green in-memory catalog
20
+ * proves nothing about it.
21
+ */
22
+ import { describe, it, expect } from "vitest"
23
+ import {
24
+ CHARACTER_FX_POSITIONS,
25
+ CHARACTER_FX_DURATIONS,
26
+ CHARACTER_FX_INTENSITIES,
27
+ composeCharacterFxHintFromConnections,
28
+ } from "../character-fx.js"
29
+ import { TRANSITION_POSITIONS, TRANSITION_DURATIONS } from "../transitions.js"
30
+ import { PICKER_CATALOGS, projectPickerCatalog, projectAllCatalogs } from "../picker-catalogs.js"
31
+
32
+ const DIMENSIONS = [
33
+ ["position", CHARACTER_FX_POSITIONS],
34
+ ["duration", CHARACTER_FX_DURATIONS],
35
+ ["intensity", CHARACTER_FX_INTENSITIES],
36
+ ] as const
37
+
38
+ const characterFxCatalog = PICKER_CATALOGS.find((c) => c.nodeType === "character-fx")!
39
+
40
+ describe("character-fx timing catalogs", () => {
41
+ // The `CharacterFxPosition` / `Duration` / `Intensity` unions are DERIVED
42
+ // from these arrays, so nothing at the type level can pin the values
43
+ // themselves. These are the values the editor dropdowns, the node data, and
44
+ // every stored workflow already use: changing one is a data migration, not a
45
+ // rename.
46
+ it("spells exactly the ids every stored workflow already uses", () => {
47
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.id)).toEqual([
48
+ "auto", "start", "middle", "end", "full",
49
+ ])
50
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.id)).toEqual([
51
+ "auto", "instant", "short", "medium", "long",
52
+ ])
53
+ expect(CHARACTER_FX_INTENSITIES.map((o) => o.id)).toEqual([
54
+ "auto", "subtle", "natural", "dynamic", "crazy",
55
+ ])
56
+ })
57
+
58
+ it("injects exactly the clauses that shipped before the scales were enumerable", () => {
59
+ // Byte-for-byte the literals the hand-written clause tables carried. This
60
+ // task was exposure, not authoring: if any of these move, that is a prompt
61
+ // change for real users and must be its own deliberate diff.
62
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).toEqual([
63
+ "",
64
+ "the effect occurs at the opening of the clip",
65
+ "the effect occurs in the middle of the clip",
66
+ "the effect occurs at the end of the clip",
67
+ "the effect persists for the entire clip",
68
+ ])
69
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).toEqual([
70
+ "",
71
+ "manifesting instantaneously",
72
+ "manifesting over approximately 1 second",
73
+ "manifesting over approximately 2 seconds",
74
+ "manifesting over approximately 3 seconds",
75
+ ])
76
+ expect(CHARACTER_FX_INTENSITIES.map((o) => o.promptHint)).toEqual([
77
+ "",
78
+ "with subtle restrained energy and minimal flourish",
79
+ "with natural unhurried timing",
80
+ "with dynamic energy and assertive flourish",
81
+ "with extreme exaggerated energy, wild flourishes, and dramatic distortion",
82
+ ])
83
+ })
84
+
85
+ it("keeps its own wording — an effect manifests and persists, a transition occurs and spans", () => {
86
+ // The one constraint that matters most: these scales must never be unified
87
+ // with the transition ones. Position and duration are worded differently
88
+ // on purpose, so pointing character-fx at the transition rows fails here.
89
+ // Intensity is deliberately NOT compared: its three clauses happen to be
90
+ // identical today, and that is a coincidence, not a shared definition.
91
+ expect(CHARACTER_FX_POSITIONS.map((o) => o.promptHint)).not.toEqual(
92
+ TRANSITION_POSITIONS.map((o) => o.promptHint),
93
+ )
94
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.promptHint)).not.toEqual(
95
+ TRANSITION_DURATIONS.map((o) => o.promptHint),
96
+ )
97
+ })
98
+
99
+ it("carries the timing precision users read off the dropdown", () => {
100
+ // The editor renders straight from here (its own catalogs, not the
101
+ // transition ones), so this is the one place the numbers live.
102
+ expect(CHARACTER_FX_DURATIONS.map((o) => o.label)).toEqual([
103
+ "Auto", "Instant", "Short (~1s)", "Medium (~2s)", "Long (~3s)",
104
+ ])
105
+ })
106
+
107
+ it("gives every injecting step a compact term, so compact mode never drops it", () => {
108
+ // `catalog-terms.test.ts` enforces this globally; pinned here too because
109
+ // the platform composes timing from `promptHint` in BOTH hint modes, so a
110
+ // broken `term` would only ever surface on an external compact client.
111
+ for (const [field, options] of DIMENSIONS) {
112
+ for (const o of options.slice(1)) {
113
+ expect(o.term, `${field}/${o.id} needs a compact term`).toBeTruthy()
114
+ }
115
+ expect(options[0]!.term, `${field}/auto injects nothing`).toBe("")
116
+ }
117
+ })
118
+
119
+ it("derives a clause for every non-auto step, so the composer can't index a hole", () => {
120
+ // The regression this guards: a step present in the catalog but missing
121
+ // from the clause table made `parts.push(undefined)` render as a dangling
122
+ // ", " on a prompt that shipped to the provider. Walks the ARRAYS, not a
123
+ // hardcoded id list, so a step added to a catalog is exercised here too.
124
+ for (const [field, options] of DIMENSIONS) {
125
+ for (const o of options.slice(1)) {
126
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], {
127
+ [field]: o.id,
128
+ } as never)
129
+ expect(composed, `${field}/${o.id} composed a dangling separator`).not.toMatch(/,\s*$/)
130
+ expect(composed, `${field}/${o.id}`).not.toContain("undefined")
131
+ }
132
+ }
133
+ })
134
+
135
+ it("every scale leads with a no-op `auto` that injects nothing", () => {
136
+ for (const [field, options] of DIMENSIONS) {
137
+ expect(options[0]!.id, `${field} must lead with auto`).toBe("auto")
138
+ expect(options[0]!.promptHint, `${field}/auto must inject nothing`).toBe("")
139
+ // Every other step must actually carry a clause.
140
+ for (const o of options.slice(1)) {
141
+ expect(o.promptHint, `${field}/${o.id} needs a promptHint`).not.toBe("")
142
+ }
143
+ }
144
+ })
145
+
146
+ it("the composed clause is the catalog's own promptHint, verbatim", () => {
147
+ // One source of truth: if the composer ever grows a private copy of these
148
+ // strings, the composed sentence stops containing the advertised hint.
149
+ for (const o of CHARACTER_FX_POSITIONS.slice(1)) {
150
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { position: o.id as never })
151
+ expect(composed, `position/${o.id}`).toContain(o.promptHint)
152
+ }
153
+ for (const o of CHARACTER_FX_DURATIONS.slice(1)) {
154
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { duration: o.id as never })
155
+ expect(composed, `duration/${o.id}`).toContain(o.promptHint)
156
+ }
157
+ for (const o of CHARACTER_FX_INTENSITIES.slice(1)) {
158
+ const composed = composeCharacterFxHintFromConnections("werewolf", [], { intensity: o.id as never })
159
+ expect(composed, `intensity/${o.id}`).toContain(o.promptHint)
160
+ }
161
+ })
162
+
163
+ it("the clauses are emitted identically in compact hint mode", () => {
164
+ // Timing is composed from `promptHint` in BOTH modes — only the effect's
165
+ // base fragment swaps to its term. Pinned so a compact-mode refactor cannot
166
+ // quietly start reading `term` for the timing rows.
167
+ for (const [field, options] of DIMENSIONS) {
168
+ for (const o of options.slice(1)) {
169
+ const compact = composeCharacterFxHintFromConnections("werewolf", [], {
170
+ [field]: o.id,
171
+ } as never, "compact")
172
+ expect(compact, `${field}/${o.id} in compact mode`).toContain(o.promptHint)
173
+ }
174
+ }
175
+ })
176
+
177
+ it("`auto` adds nothing to the composed sentence", () => {
178
+ const bare = composeCharacterFxHintFromConnections("werewolf", [])
179
+ const allAuto = composeCharacterFxHintFromConnections("werewolf", [], { position: "auto", duration: "auto", intensity: "auto" })
180
+ expect(allAuto).toBe(bare)
181
+ })
182
+
183
+ it("exposes the three dimensions on the character-fx catalog", () => {
184
+ expect(characterFxCatalog.kind).toBe("single")
185
+ expect(characterFxCatalog.dimensions?.map((d) => d.field)).toEqual([
186
+ "position",
187
+ "duration",
188
+ "intensity",
189
+ ])
190
+ })
191
+
192
+ it("keeps the dimensions through the wire projection", () => {
193
+ const wire = projectPickerCatalog(characterFxCatalog, { detail: "full" })
194
+
195
+ // The 57-option effect list is untouched by the addition.
196
+ expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
197
+
198
+ expect(wire.dimensions).toHaveLength(3)
199
+ for (const [field, options] of DIMENSIONS) {
200
+ const dim = wire.dimensions?.find((d) => d.field === field)
201
+ expect(dim, `${field} must reach the wire`).toBeDefined()
202
+ expect(dim!.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
203
+ expect(dim!.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
204
+ }
205
+ })
206
+
207
+ it("field filter narrows a single-dim catalog to one secondary dimension", () => {
208
+ const wire = projectPickerCatalog(characterFxCatalog, { field: "duration" })
209
+ expect(wire.options?.length).toBe(characterFxCatalog.options?.length)
210
+ expect(wire.dimensions?.map((d) => d.field)).toEqual(["duration"])
211
+ })
212
+
213
+ describe("projectAllCatalogs — what an id-only client actually reads", () => {
214
+ // `GET /v1/catalogs` is built from this call. Both detail levels must carry
215
+ // the three dimensions with ids and labels; `full` must also carry the
216
+ // clause, and `compact` must carry the term so a thin client can inject
217
+ // without a second fetch.
218
+ for (const detail of ["compact", "full"] as const) {
219
+ it(`detail=${detail}: three dimensions with ids, labels${detail === "full" ? " and hints" : " and terms"}`, () => {
220
+ const wire = projectAllCatalogs({ detail }).find((c) => c.nodeType === "character-fx")!
221
+ expect(wire.detail).toBe(detail)
222
+ expect(wire.kind).toBe("single")
223
+ expect(wire.dimensions?.map((d) => d.field)).toEqual(["position", "duration", "intensity"])
224
+
225
+ for (const [field, options] of DIMENSIONS) {
226
+ const dim = wire.dimensions!.find((d) => d.field === field)!
227
+ expect(dim.label, `${field} label`).toBeTruthy()
228
+ expect(dim.options.map((o) => o.id)).toEqual(options.map((o) => o.id))
229
+ expect(dim.options.map((o) => o.label)).toEqual(options.map((o) => o.label))
230
+ expect(dim.options.map((o) => o.term)).toEqual(options.map((o) => o.term))
231
+ if (detail === "full") {
232
+ expect(dim.options.map((o) => o.promptHint)).toEqual(options.map((o) => o.promptHint))
233
+ } else {
234
+ for (const o of dim.options) expect(o, `${field}/${o.id} compact`).not.toHaveProperty("promptHint")
235
+ }
236
+ }
237
+ })
238
+ }
239
+ })
240
+ })