@nodaro/prompts 1.10.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,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
+ })