@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.
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ getRegisteredPickerCatalogs,
4
+ type PickerCatalog,
5
+ type PickerOption,
6
+ } from "../picker-catalogs.js"
7
+
8
+ /**
9
+ * THE GUARD THAT MAKES "FOLD BEFORE THE REFERENCE RESOLVER" SAFE.
10
+ *
11
+ * `composeVideoPromptText` folds catalog text into the prompt BODY *before*
12
+ * `resolveVideoReferenceCore` runs (the look/motion description has to be
13
+ * inside the body the resolver frames, not appended after the identity
14
+ * directives). The consequence is that catalog text is then scanned by three
15
+ * passes that treat certain substrings as INPUT GRAMMAR:
16
+ *
17
+ * - the `{image:N}` / `{video:N}` / `{audio:N}` reference-slot expander,
18
+ * - the `{ref:<id>}` id-addressed reference-token pass,
19
+ * - the `@slug:N` character/named-image mention pass.
20
+ *
21
+ * A catalog whose text happened to contain one of those shapes would be
22
+ * rewritten by a pass that was never meant to see it — and, worse, could change
23
+ * the ASSEMBLED REFERENCE COUNT, which is exactly the quantity MiniMax-H3
24
+ * credit prediction reserves against. That is the one theoretical coupling
25
+ * between this text-only fold and pricing, and those three patterns are what
26
+ * close it.
27
+ *
28
+ * A fourth pattern is scanned for HYGIENE, not pricing: the resolver's OUTPUT
29
+ * binding form `@image_N` / `@video_N` / `@audio_N`. Nothing re-parses that
30
+ * shape, so it cannot move the assembled reference count — but a catalog
31
+ * emitting one would ship a binding directive to the model that binds to
32
+ * nothing.
33
+ *
34
+ * Scope is deliberately TOTAL rather than "the direction dimensions": it runs
35
+ * over every registered picker catalog (pack-composed, so a deployment's own
36
+ * pack is covered too) and over EVERY string a fold can inject — the full
37
+ * hint, the resolved compact term, AND the label. Labels are not decoration
38
+ * here: the multi-pick blend renderers weave them straight into the clause
39
+ * (`buildMoodHint`'s "with a {label} and {label} expression", `buildAestheticHints`'
40
+ * "{label} + {label} aesthetic blend", `buildPhotographerHints`' "blended visual
41
+ * language of {label} and {label}"), and mood + aesthetic are both
42
+ * `surface: "both"` with `maxPicks: 2`. Totality also has to survive promotion:
43
+ * a dimension can join the direction channel at any time, and the guard must
44
+ * already hold when it does.
45
+ *
46
+ * A failure here is a CATALOG fix (reword the entry), never a fold-site change.
47
+ */
48
+
49
+ /** The reference-slot grammar (`video-reference-resolver.ts`'s `REFERENCE_TOKEN_RE`, `i`-flagged there). */
50
+ const SLOT_TOKEN = /\{(?:image|video|audio):\d+/i
51
+ /** The id-addressed reference token (`ref-id-tokens.ts`'s `HAS_REF_ID_TOKEN_RE`, `i`-flagged there). */
52
+ const REF_ID_TOKEN = /\{ref:/i
53
+ /**
54
+ * A character / named-image mention (`@kira:1`), boundary-guarded like the pass.
55
+ *
56
+ * `i`-flagged even though `findCharacterMentionTokens` is NOT: `buildMoodHint`
57
+ * folds `label.toLowerCase()`, so an upper-case mention-shaped label reaches the
58
+ * prompt lower-cased — i.e. as live grammar. Scanning case-insensitively is
59
+ * strictly the safe side; do not "correct" this to match the pass.
60
+ */
61
+ const MENTION = /(?:^|[^A-Za-z0-9])@[a-z][a-z0-9-]*:\d+/i
62
+ /** The resolver's OUTPUT binding form (`video-reference-resolver.ts` emits `@${kind}_${n}`). */
63
+ const BINDING_FORM = /@(?:image|video|audio)_\d+/i
64
+
65
+ const FORBIDDEN: ReadonlyArray<{ name: string; re: RegExp }> = [
66
+ { name: "reference slot token ({image:N} / {video:N} / {audio:N})", re: SLOT_TOKEN },
67
+ { name: "id-addressed reference token ({ref:…})", re: REF_ID_TOKEN },
68
+ { name: "character/named-image mention (@slug:N)", re: MENTION },
69
+ { name: "reference binding form (@image_N / @video_N / @audio_N)", re: BINDING_FORM },
70
+ ]
71
+
72
+ /** Every option of a catalog, single-dim and multi-dim alike. */
73
+ function allOptions(catalog: PickerCatalog): ReadonlyArray<PickerOption> {
74
+ return [
75
+ ...(catalog.options ?? []),
76
+ ...(catalog.dimensions ?? []).flatMap((d) => d.options),
77
+ ]
78
+ }
79
+
80
+ describe("direction hint token safety", () => {
81
+ const catalogs = getRegisteredPickerCatalogs()
82
+
83
+ it("has catalogs to check (the guard must not pass vacuously)", () => {
84
+ expect(catalogs.length).toBeGreaterThan(0)
85
+ expect(catalogs.reduce((n, c) => n + allOptions(c).length, 0)).toBeGreaterThan(1000)
86
+ })
87
+
88
+ it("emits no reference-grammar token from any catalog hint, term or label", () => {
89
+ const offenders: string[] = []
90
+ for (const catalog of catalogs) {
91
+ for (const option of allOptions(catalog)) {
92
+ // `term` is already RESOLVED by the projection (`resolveTerm`), so this
93
+ // checks exactly the string a compact-mode fold would inject. `label`
94
+ // is injected verbatim by the multi-pick blend renderers (see the
95
+ // header). `description` is deliberately NOT scanned — no render path
96
+ // folds it into the prompt.
97
+ for (const [field, text] of [
98
+ ["promptHint", option.promptHint],
99
+ ["term", option.term],
100
+ ["label", option.label],
101
+ ] as const) {
102
+ if (!text) continue
103
+ for (const { name, re } of FORBIDDEN) {
104
+ if (re.test(text)) {
105
+ offenders.push(`${catalog.nodeType} • ${option.id} • ${field}: ${name}`)
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ expect(offenders).toEqual([])
112
+ })
113
+ })
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ DIRECTION_FIELDS,
4
+ DIRECTION_KEYS,
5
+ DIRECTION_ARRAY_CEILING,
6
+ IMAGE_HINT_MODE_DEFAULT,
7
+ VIDEO_HINT_MODE_DEFAULT,
8
+ directionFieldsForSurface,
9
+ modeForFamily,
10
+ renderDirectionHints,
11
+ } from "../direction-registry.js"
12
+ import {
13
+ buildFramingHints,
14
+ getFramingPromptHint,
15
+ getFramingTerm,
16
+ getFramingCategoryLimit,
17
+ FRAMING_FIELD_BY_CATEGORY,
18
+ type FramingCategory,
19
+ } from "../framing.js"
20
+ import { buildLightingHints, getLightingPromptHint } from "../lighting.js"
21
+ import { buildExposureHints } from "../exposure-settings.js"
22
+ import { getLensPromptHint } from "../lens.js"
23
+ import { getCameraFormatPromptHint } from "../camera-format.js"
24
+ import { buildMoodHints } from "../mood.js"
25
+ import { buildAestheticHints } from "../aesthetic.js"
26
+ import { buildPhotographerHints } from "../photographer.js"
27
+ import { buildAtmosphereHints } from "../atmosphere.js"
28
+ import { getStylePromptHint, getStyleTerm } from "../style.js"
29
+ import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
30
+
31
+ /**
32
+ * The direction registry is the platform-owned contract for the flat
33
+ * `direction` wire channel: WHICH dimensions ride it, in WHAT order they fold,
34
+ * and HOW each catalog renders its selection. Every assertion here is a pin on
35
+ * that contract — a failure means a reorder / retable was intentional and the
36
+ * changeset has to say so.
37
+ */
38
+
39
+ const IMAGE = { surface: "image" } as const
40
+ const VIDEO = { surface: "video" } as const
41
+
42
+ // Real catalog ids (every getter returns "" on a miss, so a fake id would make
43
+ // most of these assertions vacuously pass).
44
+ const NO_SUCH_ID = "__no_such_id__"
45
+
46
+ describe("DIRECTION_FIELDS — table integrity", () => {
47
+ it("has unique keys", () => {
48
+ const keys = DIRECTION_FIELDS.map((f) => f.key)
49
+ expect(new Set(keys).size).toBe(keys.length)
50
+ })
51
+
52
+ it("gives every row a positive maxPicks and a render function", () => {
53
+ for (const spec of DIRECTION_FIELDS) {
54
+ expect(spec.maxPicks, spec.key).toBeGreaterThanOrEqual(1)
55
+ expect(typeof spec.render, spec.key).toBe("function")
56
+ }
57
+ })
58
+
59
+ it("exports DIRECTION_KEYS in table order", () => {
60
+ expect(DIRECTION_KEYS).toEqual(DIRECTION_FIELDS.map((f) => f.key))
61
+ })
62
+
63
+ // ── THE ORDER PIN ────────────────────────────────────────────────────────
64
+ // The canonical fold order, exported so a client preview cannot drift from
65
+ // the server. Camera motion leads; the five pre-registry keys form the
66
+ // LEGACY BLOCK at the tail (see the legacy-tail pin below).
67
+ it("pins the canonical fold order (42 keys)", () => {
68
+ expect(DIRECTION_KEYS).toEqual([
69
+ "cameraMotion",
70
+ "shotSize",
71
+ "angle",
72
+ "coverage",
73
+ "composition",
74
+ "vantage",
75
+ "pose",
76
+ "compositionEffect",
77
+ "cameraFormat",
78
+ "lens",
79
+ "aperture",
80
+ "shutterSpeed",
81
+ "isoValue",
82
+ "timeOfDay",
83
+ "lightingStyle",
84
+ "lightingDirection",
85
+ "lightingRatio",
86
+ "colorTemperature",
87
+ "colorLook",
88
+ "atmosphere",
89
+ "postProcess",
90
+ "style",
91
+ "mood",
92
+ "aesthetic",
93
+ "photoGenre",
94
+ "photographer",
95
+ "renderQuality",
96
+ "setting",
97
+ "era",
98
+ "backdrop",
99
+ "actionFx",
100
+ "temporalSpeed",
101
+ "temporalFreeze",
102
+ "temporalDirection",
103
+ "temporalShutter",
104
+ "transition",
105
+ "loopSubject",
106
+ "framingId",
107
+ "framingAngleId",
108
+ "lightingId",
109
+ "lensId",
110
+ "cameraFormatId",
111
+ ])
112
+ })
113
+
114
+ // ── THE LEGACY-TAIL PIN (the darkness guarantee) ─────────────────────────
115
+ // This is what makes "no prompt text changed for any EXISTING caller" true:
116
+ // the five pre-registry keys sit LAST, in the exact order the old inlined
117
+ // `composePromptText` folded them. Re-sorting the table must fail loudly.
118
+ it("keeps the five pre-registry keys last, in their original fold order", () => {
119
+ expect(DIRECTION_KEYS.slice(-5)).toEqual([
120
+ "framingId",
121
+ "framingAngleId",
122
+ "lightingId",
123
+ "lensId",
124
+ "cameraFormatId",
125
+ ])
126
+ })
127
+
128
+ it("folds a legacy-only direction in the original order", () => {
129
+ const hints = renderDirectionHints(
130
+ {
131
+ framingId: "wide-shot",
132
+ framingAngleId: "low-angle",
133
+ lightingId: "dawn",
134
+ lensId: "wide-24mm",
135
+ cameraFormatId: "16mm-film",
136
+ },
137
+ IMAGE,
138
+ )
139
+ expect(hints).toEqual([
140
+ getFramingPromptHint("wide-shot"),
141
+ getFramingPromptHint("low-angle"),
142
+ getLightingPromptHint("dawn"),
143
+ getLensPromptHint("wide-24mm"),
144
+ getCameraFormatPromptHint("16mm-film"),
145
+ ])
146
+ })
147
+
148
+ // ── COUNT PIN ────────────────────────────────────────────────────────────
149
+ it("pins the surface split", () => {
150
+ expect(DIRECTION_FIELDS).toHaveLength(42)
151
+ expect(DIRECTION_FIELDS.filter((f) => f.surface === "both")).toHaveLength(27)
152
+ expect(DIRECTION_FIELDS.filter((f) => f.surface === "image")).toHaveLength(7)
153
+ expect(DIRECTION_FIELDS.filter((f) => f.surface === "video")).toHaveLength(8)
154
+ expect(directionFieldsForSurface("image")).toHaveLength(34)
155
+ expect(directionFieldsForSurface("video")).toHaveLength(35)
156
+ })
157
+
158
+ it("excludes characterFx (it needs a per-shot composer, not a bare id)", () => {
159
+ expect(DIRECTION_KEYS).not.toContain("characterFx")
160
+ })
161
+
162
+ it("exposes a wire array ceiling above every per-row cap", () => {
163
+ for (const spec of DIRECTION_FIELDS) {
164
+ expect(spec.maxPicks, spec.key).toBeLessThanOrEqual(DIRECTION_ARRAY_CEILING)
165
+ }
166
+ })
167
+
168
+ // ── CANONICAL-CAP PIN ────────────────────────────────────────────────────
169
+ // `maxPicks` is written as a literal per row (the table stays readable, and
170
+ // most catalogs have no exported cap to derive from), but where the platform
171
+ // DOES own a canonical per-dimension limit the two must agree. Framing is
172
+ // that case: `getFramingCategoryLimit` is the source of truth the pickers
173
+ // read, so raising it (e.g. composition 2 → 3) must fail here rather than
174
+ // leave the registry silently slicing to the stale number.
175
+ it("matches the canonical framing category limits", () => {
176
+ const byKey = new Map(DIRECTION_FIELDS.map((f) => [f.key as string, f.maxPicks]))
177
+ for (const [category, field] of Object.entries(FRAMING_FIELD_BY_CATEGORY)) {
178
+ expect(byKey.get(field), field).toBe(getFramingCategoryLimit(category as FramingCategory))
179
+ }
180
+ })
181
+ })
182
+
183
+ describe("renderDirectionHints — totality", () => {
184
+ it("renders nothing for an empty id list, in both modes", () => {
185
+ for (const spec of DIRECTION_FIELDS) {
186
+ expect(spec.render([], "full"), spec.key).toEqual([])
187
+ expect(spec.render([], "compact"), spec.key).toEqual([])
188
+ }
189
+ })
190
+
191
+ it("renders nothing for an unknown id, in both modes (never throws)", () => {
192
+ for (const spec of DIRECTION_FIELDS) {
193
+ expect(spec.render([NO_SUCH_ID], "full"), spec.key).toEqual([])
194
+ expect(spec.render([NO_SUCH_ID], "compact"), spec.key).toEqual([])
195
+ }
196
+ })
197
+
198
+ it("returns [] for undefined / {} / empty values", () => {
199
+ expect(renderDirectionHints(undefined, IMAGE)).toEqual([])
200
+ expect(renderDirectionHints({}, IMAGE)).toEqual([])
201
+ expect(renderDirectionHints({ style: "" }, IMAGE)).toEqual([])
202
+ expect(renderDirectionHints({ mood: [] }, IMAGE)).toEqual([])
203
+ expect(renderDirectionHints({ style: NO_SUCH_ID }, IMAGE)).toEqual([])
204
+ })
205
+
206
+ it("ignores wire keys that are not in the table", () => {
207
+ expect(
208
+ renderDirectionHints({ notAKey: "whatever" } as never, IMAGE),
209
+ ).toEqual([])
210
+ })
211
+ })
212
+
213
+ describe("renderDirectionHints — surface filtering", () => {
214
+ it("makes a video-only key inert on the image surface", () => {
215
+ expect(renderDirectionHints({ cameraMotion: "handheld" }, IMAGE)).toEqual([])
216
+ expect(renderDirectionHints({ temporalSpeed: "slow-motion" }, IMAGE)).toEqual([])
217
+ expect(renderDirectionHints({ actionFx: "earthquake-tremor" }, IMAGE)).toEqual([])
218
+ })
219
+
220
+ it("makes an image-only key inert on the video surface", () => {
221
+ expect(renderDirectionHints({ aperture: "aperture-f1-4" }, VIDEO)).toEqual([])
222
+ expect(renderDirectionHints({ photographer: "tim-walker" }, VIDEO)).toEqual([])
223
+ })
224
+
225
+ it("renders a `both` key on both surfaces", () => {
226
+ const expected = [getStylePromptHint("anime")]
227
+ expect(renderDirectionHints({ style: "anime" }, IMAGE)).toEqual(expected)
228
+ expect(renderDirectionHints({ style: "anime" }, VIDEO)).toEqual(expected)
229
+ })
230
+
231
+ it("folds in table order regardless of the caller's object-literal order", () => {
232
+ const hints = renderDirectionHints({ style: "anime", shotSize: "wide-shot" }, IMAGE)
233
+ expect(hints).toEqual([getFramingPromptHint("wide-shot"), getStylePromptHint("anime")])
234
+ })
235
+ })
236
+
237
+ describe("renderDirectionHints — multiplicity", () => {
238
+ it("caps a multi-pick dimension at its maxPicks", () => {
239
+ const capped = renderDirectionHints({ mood: ["happy", "joyful", "relieved"] }, IMAGE)
240
+ expect(capped).toEqual(buildMoodHints({ mood: ["happy", "joyful"] }, "full"))
241
+ })
242
+
243
+ it("tolerates an array on a single-pick key (top pick wins, no throw)", () => {
244
+ expect(renderDirectionHints({ style: ["anime", "3d-render"] }, IMAGE)).toEqual([
245
+ getStylePromptHint("anime"),
246
+ ])
247
+ })
248
+
249
+ it("de-dupes repeated ids inside one key", () => {
250
+ expect(renderDirectionHints({ atmosphere: ["clear", "clear"] }, IMAGE)).toEqual(
251
+ buildAtmosphereHints("clear", "full"),
252
+ )
253
+ })
254
+
255
+ it("stops scanning a key once maxPicks unique ids are in hand", () => {
256
+ // The dedupe is an `includes` scan, so scanning past `maxPicks` is
257
+ // quadratic in an array length no caller bounds for us — and one caller
258
+ // (`readDirectionFields`) reads untrusted persisted JSONB. Bailing is
259
+ // semantics-preserving: the result must equal the one from the
260
+ // already-sufficient prefix. No timing assertion — the default vitest
261
+ // timeout is the guard.
262
+ const flood = ["happy", "joyful", ...Array.from({ length: 200_000 }, (_, i) => `id-${i}`)]
263
+ expect(renderDirectionHints({ mood: flood }, IMAGE)).toEqual(
264
+ buildMoodHints({ mood: ["happy", "joyful"] }, "full"),
265
+ )
266
+ })
267
+ })
268
+
269
+ // ── THE DEDUPE INVARIANT ───────────────────────────────────────────────────
270
+ // The five legacy whole-catalog keys address the SAME catalogs as their
271
+ // canonical counterparts but are NOT aliases of one category — so overlap is
272
+ // resolved by exact-clause dedupe, never by an alias table.
273
+ describe("renderDirectionHints — dedupe invariant", () => {
274
+ it("emits ONE clause when a legacy key and its canonical counterpart carry the same id", () => {
275
+ expect(renderDirectionHints({ framingId: "wide-shot", shotSize: "wide-shot" }, IMAGE)).toEqual([
276
+ getFramingPromptHint("wide-shot"),
277
+ ])
278
+ })
279
+
280
+ it("emits BOTH clauses for two DIFFERENT ids of one catalog (what an alias table would have broken)", () => {
281
+ const hints = renderDirectionHints(
282
+ { lightingId: "dawn", lightingStyle: "rembrandt" },
283
+ IMAGE,
284
+ )
285
+ expect(hints).toHaveLength(2)
286
+ expect(hints).toContain(getLightingPromptHint("dawn"))
287
+ expect(hints).toContain(getLightingPromptHint("rembrandt"))
288
+ })
289
+
290
+ it("collapses the degenerate legacy case: the same id on two keys of one catalog", () => {
291
+ // The single documented byte difference for a pre-registry caller: this
292
+ // emitted the identical clause TWICE before the registry.
293
+ expect(
294
+ renderDirectionHints({ framingId: "wide-shot", framingAngleId: "wide-shot" }, IMAGE),
295
+ ).toEqual([getFramingPromptHint("wide-shot")])
296
+ })
297
+ })
298
+
299
+ // ── BLEND SEMANTICS ────────────────────────────────────────────────────────
300
+ // Mood / Aesthetic / Photographer emit ONE blended clause for a multi-pick, not
301
+ // one paragraph per id. A naive per-id loop would silently regress this.
302
+ describe("renderDirectionHints — blend catalogs", () => {
303
+ it("blends two moods into the catalog's own single clause", () => {
304
+ const hints = renderDirectionHints({ mood: ["happy", "joyful"] }, IMAGE)
305
+ expect(hints).toEqual(buildMoodHints({ mood: ["happy", "joyful"] }, "full"))
306
+ expect(hints).toHaveLength(1)
307
+ })
308
+
309
+ it("blends two aesthetics through buildAestheticHints", () => {
310
+ const hints = renderDirectionHints({ aesthetic: ["y2k", "cottagecore"] }, IMAGE)
311
+ expect(hints).toHaveLength(1)
312
+ expect(hints).toEqual([buildAestheticHints(["y2k", "cottagecore"], "full")])
313
+ })
314
+
315
+ it("blends two photographers through buildPhotographerHints", () => {
316
+ const hints = renderDirectionHints(
317
+ { photographer: ["tim-walker", "paolo-roversi"] },
318
+ IMAGE,
319
+ )
320
+ expect(hints).toHaveLength(1)
321
+ expect(hints).toEqual([buildPhotographerHints(["tim-walker", "paolo-roversi"], "full")])
322
+ })
323
+ })
324
+
325
+ // ── FAMILY EQUIVALENCE ─────────────────────────────────────────────────────
326
+ // Ties the flat per-category keys to the canonical family walks FOREVER: the
327
+ // registry must render exactly what a wired picker node of that family does.
328
+ describe("renderDirectionHints — family equivalence with the canonical builders", () => {
329
+ it("matches buildFramingHints for the five framing keys", () => {
330
+ const framing = {
331
+ shotSize: "wide-shot",
332
+ angle: "low-angle",
333
+ coverage: "two-shot",
334
+ composition: "rule-of-thirds",
335
+ vantage: "profile-left",
336
+ }
337
+ expect(renderDirectionHints(framing, IMAGE)).toEqual(
338
+ buildFramingHints(framing, false, "full"),
339
+ )
340
+ })
341
+
342
+ it("matches buildLightingHints for the five lighting keys", () => {
343
+ const lighting = {
344
+ timeOfDay: "dawn",
345
+ lightingStyle: "rembrandt",
346
+ lightingDirection: "side",
347
+ lightingRatio: "ratio-1-2",
348
+ colorTemperature: "temp-3200k",
349
+ }
350
+ expect(renderDirectionHints(lighting, IMAGE)).toEqual(buildLightingHints(lighting, "full"))
351
+ })
352
+
353
+ it("matches buildExposureHints for the three exposure keys", () => {
354
+ const exposure = {
355
+ aperture: "aperture-f1-4",
356
+ shutterSpeed: "shutter-1-60",
357
+ isoValue: "iso-400",
358
+ }
359
+ expect(renderDirectionHints(exposure, IMAGE)).toEqual(buildExposureHints(exposure, "full"))
360
+ })
361
+ })
362
+
363
+ describe("hint modes", () => {
364
+ it("resolves a flat mode for both families", () => {
365
+ expect(modeForFamily("compact", "look")).toBe("compact")
366
+ expect(modeForFamily("compact", "motion")).toBe("compact")
367
+ })
368
+
369
+ it("resolves a split mode per family", () => {
370
+ expect(modeForFamily(VIDEO_HINT_MODE_DEFAULT, "look")).toBe("full")
371
+ expect(modeForFamily(VIDEO_HINT_MODE_DEFAULT, "motion")).toBe("compact")
372
+ expect(IMAGE_HINT_MODE_DEFAULT).toBe("full")
373
+ })
374
+
375
+ it("emits compact terms in compact mode", () => {
376
+ expect(renderDirectionHints({ style: "anime" }, { ...IMAGE, mode: "compact" })).toEqual([
377
+ getStyleTerm("anime"),
378
+ ])
379
+ expect(
380
+ renderDirectionHints({ shotSize: "wide-shot" }, { ...IMAGE, mode: "compact" }),
381
+ ).toEqual([getFramingTerm("wide-shot")])
382
+ })
383
+
384
+ it("applies the split mode per family on a video fold", () => {
385
+ const hints = renderDirectionHints(
386
+ { cameraMotion: "handheld", style: "anime" },
387
+ { ...VIDEO, mode: VIDEO_HINT_MODE_DEFAULT },
388
+ )
389
+ // cameraMotion leads (motion → compact term); style follows (look → full).
390
+ expect(hints).toEqual([getCameraMotionTerm("handheld"), getStylePromptHint("anime")])
391
+ expect(hints[0]).not.toBe(getCameraMotionPromptHint("handheld"))
392
+ })
393
+ })