@nodaro/prompts 1.0.1

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 (150) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +27 -0
  3. package/dist/index.cjs +21240 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +3599 -0
  6. package/dist/index.d.ts +3599 -0
  7. package/dist/index.js +20854 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
  11. package/src/__tests__/action-fx.test.ts +155 -0
  12. package/src/__tests__/apply-picker-json.test.ts +50 -0
  13. package/src/__tests__/assemble-image-input.test.ts +248 -0
  14. package/src/__tests__/assemble-suno-input.test.ts +283 -0
  15. package/src/__tests__/brand-tokens.test.ts +81 -0
  16. package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
  17. package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
  18. package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
  19. package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
  20. package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
  21. package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
  22. package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
  23. package/src/__tests__/catalog-gapfill.test.ts +53 -0
  24. package/src/__tests__/character-convergence-image.test.ts +217 -0
  25. package/src/__tests__/character-default-role-image.test.ts +167 -0
  26. package/src/__tests__/character-default-role-video.test.ts +168 -0
  27. package/src/__tests__/character-default-role.test.ts +70 -0
  28. package/src/__tests__/character-fx.test.ts +200 -0
  29. package/src/__tests__/entity-prompts-location.test.ts +97 -0
  30. package/src/__tests__/entity-prompts.test.ts +240 -0
  31. package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
  32. package/src/__tests__/factory-presets.test.ts +1045 -0
  33. package/src/__tests__/factory-snippets.test.ts +68 -0
  34. package/src/__tests__/framing-multi.test.ts +71 -0
  35. package/src/__tests__/framing-vantage.test.ts +39 -0
  36. package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
  37. package/src/__tests__/identity-lock.test.ts +46 -0
  38. package/src/__tests__/instrumentation.test.ts +68 -0
  39. package/src/__tests__/lighting-multi.test.ts +68 -0
  40. package/src/__tests__/location-convergence-image.test.ts +124 -0
  41. package/src/__tests__/mention-lock-flag.test.ts +499 -0
  42. package/src/__tests__/multi-picker-spec.test.ts +71 -0
  43. package/src/__tests__/music-genre.test.ts +103 -0
  44. package/src/__tests__/music-mood.test.ts +87 -0
  45. package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
  46. package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
  47. package/src/__tests__/parameter-registry-sync.test.ts +243 -0
  48. package/src/__tests__/person-age.test.ts +80 -0
  49. package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
  50. package/src/__tests__/person-body-axes.test.ts +67 -0
  51. package/src/__tests__/person-facial-geometry.test.ts +156 -0
  52. package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
  53. package/src/__tests__/person-sections.test.ts +18 -0
  54. package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
  55. package/src/__tests__/picker-catalogs-project.test.ts +85 -0
  56. package/src/__tests__/picker-catalogs.test.ts +53 -0
  57. package/src/__tests__/picker-limits.test.ts +20 -0
  58. package/src/__tests__/prompt-builder-segments.test.ts +183 -0
  59. package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
  60. package/src/__tests__/prompt-builder.test.ts +1773 -0
  61. package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
  62. package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
  63. package/src/__tests__/resolve-prompt-append.test.ts +58 -0
  64. package/src/__tests__/resolve-prompt.test.ts +44 -0
  65. package/src/__tests__/role-picker-shared.test.ts +208 -0
  66. package/src/__tests__/seedance-2-inputs.test.ts +173 -0
  67. package/src/__tests__/seedance-extend.test.ts +44 -0
  68. package/src/__tests__/sound-aggregator.test.ts +350 -0
  69. package/src/__tests__/style-presets.test.ts +34 -0
  70. package/src/__tests__/temporal-multi.test.ts +74 -0
  71. package/src/__tests__/transitions.test.ts +213 -0
  72. package/src/__tests__/video-reference-features.test.ts +41 -0
  73. package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
  74. package/src/__tests__/video-reference-resolver.test.ts +233 -0
  75. package/src/__tests__/video-reference-roles.test.ts +96 -0
  76. package/src/__tests__/voice-character.test.ts +48 -0
  77. package/src/__tests__/voice-delivery.test.ts +41 -0
  78. package/src/__tests__/wardrobe.test.ts +25 -0
  79. package/src/action-fx.ts +255 -0
  80. package/src/aesthetic.ts +435 -0
  81. package/src/assemble-image-input.ts +236 -0
  82. package/src/assemble-suno-input.ts +147 -0
  83. package/src/atmosphere.ts +104 -0
  84. package/src/backdrop.ts +131 -0
  85. package/src/brand-tokens.ts +154 -0
  86. package/src/camera-format.ts +76 -0
  87. package/src/camera-motions.ts +615 -0
  88. package/src/character-fx.ts +274 -0
  89. package/src/color-look.ts +103 -0
  90. package/src/composition-effects.ts +67 -0
  91. package/src/entity-prompts.ts +231 -0
  92. package/src/era.ts +292 -0
  93. package/src/exposure-settings.ts +142 -0
  94. package/src/factory-presets/generate-image.ts +1644 -0
  95. package/src/factory-presets/generate-video.ts +1116 -0
  96. package/src/factory-presets/index.ts +46 -0
  97. package/src/factory-presets/lottie-overlay.ts +166 -0
  98. package/src/factory-presets/motion-graphics.ts +350 -0
  99. package/src/factory-presets/music.ts +734 -0
  100. package/src/factory-presets/sfx.ts +136 -0
  101. package/src/factory-presets/shared-image.ts +207 -0
  102. package/src/factory-presets/switchx.ts +65 -0
  103. package/src/factory-presets/text.ts +292 -0
  104. package/src/factory-presets/types.ts +51 -0
  105. package/src/factory-presets/video-edit.ts +136 -0
  106. package/src/factory-presets/voice.ts +172 -0
  107. package/src/factory-presets.ts +2 -0
  108. package/src/factory-snippets/catalog.ts +105 -0
  109. package/src/factory-snippets/index.ts +16 -0
  110. package/src/factory-snippets/types.ts +33 -0
  111. package/src/framing.ts +634 -0
  112. package/src/held-prop.ts +187 -0
  113. package/src/identity-lock.ts +213 -0
  114. package/src/index.ts +66 -0
  115. package/src/instrumentation.ts +337 -0
  116. package/src/lens.ts +59 -0
  117. package/src/lighting.ts +229 -0
  118. package/src/loop-subject.ts +276 -0
  119. package/src/materials.ts +184 -0
  120. package/src/mood.ts +186 -0
  121. package/src/music-genre.ts +662 -0
  122. package/src/music-mood.ts +137 -0
  123. package/src/object-asset-presets.ts +81 -0
  124. package/src/parameter-prompt-hint.ts +281 -0
  125. package/src/person.ts +1368 -0
  126. package/src/photo-genre.ts +151 -0
  127. package/src/photographer.ts +612 -0
  128. package/src/picker-analyzer-registry.ts +374 -0
  129. package/src/picker-catalogs.ts +858 -0
  130. package/src/pose.ts +246 -0
  131. package/src/post-process-effects.ts +94 -0
  132. package/src/prompt-builder-structured-fields.ts +116 -0
  133. package/src/prompt-builder.ts +2964 -0
  134. package/src/prompt-templates.ts +50 -0
  135. package/src/prompt-wizard-categories.ts +334 -0
  136. package/src/provider-prompt-doctrine.ts +85 -0
  137. package/src/render-quality.ts +89 -0
  138. package/src/resolve-prompt.ts +120 -0
  139. package/src/seedance-2-inputs.ts +100 -0
  140. package/src/setting.ts +130 -0
  141. package/src/sound-aggregator.ts +241 -0
  142. package/src/style-presets.ts +162 -0
  143. package/src/style.ts +99 -0
  144. package/src/styling.ts +585 -0
  145. package/src/temporal.ts +151 -0
  146. package/src/transitions.ts +333 -0
  147. package/src/video-reference-resolver.ts +823 -0
  148. package/src/voice-character.ts +237 -0
  149. package/src/voice-delivery.ts +142 -0
  150. package/src/wardrobe.ts +179 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Parameter-registry sync tests.
3
+ *
4
+ * Per CLAUDE.md "Parameter Picker Node Registration", every parameter node
5
+ * type must appear in 5 registries. The shared package owns 3 of them:
6
+ *
7
+ * 1. PARAMETER_NODE_TYPES set (parameter-node-value.ts)
8
+ * 2. getParameterValue switch cases (parameter-node-value.ts)
9
+ * 3. getParameterPromptHint switch cases (parameter-prompt-hint.ts)
10
+ *
11
+ * (The frontend owns PARAMETER_PICKER_NODE_TYPES and parameter-picker-registry,
12
+ * tested separately on that side.)
13
+ *
14
+ * Drift between any of these has caused two production outages — action-fx
15
+ * (#1649 era) and loop-subject (#2132). Both times: the type was added to
16
+ * the set but the corresponding switch case was missed, so the orchestrator
17
+ * created a stale `pending` jobs row and threw `Unknown node type` while the
18
+ * picker UI silently rendered nothing.
19
+ *
20
+ * These tests drive a sample-data probe FROM the set, so adding a new
21
+ * parameter type without updating the helpers fails CI here.
22
+ */
23
+
24
+ import { describe, it, expect } from "vitest"
25
+ import { PARAMETER_NODE_TYPES, getParameterValue } from "@nodaro/shared"
26
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
27
+
28
+ // Catalog-driven types need real ids — the prompt-hint builders look them up.
29
+ import { FRAMINGS } from "../framing.js"
30
+ import { LIGHTINGS } from "../lighting.js"
31
+ import { LENSES } from "../lens.js"
32
+ import { CAMERA_FORMATS } from "../camera-format.js"
33
+ import { CAMERA_MOTIONS } from "../camera-motions.js"
34
+ import { COLOR_LOOKS } from "../color-look.js"
35
+ import { ATMOSPHERES } from "../atmosphere.js"
36
+ import { ACTION_FX } from "../action-fx.js"
37
+ import { STYLES } from "../style.js"
38
+ import { SETTINGS } from "../setting.js"
39
+ import { LOOP_SUBJECTS } from "../loop-subject.js"
40
+ import { MATERIALS } from "../materials.js"
41
+ import { ANIMALS } from "@nodaro/shared"
42
+ import { VEHICLES } from "@nodaro/shared"
43
+ import { WEAPONS } from "@nodaro/shared"
44
+ import { FURNITURE } from "@nodaro/shared"
45
+ import { PHOTO_GENRES } from "../photo-genre.js"
46
+ import { BACKDROPS } from "../backdrop.js"
47
+ import { HELD_PROPS } from "../held-prop.js"
48
+ import { PHOTOGRAPHERS } from "../photographer.js"
49
+ import { AESTHETICS } from "../aesthetic.js"
50
+ import { ERAS } from "../era.js"
51
+ import { MOODS } from "../mood.js"
52
+ import { POSES } from "../pose.js"
53
+ import { STYLINGS } from "../styling.js"
54
+ import { TEMPORALS } from "../temporal.js"
55
+ import { EXPOSURE_SETTINGS } from "../exposure-settings.js"
56
+ import { RENDER_QUALITIES } from "../render-quality.js"
57
+ import { COMPOSITION_EFFECTS } from "../composition-effects.js"
58
+ import { POST_PROCESS_EFFECTS } from "../post-process-effects.js"
59
+ import { PEOPLE } from "../person.js"
60
+ import { MUSIC_GENRES } from "../music-genre.js"
61
+ import { MUSIC_EMOTIONS } from "../music-mood.js"
62
+ import { INSTRUMENTS } from "../instrumentation.js"
63
+ import { VOICE_TIMBRES } from "../voice-character.js"
64
+ import { VOICE_ARCHETYPES } from "../voice-delivery.js"
65
+ import { TRANSITIONS } from "../transitions.js"
66
+ import { CHARACTER_FX } from "../character-fx.js"
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Lookup helper — pick an id from a catalog or return a placeholder. Empty
70
+ // catalogs fall through to a clearly invalid sentinel so the test fails
71
+ // loudly rather than silently passing on missing fixtures.
72
+ // ---------------------------------------------------------------------------
73
+ function firstId(catalog: ReadonlyArray<{ readonly id: string }>): string {
74
+ return catalog[0]?.id ?? "<MISSING_CATALOG_ENTRY>"
75
+ }
76
+
77
+ // Pick the first FRAMINGS entry whose category === target, since FRAMINGS
78
+ // is a flat list spanning shot-size/angle/coverage/composition/vantage.
79
+ function firstByCategory<T extends { readonly id: string; readonly category: string }>(
80
+ catalog: ReadonlyArray<T>,
81
+ category: string,
82
+ ): string {
83
+ for (const e of catalog) {
84
+ if (e.category === category) return e.id
85
+ }
86
+ return "<MISSING_CATEGORY>"
87
+ }
88
+
89
+ // Same idea for LIGHTINGS (which is also flat with `category`).
90
+ const FIRST_FRAMING_SHOT = firstByCategory(
91
+ FRAMINGS as ReadonlyArray<{ id: string; category: string }>,
92
+ "shot-size",
93
+ )
94
+ const FIRST_LIGHTING_TIME = firstByCategory(
95
+ LIGHTINGS as ReadonlyArray<{ id: string; category: string }>,
96
+ "time-of-day",
97
+ )
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Sample data per parameter type. Drives both getParameterValue and
101
+ // getParameterPromptHint tests.
102
+ // ---------------------------------------------------------------------------
103
+ const SAMPLE_DATA_BY_TYPE: Record<string, Record<string, unknown>> = {
104
+ "text-prompt": { text: "a dog running" },
105
+ "tone": { tone: "dramatic" },
106
+ "style-guide": { text: "photographic, naturalistic" },
107
+ "motion": { motion: "moderate" },
108
+ // CAMERA_MOTIONS[0] is "auto" with intentionally empty promptHint — use a
109
+ // real motion id so the sync test exercises the hint dispatch.
110
+ "camera-motion": { cameraMotion: "static" },
111
+ "framing": { shotSize: FIRST_FRAMING_SHOT },
112
+ "lens": { lens: firstId(LENSES) },
113
+ "camera-format": { cameraFormat: firstId(CAMERA_FORMATS) },
114
+ "lighting": { timeOfDay: FIRST_LIGHTING_TIME },
115
+ "color-look": { colorLook: firstId(COLOR_LOOKS) },
116
+ "atmosphere": { atmosphere: firstId(ATMOSPHERES) },
117
+ "style": { style: firstId(STYLES) },
118
+ "setting": { setting: firstId(SETTINGS) },
119
+ "person": { type: firstId(PEOPLE) },
120
+ "mood": { mood: firstId(MOODS) },
121
+ "photographer": { photographer: firstId(PHOTOGRAPHERS) },
122
+ "aesthetic": { aesthetic: firstId(AESTHETICS) },
123
+ "era": { era: firstId(ERAS) },
124
+ "pose": { pose: firstId(POSES) },
125
+ "styling": { outfit: firstId(STYLINGS) },
126
+ "temporal": { temporalSpeed: firstId(TEMPORALS) },
127
+ "material": { material: firstId(MATERIALS) },
128
+ "animal": { animal: firstId(ANIMALS) },
129
+ "vehicle": { vehicle: firstId(VEHICLES) },
130
+ "weapon": { weapon: firstId(WEAPONS) },
131
+ "furniture": { furniture: firstId(FURNITURE) },
132
+ "photo-genre": { photoGenre: firstId(PHOTO_GENRES) },
133
+ "backdrop": { backdrop: firstId(BACKDROPS) },
134
+ "held-prop": { heldProp: firstId(HELD_PROPS) },
135
+ "exposure-settings": { aperture: firstId(EXPOSURE_SETTINGS) },
136
+ "render-quality": { renderQuality: firstId(RENDER_QUALITIES) },
137
+ "composition-effects": { compositionEffect: firstId(COMPOSITION_EFFECTS) },
138
+ "post-process-effects": { postProcess: firstId(POST_PROCESS_EFFECTS) },
139
+ "action-fx": { actionFx: firstId(ACTION_FX) },
140
+ // TRANSITIONS[0] is "auto" with an intentionally empty promptHint — use a
141
+ // real transition id so the sync test exercises the hint dispatch.
142
+ "transition": { transition: "cross-dissolve" },
143
+ // CHARACTER_FX[0] is "auto" with an intentionally empty promptHint — use a
144
+ // real effect id so the sync test exercises the hint dispatch.
145
+ "character-fx": { characterFx: "werewolf" },
146
+ "loop-subject": { loopSubject: firstId(LOOP_SUBJECTS) },
147
+ "scene-count": { count: 5 },
148
+ "duration": { seconds: 8 },
149
+ "aspect-ratio": { ratio: "16:9" },
150
+ "music-genre": { genre: firstId(MUSIC_GENRES) },
151
+ "music-mood": { emotion: firstId(MUSIC_EMOTIONS) },
152
+ "instrumentation": { instruments: [firstId(INSTRUMENTS)] },
153
+ "voice-character": { timbre: firstId(VOICE_TIMBRES) },
154
+ "voice-delivery": { archetype: firstId(VOICE_ARCHETYPES) },
155
+ }
156
+
157
+ // Types that intentionally do NOT inject a prompt hint via
158
+ // getParameterPromptHint. They carry pure runtime parameters (counts,
159
+ // durations, aspect ratios, motion intensity) consumed by the executor
160
+ // directly, not appended to a downstream prompt.
161
+ const HINT_EXEMPT: ReadonlySet<string> = new Set([
162
+ "motion",
163
+ "style-guide",
164
+ "scene-count",
165
+ "duration",
166
+ "aspect-ratio",
167
+ ])
168
+
169
+ // =============================================================================
170
+ // Test 1 — every type in the set has a sample (forces the developer who adds
171
+ // a new type to also extend this file).
172
+ // =============================================================================
173
+
174
+ describe("PARAMETER_NODE_TYPES — sample coverage", () => {
175
+ for (const type of PARAMETER_NODE_TYPES) {
176
+ it(`has a sample data fixture for "${type}"`, () => {
177
+ expect(
178
+ SAMPLE_DATA_BY_TYPE[type],
179
+ `Missing SAMPLE_DATA_BY_TYPE entry for "${type}". When you add a new parameter type to PARAMETER_NODE_TYPES, you must also add a sample here so the cross-registry sync tests can probe it.`,
180
+ ).toBeDefined()
181
+ })
182
+ }
183
+ })
184
+
185
+ // =============================================================================
186
+ // Test 2 — every type in PARAMETER_NODE_TYPES resolves via getParameterValue.
187
+ // Catches: type added to set but switch-case missing in getParameterValue.
188
+ // (The action-fx + loop-subject outages were exactly this.)
189
+ // =============================================================================
190
+
191
+ describe("PARAMETER_NODE_TYPES ↔ getParameterValue cases", () => {
192
+ for (const type of PARAMETER_NODE_TYPES) {
193
+ it(`getParameterValue resolves a value for "${type}"`, () => {
194
+ const sample = SAMPLE_DATA_BY_TYPE[type] ?? {}
195
+ const value = getParameterValue(sample, type)
196
+ expect(
197
+ value,
198
+ `getParameterValue returned undefined for "${type}". Either add a "case ${JSON.stringify(type)}" to getParameterValue, or update SAMPLE_DATA_BY_TYPE[${JSON.stringify(type)}] with a field this case reads.`,
199
+ ).toBeDefined()
200
+ })
201
+ }
202
+ })
203
+
204
+ // =============================================================================
205
+ // Test 3 — every non-exempt type returns a non-empty prompt hint.
206
+ // Catches: type added but missing from getParameterPromptHint switch — picker
207
+ // mounts and value resolves but the prompt fragment is never injected.
208
+ // =============================================================================
209
+
210
+ describe("PARAMETER_NODE_TYPES ↔ getParameterPromptHint cases", () => {
211
+ for (const type of PARAMETER_NODE_TYPES) {
212
+ if (HINT_EXEMPT.has(type)) continue
213
+ it(`getParameterPromptHint returns non-empty for "${type}"`, () => {
214
+ const sample = SAMPLE_DATA_BY_TYPE[type] ?? {}
215
+ const node = { id: "n1", type, data: sample }
216
+ const hint = getParameterPromptHint(node)
217
+ expect(
218
+ hint,
219
+ `getParameterPromptHint returned an empty string for "${type}". Either add a "case ${JSON.stringify(type)}" to getParameterPromptHint, add the type to HINT_EXEMPT (only if it's a pure runtime parameter), or fix SAMPLE_DATA_BY_TYPE[${JSON.stringify(type)}] so the catalog lookup succeeds.`,
220
+ ).toBeTruthy()
221
+ expect(typeof hint).toBe("string")
222
+ expect(hint.length).toBeGreaterThan(0)
223
+ })
224
+ }
225
+ })
226
+
227
+ // =============================================================================
228
+ // Test 4 — types in the exempt set are present in PARAMETER_NODE_TYPES (so
229
+ // HINT_EXEMPT can't drift to reference dead types).
230
+ // =============================================================================
231
+
232
+ describe("HINT_EXEMPT integrity", () => {
233
+ for (const type of HINT_EXEMPT) {
234
+ it(`exempt type "${type}" is still in PARAMETER_NODE_TYPES`, () => {
235
+ expect(PARAMETER_NODE_TYPES.has(type)).toBe(true)
236
+ })
237
+ it(`exempt type "${type}" really returns empty from getParameterPromptHint`, () => {
238
+ const sample = SAMPLE_DATA_BY_TYPE[type] ?? {}
239
+ const node = { id: "n1", type, data: sample }
240
+ expect(getParameterPromptHint(node)).toBe("")
241
+ })
242
+ }
243
+ })
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildAgeHint, buildPersonHints, getPerson } from "../person.js"
3
+
4
+ describe("buildAgeHint", () => {
5
+ it("returns the catalog promptHint for non-custom presets", () => {
6
+ expect(buildAgeHint("age-toddler", undefined)).toBe(getPerson("age-toddler")?.promptHint)
7
+ expect(buildAgeHint("age-30s", undefined)).toBe("in their 30s")
8
+ })
9
+
10
+ it("returns empty when ageId is undefined or unknown", () => {
11
+ expect(buildAgeHint(undefined, undefined)).toBe("")
12
+ expect(buildAgeHint(null, undefined)).toBe("")
13
+ expect(buildAgeHint("age-fictional", undefined)).toBe("")
14
+ })
15
+
16
+ it("ignores customAge unless ageId is the sentinel", () => {
17
+ // Without the sentinel the helper falls through to the catalog hint —
18
+ // a stale customAge from a previous selection must not leak.
19
+ expect(buildAgeHint("age-30s", 7)).toBe("in their 30s")
20
+ })
21
+
22
+ it("returns empty for custom sentinel without a number", () => {
23
+ expect(buildAgeHint("age-custom", undefined)).toBe("")
24
+ expect(buildAgeHint("age-custom", null)).toBe("")
25
+ expect(buildAgeHint("age-custom", Number.NaN)).toBe("")
26
+ })
27
+
28
+ it("generates age-band-tuned phrasing for the lower ranges", () => {
29
+ expect(buildAgeHint("age-custom", 0)).toBe("a newborn under 1 year old")
30
+ expect(buildAgeHint("age-custom", 1)).toBe("around 1 year old")
31
+ expect(buildAgeHint("age-custom", 3)).toBe("a toddler around 3 years old")
32
+ expect(buildAgeHint("age-custom", 5)).toBe("a young child around 5 years old")
33
+ expect(buildAgeHint("age-custom", 8)).toBe("a child around 8 years old")
34
+ expect(buildAgeHint("age-custom", 11)).toBe("a pre-teen around 11 years old")
35
+ expect(buildAgeHint("age-custom", 14)).toBe("14 years old, in their teens")
36
+ })
37
+
38
+ it("uses plain phrasing for adult ages so Age dim composes with Type", () => {
39
+ expect(buildAgeHint("age-custom", 22)).toBe("22 years old")
40
+ expect(buildAgeHint("age-custom", 28)).toBe("28 years old")
41
+ expect(buildAgeHint("age-custom", 47)).toBe("47 years old")
42
+ })
43
+
44
+ it("clamps and rounds outlandish numbers", () => {
45
+ expect(buildAgeHint("age-custom", -3)).toBe("a newborn under 1 year old")
46
+ expect(buildAgeHint("age-custom", 999)).toBe("120 years old")
47
+ expect(buildAgeHint("age-custom", 7.6)).toBe("a child around 8 years old")
48
+ })
49
+ })
50
+
51
+ describe("buildPersonHints with age-custom", () => {
52
+ it("emits the custom-age phrase in canonical position", () => {
53
+ const hints = buildPersonHints({
54
+ type: "woman",
55
+ age: "age-custom",
56
+ customAge: 8,
57
+ })
58
+ expect(hints).toContain("a woman")
59
+ expect(hints).toContain("a child around 8 years old")
60
+ // Age must follow type per PERSON_DIMENSION_ORDER.
61
+ expect(hints.indexOf("a child around 8 years old"))
62
+ .toBeGreaterThan(hints.indexOf("a woman"))
63
+ })
64
+
65
+ it("drops the age clause silently when age-custom has no number", () => {
66
+ const hints = buildPersonHints({
67
+ type: "woman",
68
+ age: "age-custom",
69
+ })
70
+ expect(hints).toEqual(["a woman"])
71
+ })
72
+
73
+ it("ignores customAge when a non-custom preset is selected", () => {
74
+ const hints = buildPersonHints({
75
+ age: "age-30s",
76
+ customAge: 7,
77
+ })
78
+ expect(hints).toEqual(["in their 30s"])
79
+ })
80
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ PEOPLE,
4
+ PERSON_DIMENSION_ORDER,
5
+ PERSON_FIELD_BY_DIMENSION,
6
+ MAX_SELECTED_BY_DIMENSION,
7
+ getPersonDimensionLimit,
8
+ } from "../person.js"
9
+ import { buildPickerAnalyzerSpec } from "../picker-analyzer-registry.js"
10
+
11
+ // PersonData array-capable fields (string | ReadonlyArray<string>).
12
+ const ARRAY_FIELDS = new Set([
13
+ "ethnicity", "regionalAesthetic", "lipState", "hairColor",
14
+ "skinTexture", "eyeColor", "eyeState", "distinctiveFeature",
15
+ ])
16
+
17
+ describe("person analyzer invariants", () => {
18
+ it("every dimension has a field mapping and a defined limit", () => {
19
+ for (const dim of PERSON_DIMENSION_ORDER) {
20
+ expect(PERSON_FIELD_BY_DIMENSION[dim]).toBeTruthy()
21
+ expect(getPersonDimensionLimit(dim)).toBeGreaterThanOrEqual(1)
22
+ }
23
+ expect(PERSON_DIMENSION_ORDER).toHaveLength(33)
24
+ })
25
+
26
+ it("cardinality bijection: limit>1 ⇔ field is array-capable", () => {
27
+ for (const dim of PERSON_DIMENSION_ORDER) {
28
+ const field = PERSON_FIELD_BY_DIMENSION[dim]
29
+ const multi = getPersonDimensionLimit(dim) > 1
30
+ expect(multi).toBe(ARRAY_FIELDS.has(field))
31
+ }
32
+ })
33
+
34
+ it("analyzer enum equals the catalog ids per dimension (ids, never labels)", () => {
35
+ const spec = buildPickerAnalyzerSpec("person")
36
+ for (const d of spec.dimensions) {
37
+ const catalog = PEOPLE.filter((p) => p.dimension === d.dimension && p.id !== "age-custom").map((p) => p.id)
38
+ expect([...d.entryIds].sort()).toEqual([...catalog].sort())
39
+ }
40
+ })
41
+
42
+ it("MAX_SELECTED keys are all valid dimensions", () => {
43
+ for (const k of Object.keys(MAX_SELECTED_BY_DIMENSION)) {
44
+ expect(PERSON_DIMENSION_ORDER).toContain(k)
45
+ }
46
+ })
47
+ })
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildPersonHints } from "../person.js"
3
+
4
+ /**
5
+ * Body proportion axes — Frame / Body Mass / Bust / Waist / Hips / Silhouette —
6
+ * replaced the old coarse Build + Body Proportions pickers. These tests pin the
7
+ * prompt-token composition (the part that actually drives generation), the
8
+ * "neutral emits nothing" contract, and the independent-axes guarantee the split
9
+ * exists to provide ("slim frame + full bust" was inexpressible before).
10
+ */
11
+ describe("buildPersonHints — body proportion axes", () => {
12
+ it("composes the target example (slim woman, full bust)", () => {
13
+ const hints = buildPersonHints({
14
+ frame: "frame-slim",
15
+ bodyMass: "mass-lean",
16
+ bust: "bust-full",
17
+ waist: "waist-defined",
18
+ silhouette: "silhouette-hourglass",
19
+ })
20
+ expect(hints.join(", ")).toBe(
21
+ "slim, lean build, full bust, defined waist, hourglass silhouette",
22
+ )
23
+ })
24
+
25
+ it("emits every axis in canonical order (frame, mass, bust, waist, hips, silhouette)", () => {
26
+ const joined = buildPersonHints({
27
+ frame: "frame-broad",
28
+ bodyMass: "mass-heavy",
29
+ bust: "bust-small",
30
+ waist: "waist-straight",
31
+ hips: "hips-wide",
32
+ silhouette: "silhouette-pear",
33
+ }).join(", ")
34
+ expect(joined).toBe(
35
+ "broad-framed, heavy-set build, small bust, straight waistline, wide hips, pear-shaped silhouette",
36
+ )
37
+ })
38
+
39
+ it("neutral values (Average / Balanced) contribute nothing", () => {
40
+ expect(
41
+ buildPersonHints({
42
+ frame: "frame-average",
43
+ bodyMass: "mass-average",
44
+ bust: "bust-average",
45
+ waist: "waist-average",
46
+ hips: "hips-balanced",
47
+ }),
48
+ ).toEqual([])
49
+ })
50
+
51
+ it("axes are independent — a slim frame pairs with a full bust (the bug the split fixes)", () => {
52
+ expect(buildPersonHints({ frame: "frame-slim", bust: "bust-full" })).toEqual([
53
+ "slim",
54
+ "full bust",
55
+ ])
56
+ })
57
+
58
+ it("Bust:Very Full emits the distinct very-full token", () => {
59
+ expect(buildPersonHints({ bust: "bust-very-full" })).toEqual(["very full bust"])
60
+ })
61
+
62
+ it("ignores stored values for the retired Build / Body Proportions keys (no migration)", () => {
63
+ // Old saved workflow data may still carry these now-removed fields; no
64
+ // dimension reads them, so they emit nothing and never crash the picker.
65
+ expect(buildPersonHints({ build: "voluptuous", bodyProportions: "proportions-hourglass" })).toEqual([])
66
+ })
67
+ })
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PEOPLE, PERSON_DIMENSION_ORDER, PERSON_FIELD_BY_DIMENSION, buildPersonHints, migratePersonValue, getPersonPromptHint } from "../index.js"
3
+
4
+ /**
5
+ * Facial-geometry layer: new Face dimensions + the eye/lips split, plus the
6
+ * backward-compatibility guarantees for already-saved Person data.
7
+ */
8
+ describe("facial-geometry catalog", () => {
9
+ const idsFor = (dim: string) => PEOPLE.filter((p) => p.dimension === dim).map((p) => p.id)
10
+
11
+ it("registers every new dimension in the canonical order", () => {
12
+ for (const dim of [
13
+ "cheekbones",
14
+ "facial-fullness",
15
+ "eyelid-type",
16
+ "canthal-tilt",
17
+ "eye-spacing",
18
+ "eye-set-brow",
19
+ "nose-tip",
20
+ "lip-fullness",
21
+ "lip-shape",
22
+ ]) {
23
+ expect(PERSON_DIMENSION_ORDER, dim).toContain(dim)
24
+ expect(PERSON_FIELD_BY_DIMENSION[dim as keyof typeof PERSON_FIELD_BY_DIMENSION], dim).toBeTruthy()
25
+ expect(idsFor(dim).length, dim).toBeGreaterThan(0)
26
+ }
27
+ })
28
+
29
+ it("dropped `lips` and `philtrum` as dimensions", () => {
30
+ expect(PERSON_DIMENSION_ORDER).not.toContain("lips")
31
+ expect(PERSON_DIMENSION_ORDER).not.toContain("philtrum")
32
+ })
33
+
34
+ it("eyelid-type exposes Standard / Hooded / Droopy / Deep-set (droopy is its own value)", () => {
35
+ expect(idsFor("eyelid-type")).toEqual(["eyelid-standard", "eye-hooded", "eye-droopy", "eye-deep-set"])
36
+ })
37
+
38
+ it("eye-shape keeps only anatomical shapes (lid/tilt/spacing moved out)", () => {
39
+ expect(idsFor("eye-shape")).toEqual([
40
+ "eye-almond",
41
+ "eye-round",
42
+ "eye-monolid",
43
+ "eye-double-eyelid",
44
+ "eye-wide",
45
+ "eye-narrow",
46
+ ])
47
+ })
48
+
49
+ it("every neutral default carries an EMPTY promptHint (enabling injects nothing)", () => {
50
+ for (const id of [
51
+ "cheekbones-average",
52
+ "facial-fullness-average",
53
+ "eyelid-standard",
54
+ "canthal-neutral",
55
+ "eye-spacing-average",
56
+ "eyeset-average",
57
+ "nose-tip-natural",
58
+ "lips-natural",
59
+ ]) {
60
+ expect(getPersonPromptHint(id), id).toBe("")
61
+ }
62
+ })
63
+
64
+ it("non-neutral options inject a real fragment", () => {
65
+ expect(getPersonPromptHint("cheekbones-sculpted")).toContain("cheekbones")
66
+ expect(getPersonPromptHint("eye-upturned")).toContain("canthal tilt")
67
+ expect(getPersonPromptHint("nose-tip-upturned")).toContain("nose tip")
68
+ expect(getPersonPromptHint("lips-full-lower")).toContain("lower lip")
69
+ })
70
+ })
71
+
72
+ describe("buildPersonHints — facial geometry composition", () => {
73
+ it("composes the geometry fields in canonical order", () => {
74
+ const hints = buildPersonHints({
75
+ cheekbones: "cheekbones-sculpted",
76
+ facialFullness: "facial-fullness-lean",
77
+ eyeShape: "eye-almond",
78
+ eyelidType: "eye-hooded",
79
+ canthalTilt: "eye-upturned",
80
+ lipFullness: "lips-full",
81
+ lipShape: "lips-cupids-bow",
82
+ })
83
+ const joined = hints.join(", ")
84
+ expect(joined).toContain("sharply sculpted high cheekbones")
85
+ expect(joined).toContain("almond-shaped eyes")
86
+ expect(joined).toContain("hooded eyes")
87
+ expect(joined).toContain("positive canthal tilt")
88
+ expect(joined).toContain("full plump lips")
89
+ expect(joined).toContain("cupid's bow")
90
+ // cheekbones precede eyes precede lips (Face dimension order)
91
+ expect(joined.indexOf("cheekbones")).toBeLessThan(joined.indexOf("almond"))
92
+ expect(joined.indexOf("almond")).toBeLessThan(joined.indexOf("cupid's bow"))
93
+ })
94
+
95
+ it("neutral picks contribute nothing", () => {
96
+ expect(buildPersonHints({ cheekbones: "cheekbones-average", noseTip: "nose-tip-natural" })).toEqual([])
97
+ })
98
+ })
99
+
100
+ describe("backward compatibility — legacy saved Person data still resolves", () => {
101
+ it("legacy eyeShape values (relocated dims) still emit their original hint", () => {
102
+ // Old data stored hooded/upturned/wide-set under `eyeShape`; the ids stayed
103
+ // in the catalog, so buildPersonHints (id-based) emits the same phrase.
104
+ expect(buildPersonHints({ eyeShape: "eye-hooded" })).toEqual([
105
+ "hooded eyes with the upper lid partially covering the crease",
106
+ ])
107
+ expect(buildPersonHints({ eyeShape: "eye-wide-set" }).join("")).toContain("wide-set eyes")
108
+ })
109
+
110
+ it("legacy `lips` value emits via the fallback when no new lip field is set", () => {
111
+ expect(buildPersonHints({ lips: "lips-cupids-bow" })).toEqual([
112
+ "a pronounced cupid's bow on the upper lip",
113
+ ])
114
+ })
115
+
116
+ it("does NOT double-emit when both legacy `lips` and a new lip field are set", () => {
117
+ const hints = buildPersonHints({ lips: "lips-thin", lipFullness: "lips-full" })
118
+ expect(hints).toEqual(["full plump lips"])
119
+ })
120
+ })
121
+
122
+ describe("migratePersonValue", () => {
123
+ it("relocates legacy eyeShape onto the split fields and clears the source", () => {
124
+ expect(migratePersonValue({ eyeShape: "eye-hooded" })).toEqual({ eyeShape: undefined, eyelidType: "eye-hooded" })
125
+ expect(migratePersonValue({ eyeShape: "eye-upturned" })).toEqual({ eyeShape: undefined, canthalTilt: "eye-upturned" })
126
+ expect(migratePersonValue({ eyeShape: "eye-close-set" })).toEqual({ eyeShape: undefined, eyeSpacing: "eye-close-set" })
127
+ })
128
+
129
+ it("relocates legacy lips onto fullness/shape", () => {
130
+ expect(migratePersonValue({ lips: "lips-full" })).toEqual({ lips: undefined, lipFullness: "lips-full" })
131
+ expect(migratePersonValue({ lips: "lips-small" })).toEqual({ lips: undefined, lipShape: "lips-small" })
132
+ })
133
+
134
+ it("leaves kept eye-shape values and unrelated keys untouched (same reference)", () => {
135
+ const kept = { eyeShape: "eye-almond", label: "Person", type: "woman" }
136
+ expect(migratePersonValue(kept)).toBe(kept)
137
+ })
138
+
139
+ it("never overwrites an already-set target field", () => {
140
+ const v = { eyeShape: "eye-hooded", eyelidType: "eye-deep-set" }
141
+ // target already set → leave both as-is (no clobber)
142
+ expect(migratePersonValue(v)).toBe(v)
143
+ })
144
+
145
+ it("is idempotent", () => {
146
+ const once = migratePersonValue({ eyeShape: "eye-hooded", lips: "lips-cupids-bow" })
147
+ const twice = migratePersonValue(once)
148
+ expect(twice).toEqual(once)
149
+ })
150
+
151
+ it("preserves the same prompt output before and after migration", () => {
152
+ const legacy = { eyeShape: "eye-upturned", lips: "lips-full" }
153
+ const migrated = migratePersonValue(legacy)
154
+ expect(buildPersonHints(migrated)).toEqual(buildPersonHints(legacy))
155
+ })
156
+ })