@nodaro/prompts 1.12.0 → 1.14.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.
Files changed (36) hide show
  1. package/dist/index.cjs +389 -194
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +244 -29
  4. package/dist/index.d.ts +244 -29
  5. package/dist/index.js +377 -196
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/assemble-image-input-cap.test.ts +37 -13
  9. package/src/__tests__/assemble-image-input.test.ts +100 -19
  10. package/src/__tests__/assemble-video-input-cap.test.ts +101 -15
  11. package/src/__tests__/assemble-video-input.test.ts +167 -33
  12. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  13. package/src/__tests__/multi-picker-spec.test.ts +21 -1
  14. package/src/__tests__/person-regional-aesthetic.test.ts +2 -1
  15. package/src/__tests__/prompt-style-section.test.ts +345 -0
  16. package/src/__tests__/provider-prompt-doctrine.test.ts +39 -0
  17. package/src/__tests__/style-section-boundary.test.ts +179 -0
  18. package/src/__tests__/subject-fold.test.ts +32 -13
  19. package/src/assemble-image-input.ts +51 -26
  20. package/src/assemble-video-input.ts +54 -34
  21. package/src/direction-registry.ts +108 -37
  22. package/src/gemini-omni-inputs.ts +11 -3
  23. package/src/held-prop.ts +1 -0
  24. package/src/hint-shedding.ts +23 -4
  25. package/src/index.ts +1 -0
  26. package/src/person.ts +4 -0
  27. package/src/picker-analyzer-registry.ts +37 -0
  28. package/src/prompt-builder.ts +84 -27
  29. package/src/prompt-hint-join.ts +9 -0
  30. package/src/prompt-style-section.ts +256 -0
  31. package/src/prompt-wizard-categories.ts +6 -0
  32. package/src/provider-prompt-doctrine.ts +51 -2
  33. package/src/setting.ts +1 -0
  34. package/src/style.ts +1 -0
  35. package/src/styling.ts +5 -1
  36. package/src/video-reference-resolver.ts +5 -2
@@ -1,6 +1,11 @@
1
1
  import { describe, it, expect } from "vitest"
2
2
  import { composeVideoPromptText } from "../assemble-video-input.js"
3
- import { directionFieldsForSurface } from "../direction-registry.js"
3
+ import {
4
+ FILM_STYLE_KEYS,
5
+ VIDEO_HINT_MODE_DEFAULT,
6
+ directionFieldsForSurface,
7
+ renderDirectionHints,
8
+ } from "../direction-registry.js"
4
9
  import { getStylePromptHint, getStyleTerm } from "../style.js"
5
10
  import { getTransitionPromptHint, getTransitionTerm } from "../transitions.js"
6
11
  import { getCameraMotionPromptHint, getCameraMotionTerm } from "../camera-motions.js"
@@ -25,6 +30,10 @@ import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
25
30
  * 2. THE VERBOSITY POLICY — look dimensions render their full clause, motion
26
31
  * dimensions their compact professional term. That split moved from the
27
32
  * client to the platform, so it is pinned in both directions.
33
+ * 3. THE `[style]` SECTION — the look clauses read in a trailing block, not
34
+ * inline; the motion family and the structured fragment stay in the body.
35
+ * The header is written as a LITERAL in every expectation here so a typo in
36
+ * `STYLE_SECTION_HEADER` fails rather than redefining the contract.
28
37
  *
29
38
  * Real catalog ids throughout: every `get*PromptHint` returns `""` on a miss,
30
39
  * so a made-up id would make most assertions vacuously pass.
@@ -62,9 +71,9 @@ describe("composeVideoPromptText — the no-op contract", () => {
62
71
  })
63
72
 
64
73
  describe("composeVideoPromptText — the verbosity policy", () => {
65
- it("renders a LOOK dimension as its full clause", () => {
74
+ it("renders a LOOK dimension as its full clause, in the section", () => {
66
75
  expect(composeVideoPromptText("a knight", { style: STYLE })).toBe(
67
- `a knight. ${getStylePromptHint(STYLE)}`,
76
+ `a knight\n\n[style]:\n${getStylePromptHint(STYLE)}`,
68
77
  )
69
78
  })
70
79
 
@@ -91,12 +100,41 @@ describe("composeVideoPromptText — the verbosity policy", () => {
91
100
  hintMode: "full",
92
101
  }),
93
102
  ).toBe(`a knight. ${getTransitionPromptHint(TRANSITION)}`)
94
- // …and "compact" demotes the look family to its term.
103
+ // …and "compact" demotes the look family to its term. Verbosity does not
104
+ // move a clause: the look term still reads in the section.
95
105
  expect(
96
106
  composeVideoPromptText("a knight", { style: STYLE }, undefined, {
97
107
  hintMode: "compact",
98
108
  }),
99
- ).toBe(`a knight. ${getStyleTerm(STYLE)}`)
109
+ ).toBe(`a knight\n\n[style]:\n${getStyleTerm(STYLE)}`)
110
+ })
111
+ })
112
+
113
+ /**
114
+ * THE BODY/SECTION BOUNDARY. Tal's rule: camera motion is part of the shot
115
+ * prose, not the style — so the WHOLE motion family stays in the body, and the
116
+ * boundary is the registry's `family` column, the same column the verbosity
117
+ * policy above splits on. Coupling them is deliberate; a row cannot be shot
118
+ * prose for one and look for the other.
119
+ */
120
+ describe("composeVideoPromptText — what stays in the body", () => {
121
+ it("keeps every motion clause inline and emits no header for a motion-only fold", () => {
122
+ const out = composeVideoPromptText("a knight", {
123
+ cameraMotion: CAMERA_MOTION,
124
+ transition: TRANSITION,
125
+ })
126
+ expect(out).toBe(
127
+ `a knight. ${getCameraMotionTerm(CAMERA_MOTION)}. ${getTransitionTerm(TRANSITION)}`,
128
+ )
129
+ expect(out).not.toContain("[style]")
130
+ })
131
+
132
+ it("splits a mixed fold: motion inline, look in the section", () => {
133
+ expect(
134
+ composeVideoPromptText("a knight", { cameraMotion: CAMERA_MOTION, style: STYLE }),
135
+ ).toBe(
136
+ `a knight. ${getCameraMotionTerm(CAMERA_MOTION)}\n\n[style]:\n${getStylePromptHint(STYLE)}`,
137
+ )
100
138
  })
101
139
  })
102
140
 
@@ -114,22 +152,38 @@ describe("composeVideoPromptText — ordering", () => {
114
152
  })
115
153
 
116
154
  it("folds in TABLE order, not the caller's object order", () => {
117
- // `shotSize` (row 2) precedes `style` (row 22) however the object is written.
155
+ // `shotSize` (row 2) precedes `lightingStyle` (row 15) on the scene line
156
+ // however the object is written.
118
157
  const out = composeVideoPromptText("a knight", {
119
- style: STYLE,
158
+ lightingStyle: LIGHTING_STYLE,
120
159
  shotSize: SHOT_SIZE,
121
160
  })!
122
161
  expect(out.indexOf(getFramingPromptHint(SHOT_SIZE))).toBeLessThan(
123
- out.indexOf(getStylePromptHint(STYLE)),
162
+ out.indexOf(getLightingPromptHint(LIGHTING_STYLE)),
124
163
  )
125
164
  })
126
165
 
127
- it("appends the structured fragment AFTER every direction hint", () => {
166
+ it("groups before it orders: the film line leads the scene line", () => {
167
+ // `shotSize` folds at row 2 and `style` at row 22, so table order alone
168
+ // would read the framing clause first. The section's two lines outrank it.
169
+ expect(composeVideoPromptText("a knight", { style: STYLE, shotSize: SHOT_SIZE })).toBe(
170
+ `a knight\n\n[style]:\n${getStylePromptHint(STYLE)}\n${getFramingPromptHint(SHOT_SIZE)}`,
171
+ )
172
+ })
173
+
174
+ it("ends the BODY with the structured fragment, then hangs the section off it", () => {
128
175
  const structured = { mood: "wistful" }
129
176
  const fragment = renderStructuredFields(structured)
130
177
  expect(fragment.length).toBeGreaterThan(0)
131
- const out = composeVideoPromptText("a knight", { style: STYLE }, structured)!
132
- expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}. ${fragment}`)
178
+ const out = composeVideoPromptText(
179
+ "a knight",
180
+ { style: STYLE, cameraMotion: CAMERA_MOTION },
181
+ structured,
182
+ )!
183
+ expect(out).toBe(
184
+ `a knight. ${getCameraMotionTerm(CAMERA_MOTION)}. ${fragment}` +
185
+ `\n\n[style]:\n${getStylePromptHint(STYLE)}`,
186
+ )
133
187
  })
134
188
  })
135
189
 
@@ -138,7 +192,7 @@ describe("composeVideoPromptText — multi-pick doctrine", () => {
138
192
  const blended = buildMoodHints({ mood: ["happy", "serene"] }, "full")
139
193
  expect(blended).toHaveLength(1)
140
194
  expect(composeVideoPromptText("a knight", { mood: ["happy", "serene"] })).toBe(
141
- `a knight. ${blended[0]}`,
195
+ `a knight\n\n[style]:\n${blended[0]}`,
142
196
  )
143
197
  })
144
198
 
@@ -147,7 +201,7 @@ describe("composeVideoPromptText — multi-pick doctrine", () => {
147
201
  expect(blended.length).toBeGreaterThan(0)
148
202
  expect(
149
203
  composeVideoPromptText("a knight", { aesthetic: ["y2k", "cottagecore"] }),
150
- ).toBe(`a knight. ${blended}`)
204
+ ).toBe(`a knight\n\n[style]:\n${blended}`)
151
205
  })
152
206
 
153
207
  it("slices an over-cap array to the dimension's maxPicks (atmosphere = 2)", () => {
@@ -156,7 +210,9 @@ describe("composeVideoPromptText — multi-pick doctrine", () => {
156
210
  })!
157
211
  const kept = buildAtmosphereHints(["clear", "cloudy"], "full")
158
212
  expect(kept).toHaveLength(2)
159
- expect(out).toBe(`a knight. ${kept.join(". ")}`)
213
+ // Both survivors share the scene line, `. `-joined exactly as an inline
214
+ // fold joined them.
215
+ expect(out).toBe(`a knight\n\n[style]:\n${kept.join(". ")}`)
160
216
  expect(out).not.toContain(buildAtmosphereHints("overcast", "full")[0])
161
217
  })
162
218
 
@@ -164,7 +220,7 @@ describe("composeVideoPromptText — multi-pick doctrine", () => {
164
220
  // The legacy `V2LookPicker` shape: a single-pick dimension that stored an
165
221
  // array. Must degrade to one hint, never throw and never drop the key.
166
222
  const out = composeVideoPromptText("a knight", { style: [STYLE, "anime"] })
167
- expect(out).toBe(`a knight. ${getStylePromptHint(STYLE)}`)
223
+ expect(out).toBe(`a knight\n\n[style]:\n${getStylePromptHint(STYLE)}`)
168
224
  })
169
225
  })
170
226
 
@@ -191,12 +247,31 @@ describe("composeVideoPromptText — tolerance", () => {
191
247
 
192
248
  describe("composeVideoPromptText — an empty or absent body", () => {
193
249
  it("returns the hints alone for an empty prompt (never a leading '. ')", () => {
194
- expect(composeVideoPromptText("", { style: STYLE })).toBe(getStylePromptHint(STYLE))
250
+ expect(composeVideoPromptText("", { transition: TRANSITION })).toBe(
251
+ getTransitionTerm(TRANSITION),
252
+ )
195
253
  })
196
254
 
197
255
  it("returns the hints alone for an ABSENT prompt", () => {
198
- expect(composeVideoPromptText(undefined, { style: STYLE })).toBe(
199
- getStylePromptHint(STYLE),
256
+ expect(composeVideoPromptText(undefined, { transition: TRANSITION })).toBe(
257
+ getTransitionTerm(TRANSITION),
258
+ )
259
+ })
260
+
261
+ it("drops the section's blank line with the body (never a leading newline)", () => {
262
+ const sectionOnly = `[style]:\n${getStylePromptHint(STYLE)}`
263
+ expect(composeVideoPromptText("", { style: STYLE })).toBe(sectionOnly)
264
+ expect(composeVideoPromptText(undefined, { style: STYLE })).toBe(sectionOnly)
265
+ expect(composeVideoPromptText(" ", { style: STYLE })).toBe(sectionOnly)
266
+ })
267
+
268
+ it("TRIMS a prompt the section is the only thing folded onto", () => {
269
+ // The section counts as "something folded", so the body is trimmed exactly
270
+ // as the hint-join branch trims it — otherwise the blank line would inherit
271
+ // the prompt's trailing whitespace. (With NO fold at all the prompt still
272
+ // comes back untrimmed: the no-op contract above.)
273
+ expect(composeVideoPromptText(" a knight \n", { style: STYLE })).toBe(
274
+ `a knight\n\n[style]:\n${getStylePromptHint(STYLE)}`,
200
275
  )
201
276
  })
202
277
  })
@@ -208,30 +283,49 @@ describe("composeVideoPromptText — the dedupe invariant", () => {
208
283
  it("emits ONE clause when a legacy and a canonical key carry the SAME id", () => {
209
284
  expect(
210
285
  composeVideoPromptText("a knight", { framingId: SHOT_SIZE, shotSize: SHOT_SIZE }),
211
- ).toBe(`a knight. ${getFramingPromptHint(SHOT_SIZE)}`)
286
+ ).toBe(`a knight\n\n[style]:\n${getFramingPromptHint(SHOT_SIZE)}`)
212
287
  })
213
288
 
214
289
  it("emits BOTH clauses for two DIFFERENT ids of one catalog", () => {
215
290
  // The case an alias table would have wrongly collapsed: `lightingId` is
216
291
  // whole-catalog, so a time-of-day pick beside a lighting-style pick is two
217
- // legitimate selections.
292
+ // legitimate selections. Both are scene-line clauses, so they share a line.
218
293
  const out = composeVideoPromptText("a knight", {
219
294
  lightingStyle: LIGHTING_STYLE,
220
295
  lightingId: TIME_OF_DAY,
221
296
  })
222
297
  expect(out).toBe(
223
- `a knight. ${getLightingPromptHint(LIGHTING_STYLE)}. ${getLightingPromptHint(TIME_OF_DAY)}`,
298
+ `a knight\n\n[style]:\n` +
299
+ `${getLightingPromptHint(LIGHTING_STYLE)}. ${getLightingPromptHint(TIME_OF_DAY)}`,
224
300
  )
225
301
  })
302
+
303
+ it("emits NO header when every look clause deduped away", () => {
304
+ // The section's existence follows the clause COUNT after dedupe, not the
305
+ // caller's key count: two keys, one clause, one line — and a fold whose
306
+ // every look clause vanished would take the no-header path.
307
+ const out = composeVideoPromptText("a knight", {
308
+ framingId: SHOT_SIZE,
309
+ shotSize: SHOT_SIZE,
310
+ })!
311
+ expect(out.split("[style]:")).toHaveLength(2)
312
+ expect(composeVideoPromptText("a knight", { style: NO_SUCH_ID })).not.toContain("[style]")
313
+ })
226
314
  })
227
315
 
228
316
  /**
229
- * ORDER TOTALITY — every video-surface dimension, folded in one call.
317
+ * ORDER TOTALITY — every video-surface dimension, folded in one call, and the
318
+ * slot each one lands in.
230
319
  *
231
320
  * The fixture is keyed in `directionFieldsForSurface("video")` order and pinned
232
321
  * against it, so adding, removing or reordering a video row fails HERE as well
233
322
  * as in the registry test. Each id was chosen to render a clause distinct from
234
323
  * every other row's, so the dedupe pass cannot mask a mis-ordering.
324
+ *
325
+ * The expectation is an INDEPENDENT recomputation off the registry (per-row
326
+ * clauses, classified by `family` + `FILM_STYLE_KEYS`, re-assembled by hand),
327
+ * not a second call through the composer — so a composer that slotted a row
328
+ * wrongly would have to be wrong in the registry to pass.
235
329
  */
236
330
  const EVERY_VIDEO_DIMENSION: Record<string, string> = {
237
331
  cameraMotion: "static",
@@ -284,18 +378,58 @@ describe("composeVideoPromptText — order totality over every video dimension",
284
378
  }
285
379
  })
286
380
 
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
- )
381
+ it("folds all 35 dimensions into body, film line and scene line", () => {
382
+ const filmKeys: readonly string[] = FILM_STYLE_KEYS
383
+ const bySlot: Record<"body" | "film" | "scene", string[]> = { body: [], film: [], scene: [] }
384
+ for (const row of directionFieldsForSurface("video")) {
385
+ const slot =
386
+ row.family === "motion" ? "body" : filmKeys.includes(row.key) ? "film" : "scene"
387
+ bySlot[slot].push(
388
+ ...renderDirectionHints(
389
+ { [row.key]: EVERY_VIDEO_DIMENSION[row.key] },
390
+ { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT },
391
+ ),
392
+ )
393
+ }
394
+ const every = [...bySlot.body, ...bySlot.film, ...bySlot.scene]
395
+ expect(new Set(every).size, "fixture ids must render distinct clauses").toBe(every.length)
396
+ // Every slot has to be occupied or the assembly below proves less than it
397
+ // looks (an empty film line would collapse the section to one line).
398
+ for (const slot of ["body", "film", "scene"] as const) {
399
+ expect(bySlot[slot].length, slot).toBeGreaterThan(0)
400
+ }
401
+
297
402
  expect(composeVideoPromptText("a knight", EVERY_VIDEO_DIMENSION)).toBe(
298
- ["a knight", ...expected].join(". "),
403
+ `${["a knight", ...bySlot.body].join(". ")}` +
404
+ `\n\n[style]:\n${bySlot.film.join(". ")}\n${bySlot.scene.join(". ")}`,
405
+ )
406
+ })
407
+
408
+ it("puts the five film rows on the film line and nothing else", () => {
409
+ expect(FILM_STYLE_KEYS).toEqual([
410
+ "cameraFormat",
411
+ "colorLook",
412
+ "style",
413
+ "era",
414
+ "cameraFormatId",
415
+ ])
416
+ const filmLine = composeVideoPromptText("a knight", EVERY_VIDEO_DIMENSION)!
417
+ .split("\n\n[style]:\n")[1]!
418
+ .split("\n")[0]!
419
+ for (const key of FILM_STYLE_KEYS) {
420
+ if (!(key in EVERY_VIDEO_DIMENSION)) continue
421
+ const [clause] = renderDirectionHints(
422
+ { [key]: EVERY_VIDEO_DIMENSION[key] },
423
+ { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT },
424
+ )
425
+ expect(filmLine, key).toContain(clause)
426
+ }
427
+ // …and a scene-line dimension never leaks onto it.
428
+ expect(filmLine).not.toContain(
429
+ renderDirectionHints(
430
+ { setting: EVERY_VIDEO_DIMENSION.setting },
431
+ { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT },
432
+ )[0],
299
433
  )
300
434
  })
301
435
  })
@@ -4,6 +4,7 @@ import {
4
4
  type PickerCatalog,
5
5
  type PickerOption,
6
6
  } from "../picker-catalogs.js"
7
+ import { STYLE_SECTION_HEADER } from "../prompt-style-section.js"
7
8
 
8
9
  /**
9
10
  * THE GUARD THAT MAKES "FOLD BEFORE THE REFERENCE RESOLVER" SAFE.
@@ -43,6 +44,14 @@ import {
43
44
  * a dimension can join the direction channel at any time, and the guard must
44
45
  * already hold when it does.
45
46
  *
47
+ * A FIFTH pattern is the `[style]` section boundary. It runs in the other
48
+ * direction from the four above: those keep catalog text out of grammar the
49
+ * RESOLVER parses, this keeps catalog text out of grammar the ASSEMBLER emits.
50
+ * A catalog entry containing `[style]` would forge a section boundary inside a
51
+ * clause — enough to stop `buildImagePrompt`'s hybrid line-capitalizer early on
52
+ * an otherwise section-free prompt. Scanned case-insensitively for the same
53
+ * reason as `MENTION`: `buildMoodHint` lower-cases the labels it folds.
54
+ *
46
55
  * A failure here is a CATALOG fix (reword the entry), never a fold-site change.
47
56
  */
48
57
 
@@ -61,12 +70,15 @@ const REF_ID_TOKEN = /\{ref:/i
61
70
  const MENTION = /(?:^|[^A-Za-z0-9])@[a-z][a-z0-9-]*:\d+/i
62
71
  /** The resolver's OUTPUT binding form (`video-reference-resolver.ts` emits `@${kind}_${n}`). */
63
72
  const BINDING_FORM = /@(?:image|video|audio)_\d+/i
73
+ /** The `[style]` section boundary (`prompt-style-section.ts`'s header, colon-free so `[style]` alone trips). */
74
+ const STYLE_SECTION = /\[style\]/i
64
75
 
65
76
  const FORBIDDEN: ReadonlyArray<{ name: string; re: RegExp }> = [
66
77
  { name: "reference slot token ({image:N} / {video:N} / {audio:N})", re: SLOT_TOKEN },
67
78
  { name: "id-addressed reference token ({ref:…})", re: REF_ID_TOKEN },
68
79
  { name: "character/named-image mention (@slug:N)", re: MENTION },
69
80
  { name: "reference binding form (@image_N / @video_N / @audio_N)", re: BINDING_FORM },
81
+ { name: "style-section boundary ([style])", re: STYLE_SECTION },
70
82
  ]
71
83
 
72
84
  /** Every option of a catalog, single-dim and multi-dim alike. */
@@ -110,4 +122,13 @@ describe("direction hint token safety", () => {
110
122
  }
111
123
  expect(offenders).toEqual([])
112
124
  })
125
+
126
+ it("has a section header that is not itself reference grammar", () => {
127
+ // The header rides in the assembled prompt alongside catalog text, so it
128
+ // has to clear the same four resolver patterns the catalogs do — and it has
129
+ // to trip the fifth, or that guard is scanning for the wrong string.
130
+ for (const { name, re } of FORBIDDEN) {
131
+ expect(re.test(STYLE_SECTION_HEADER), name).toBe(re === STYLE_SECTION)
132
+ }
133
+ })
113
134
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest"
2
- import { buildMultiPickerAnalyzerSpec, buildPickerAnalyzerSpec, pickerFanoutTargets } from "../index.js"
2
+ import { buildMultiPickerAnalyzerSpec, buildPickerAnalyzerSpec, pickerFanoutTargets, PICKER_TYPES } from "../index.js"
3
3
 
4
4
  describe("buildMultiPickerAnalyzerSpec", () => {
5
5
  it("composes one section per wired picker plus gaps, order-independent", () => {
@@ -23,6 +23,26 @@ describe("buildMultiPickerAnalyzerSpec", () => {
23
23
  expect(withGaps.gaps.missingItems).toHaveLength(1)
24
24
  })
25
25
 
26
+ it("otherPickersLegend names the non-wired pickers by key, excluding the wired ones", () => {
27
+ const { otherPickersLegend } = buildMultiPickerAnalyzerSpec(["person", "styling"])
28
+ expect(otherPickersLegend.length).toBeGreaterThan(0)
29
+ // non-wired pickers are attributable by their exact key
30
+ expect(otherPickersLegend).toContain("- era:")
31
+ expect(otherPickersLegend).toContain("- setting:")
32
+ expect(otherPickersLegend).toContain("- held-prop:")
33
+ // wired pickers must NOT appear as their own bullet lines
34
+ expect(otherPickersLegend).not.toContain("- person:")
35
+ expect(otherPickersLegend).not.toContain("- styling:")
36
+ // discriminated pickers carry their dimension labels; flat ones carry the label
37
+ expect(otherPickersLegend).toContain("Era / Period") // flat `era` label
38
+ expect(otherPickersLegend).toMatch(/- exposure-settings: Exposure Settings — .*Aperture/) // title-cased key + dims
39
+ })
40
+
41
+ it("otherPickersLegend is empty when every picker is wired", () => {
42
+ const { otherPickersLegend } = buildMultiPickerAnalyzerSpec(PICKER_TYPES)
43
+ expect(otherPickersLegend).toBe("")
44
+ })
45
+
26
46
  it("FUZZ: arbitrary gaps content never alters the enum-validated picker sections", () => {
27
47
  const { schema } = buildMultiPickerAnalyzerSpec(["person"])
28
48
  const personSpec = buildPickerAnalyzerSpec("person")
@@ -26,7 +26,7 @@ describe("regional-aesthetic dimension — wiring", () => {
26
26
 
27
27
  describe("regional-aesthetic catalog — coverage and structure", () => {
28
28
  it("ships the expected total entry count", () => {
29
- expect(REGIONAL_ENTRIES.length).toBe(86)
29
+ expect(REGIONAL_ENTRIES.length).toBe(88)
30
30
  })
31
31
 
32
32
  it.each([
@@ -34,6 +34,7 @@ describe("regional-aesthetic catalog — coverage and structure", () => {
34
34
  ["USA — African-American", 6],
35
35
  ["Europe", 16],
36
36
  ["Asia", 12],
37
+ ["Central Asia", 2],
37
38
  ["Latin America", 7],
38
39
  ["Middle East", 7],
39
40
  ["North Africa", 3],