@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,161 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ PEOPLE,
4
+ buildPersonHints,
5
+ getPerson,
6
+ PERSON_DIMENSION_LABELS,
7
+ PERSON_FIELD_BY_DIMENSION,
8
+ PERSON_DIMENSION_ORDER,
9
+ } from "../person.js"
10
+
11
+ const REGIONAL_ENTRIES = PEOPLE.filter((p) => p.dimension === "regional-aesthetic")
12
+
13
+ describe("regional-aesthetic dimension — wiring", () => {
14
+ it("registers the dimension in the order array", () => {
15
+ expect(PERSON_DIMENSION_ORDER).toContain("regional-aesthetic")
16
+ })
17
+
18
+ it("has a human-readable display label", () => {
19
+ expect(PERSON_DIMENSION_LABELS["regional-aesthetic"]).toBe("Regional Aesthetic")
20
+ })
21
+
22
+ it("maps the dimension to the regionalAesthetic field on PersonValue", () => {
23
+ expect(PERSON_FIELD_BY_DIMENSION["regional-aesthetic"]).toBe("regionalAesthetic")
24
+ })
25
+ })
26
+
27
+ describe("regional-aesthetic catalog — coverage and structure", () => {
28
+ it("ships the expected total entry count", () => {
29
+ expect(REGIONAL_ENTRIES.length).toBe(86)
30
+ })
31
+
32
+ it.each([
33
+ ["USA — Mainstream", 20],
34
+ ["USA — African-American", 6],
35
+ ["Europe", 16],
36
+ ["Asia", 12],
37
+ ["Latin America", 7],
38
+ ["Middle East", 7],
39
+ ["North Africa", 3],
40
+ ["Sub-Saharan Africa", 11],
41
+ ["Oceania", 4],
42
+ ])("has exactly %i entries in the %s group", (group, expectedCount) => {
43
+ const count = REGIONAL_ENTRIES.filter((p) => p.group === group).length
44
+ expect(count).toBe(expectedCount)
45
+ })
46
+
47
+ it("gives every entry a unique id", () => {
48
+ const ids = REGIONAL_ENTRIES.map((p) => p.id)
49
+ expect(new Set(ids).size).toBe(ids.length)
50
+ })
51
+
52
+ it("gives every entry a non-empty label, description, promptHint, and group", () => {
53
+ for (const e of REGIONAL_ENTRIES) {
54
+ expect(e.label.trim()).not.toBe("")
55
+ expect(e.description.trim()).not.toBe("")
56
+ expect(e.promptHint.trim()).not.toBe("")
57
+ expect(e.group?.trim() ?? "").not.toBe("")
58
+ }
59
+ })
60
+ })
61
+
62
+ describe("regional-aesthetic catalog — vibe-only invariant", () => {
63
+ // The dimension MUST NOT hard-code visuals owned by other dimensions
64
+ // (skin tone, hair color, hair style, wardrobe). Otherwise a user picking
65
+ // RegionalAesthetic + SkinTone + HairColor sees conflicting prompt
66
+ // fragments. This test scans every promptHint for forbidden tokens.
67
+ const FORBIDDEN_TOKENS = [
68
+ // Skin tone — Skin Tone dimension owns these
69
+ /\b(?:fair|pale|olive|tan|tanned|bronzed|dark|deep|ebony|porcelain|alabaster)\s+skin\b/i,
70
+ // Hair color — Hair Color dimension owns these
71
+ /\b(?:blonde|brunette|black|brown|red|auburn|gray|silver|white)\s+hair\b/i,
72
+ // Hair style — Styling.hair-cut dimension owns these.
73
+ // `afro` is excluded ONLY when it's a standalone hairstyle word —
74
+ // the hyphenated "Afro-glam" / "Afro-fashion" are aesthetic-name
75
+ // labels for fashion movements, not the hairstyle.
76
+ /\bafro\b(?!-)/i,
77
+ /\b(?:braided|braids|dreadlocks|buzz-cut|bob|ponytail|bun|topknot)\b/i,
78
+ // Wardrobe — Styling dimension owns these (specific garments only —
79
+ // generic wardrobe-mood words like "tailored" or "boho" are fine because
80
+ // they describe a vibe, not a specific item)
81
+ /\b(?:wearing|dressed in|outfit|kente cloth|kimono)\b/i,
82
+ ]
83
+
84
+ it.each(REGIONAL_ENTRIES.map((e) => [e.id, e.promptHint]))(
85
+ "%s promptHint avoids forbidden visual-overlap tokens",
86
+ (_id, promptHint) => {
87
+ for (const pattern of FORBIDDEN_TOKENS) {
88
+ expect(promptHint).not.toMatch(pattern)
89
+ }
90
+ },
91
+ )
92
+ })
93
+
94
+ describe("regional-aesthetic — promptHint emission", () => {
95
+ it("emits a single hint when one id is picked", () => {
96
+ const hints = buildPersonHints({ regionalAesthetic: "cali-beach" })
97
+ expect(hints).toContain(getPerson("cali-beach")!.promptHint)
98
+ })
99
+
100
+ it("emits two independent hints when two ids are picked (multi-pick)", () => {
101
+ const hints = buildPersonHints({
102
+ regionalAesthetic: ["nyc-fashion", "parisienne"],
103
+ })
104
+ expect(hints).toContain(getPerson("nyc-fashion")!.promptHint)
105
+ expect(hints).toContain(getPerson("parisienne")!.promptHint)
106
+ })
107
+
108
+ it("ignores unknown ids without throwing", () => {
109
+ const hints = buildPersonHints({ regionalAesthetic: "no-such-id" })
110
+ expect(hints.every((h) => !h.includes("no-such-id"))).toBe(true)
111
+ })
112
+
113
+ it("emits nothing when the field is empty", () => {
114
+ expect(buildPersonHints({ regionalAesthetic: undefined })).toEqual([])
115
+ expect(buildPersonHints({ regionalAesthetic: "" })).toEqual([])
116
+ expect(buildPersonHints({ regionalAesthetic: [] })).toEqual([])
117
+ })
118
+
119
+ it("composes cleanly with ethnicity (no hint conflict)", () => {
120
+ // The whole point of the dimension: a Tokyo Harajuku vibe should be
121
+ // independent of ethnicity. Ethnicity can be Senegalese without
122
+ // contradicting the Harajuku styling cue.
123
+ const hints = buildPersonHints({
124
+ ethnicity: "senegalese",
125
+ regionalAesthetic: "tokyo-harajuku",
126
+ })
127
+ // Both hints should be present (Senegalese fallthrough — even unknown
128
+ // IDs are fine; we're testing that the regional hint ships alongside)
129
+ expect(hints.some((h) => h.includes("Tokyo Harajuku"))).toBe(true)
130
+ })
131
+ })
132
+
133
+ describe("regional-aesthetic — Sub-Saharan Africa colonial-overlay tagging", () => {
134
+ // Anglophone / Francophone / Lusophone tagging is part of the dimension's
135
+ // design. Catch regressions where a tag is dropped from an entry's
136
+ // description.
137
+ const SSA = REGIONAL_ENTRIES.filter((p) => p.group === "Sub-Saharan Africa")
138
+
139
+ it.each([
140
+ ["lagos-afro-glam", "🇬🇧"],
141
+ ["accra-afro-fashion", "🇬🇧"],
142
+ ["dakar-francophone", "🇫🇷"],
143
+ ["abidjan-cosmopolitan", "🇫🇷"],
144
+ ["kinshasa-sape", "🇫🇷"],
145
+ ["nairobi-cosmopolitan", "🇬🇧"],
146
+ ["swahili-coast", "🇬🇧"],
147
+ ["johannesburg-urban", "🇬🇧"],
148
+ ["cape-town-cosmopolitan", "🇬🇧"],
149
+ ["luanda-lusophone", "🇵🇹"],
150
+ ])("%s carries the %s overlay flag in its description", (id, flag) => {
151
+ const entry = SSA.find((p) => p.id === id)
152
+ expect(entry).toBeDefined()
153
+ expect(entry!.description).toContain(flag)
154
+ })
155
+
156
+ it("addis-habesha is intentionally untagged (Ethiopia was never colonized)", () => {
157
+ const entry = SSA.find((p) => p.id === "addis-habesha")
158
+ expect(entry).toBeDefined()
159
+ expect(entry!.description).not.toMatch(/🇬🇧|🇫🇷|🇵🇹/)
160
+ })
161
+ })
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PERSON_DIMENSION_SECTIONS, PERSON_DIMENSION_ORDER } from "../index.js"
3
+
4
+ describe("PERSON_DIMENSION_SECTIONS", () => {
5
+ it("partitions every PersonDimension exactly once", () => {
6
+ const inSections = PERSON_DIMENSION_SECTIONS.flatMap((s) => s.dimensions)
7
+ expect([...inSections].sort()).toEqual([...PERSON_DIMENSION_ORDER].sort())
8
+ expect(new Set(inSections).size).toBe(inSections.length)
9
+ })
10
+ it("has 6 sections with the expected counts", () => {
11
+ // Identity = 4; Body = 6: frame, body-mass, bust, waist, hips, silhouette
12
+ // (the six independent proportion axes that replaced Build + Body Proportions).
13
+ // Face = 14: face-shape, jawline, cheekbones, facial-fullness, eye-shape,
14
+ // eyelid-type, canthal-tilt, eye-spacing, eye-set-brow, nose, nose-tip,
15
+ // lip-fullness, lip-shape, lip-state (facial-geometry layer).
16
+ expect(PERSON_DIMENSION_SECTIONS.map((s) => s.dimensions.length)).toEqual([4, 6, 14, 3, 4, 2])
17
+ })
18
+ })
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ PEOPLE,
4
+ PERSON_FIELD_BY_DIMENSION,
5
+ getPersonDimensionLimit,
6
+ } from "../person.js"
7
+ import {
8
+ buildPickerAnalyzerSpec,
9
+ buildPickerZodSchema,
10
+ } from "../picker-analyzer-registry.js"
11
+ // NOTE: the existing test file already imports `buildPickerAnalyzerSpec` from
12
+ // "../picker-analyzer-registry.js" — do NOT re-import it here (duplicate-identifier
13
+ // error). Import only the NEW symbols, and reuse the existing buildPickerAnalyzerSpec.
14
+ import { PICKER_TYPES, ANALYZABLE_PICKER_TYPES, isAnalyzablePicker } from "../index.js"
15
+ import { STYLINGS } from "../styling.js"
16
+ import { FRAMINGS } from "../framing.js"
17
+ import { LENSES } from "../lens.js"
18
+ import { CAMERA_FORMATS } from "../camera-format.js"
19
+
20
+ describe("buildPickerAnalyzerSpec(person)", () => {
21
+ const spec = buildPickerAnalyzerSpec("person")
22
+
23
+ it("covers all 33 dimensions with the correct field + limit", () => {
24
+ expect(spec.dimensions).toHaveLength(33)
25
+ for (const d of spec.dimensions) {
26
+ expect(PERSON_FIELD_BY_DIMENSION[d.dimension]).toBe(d.field)
27
+ expect(d.limit).toBe(getPersonDimensionLimit(d.dimension))
28
+ expect(d.entryIds.length).toBeGreaterThan(0)
29
+ }
30
+ })
31
+
32
+ it("uses catalog ids as the enum vocabulary (never labels)", () => {
33
+ const hairColor = spec.dimensions.find((d) => d.dimension === "hair-color")!
34
+ const catalogIds = PEOPLE.filter((p) => p.dimension === "hair-color").map((p) => p.id)
35
+ expect([...hairColor.entryIds].sort()).toEqual([...catalogIds].sort())
36
+ })
37
+
38
+ it("excludes the age-custom sentinel from the age enum", () => {
39
+ const age = spec.dimensions.find((d) => d.dimension === "age")!
40
+ expect(age.entryIds).not.toContain("age-custom")
41
+ })
42
+
43
+ it("Zod schema rejects off-catalog values and over-limit arrays", () => {
44
+ const schema = buildPickerZodSchema(spec)
45
+ expect(schema.safeParse({ "hair-color": ["not-a-real-id"] }).success).toBe(false)
46
+ // distinctive-features limit is 3 → 4 fails. Real catalog ids.
47
+ const four = ["feature-freckles", "feature-glasses", "feature-scar", "feature-dimples"]
48
+ .filter((id) => PEOPLE.some((p) => p.id === id))
49
+ expect(four).toHaveLength(4)
50
+ expect(schema.safeParse({ "distinctive-features": four }).success).toBe(false)
51
+ // a valid single-pick value passes
52
+ const someType = PEOPLE.find((p) => p.dimension === "type")!.id
53
+ expect(schema.safeParse({ type: someType }).success).toBe(true)
54
+ })
55
+ })
56
+
57
+ describe("registry membership (Task 2: person only)", () => {
58
+ it("exposes person via the registry-derived set", () => {
59
+ expect(PICKER_TYPES).toContain("person")
60
+ expect(ANALYZABLE_PICKER_TYPES.has("person")).toBe(true)
61
+ expect(isAnalyzablePicker("person")).toBe(true)
62
+ expect(isAnalyzablePicker("not-a-picker")).toBe(false)
63
+ })
64
+ it("person spec is unchanged after generalization", () => {
65
+ const spec = buildPickerAnalyzerSpec("person")
66
+ expect(spec.toolName).toBe("emit_person")
67
+ // age dimension still excludes the age-custom sentinel
68
+ const age = spec.dimensions.find((d) => d.dimension === "age")!
69
+ expect(age.entryIds).not.toContain("age-custom")
70
+ // ethnicity is a 2-pick array dimension
71
+ expect(spec.dimensions.find((d) => d.dimension === "ethnicity")!.limit).toBe(2)
72
+ // every dimension now carries a human label
73
+ expect(spec.dimensions.find((d) => d.dimension === "type")!.label).toBe("Type")
74
+ })
75
+ })
76
+
77
+ describe("registry invariants (all analyzable pickers)", () => {
78
+ it("registers the batch", () => {
79
+ expect(new Set(PICKER_TYPES)).toEqual(
80
+ new Set(["person", "styling", "framing", "lens", "camera-format"]),
81
+ )
82
+ })
83
+ it.each(PICKER_TYPES)("%s: every dimension enum equals its catalog ids and is non-empty", (t) => {
84
+ const spec = buildPickerAnalyzerSpec(t)
85
+ expect(spec.dimensions.length).toBeGreaterThan(0)
86
+ for (const d of spec.dimensions) {
87
+ expect(d.entryIds.length).toBeGreaterThan(0)
88
+ expect(d.entryIds.length).toBe(new Set(d.entryIds).size) // no dupes
89
+ expect(d.field.length).toBeGreaterThan(0)
90
+ expect(d.label.length).toBeGreaterThan(0)
91
+ }
92
+ })
93
+ it("flat pickers are single limit-1 dimensions keyed by their field", () => {
94
+ expect(buildPickerAnalyzerSpec("lens").dimensions).toMatchObject([{ field: "lens", limit: 1 }])
95
+ expect(buildPickerAnalyzerSpec("camera-format").dimensions).toMatchObject([
96
+ { field: "cameraFormat", limit: 1 },
97
+ ])
98
+ })
99
+ it("multi-pick caps come through (styling jewelry=3, framing composition=2)", () => {
100
+ const styling = buildPickerAnalyzerSpec("styling")
101
+ expect(styling.dimensions.find((d) => d.dimension === "jewelry")!.limit).toBe(3)
102
+ const framing = buildPickerAnalyzerSpec("framing")
103
+ expect(framing.dimensions.find((d) => d.dimension === "composition")!.limit).toBe(2)
104
+ })
105
+ it("lens enum equals LENSES ids", () => {
106
+ expect(buildPickerAnalyzerSpec("lens").dimensions[0].entryIds).toEqual(LENSES.map((l) => l.id))
107
+ })
108
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ summarizePickerCatalogs,
4
+ projectPickerCatalog,
5
+ getPickerCatalog,
6
+ PICKER_CATALOGS,
7
+ } from "../picker-catalogs.js"
8
+
9
+ describe("summarizePickerCatalogs", () => {
10
+ it("returns one summary per catalog with a positive optionCount", () => {
11
+ const summary = summarizePickerCatalogs()
12
+ expect(summary.length).toBe(PICKER_CATALOGS.length)
13
+ for (const s of summary) {
14
+ expect(typeof s.nodeType).toBe("string")
15
+ expect(s.optionCount).toBeGreaterThan(0)
16
+ if (s.kind === "single") expect(typeof s.valueField).toBe("string")
17
+ else expect(Array.isArray(s.fields)).toBe(true)
18
+ }
19
+ })
20
+ })
21
+
22
+ describe("projectPickerCatalog", () => {
23
+ it("compact (default) drops description + promptHint on single-dim options", () => {
24
+ const setting = getPickerCatalog("setting")!
25
+ const p = projectPickerCatalog(setting)
26
+ expect(p.detail).toBe("compact")
27
+ expect(p.kind).toBe("single")
28
+ const opt = p.options![0]
29
+ expect(opt.id).toBeTruthy()
30
+ expect(opt.label).toBeTruthy()
31
+ expect(opt.promptHint).toBeUndefined()
32
+ expect(opt.description).toBeUndefined()
33
+ })
34
+
35
+ it("full keeps description + promptHint", () => {
36
+ const setting = getPickerCatalog("setting")!
37
+ const p = projectPickerCatalog(setting, { detail: "full" })
38
+ expect(p.options![0].promptHint).toBeTruthy()
39
+ })
40
+
41
+ it("category filter narrows single-dim options to that category", () => {
42
+ const setting = getPickerCatalog("setting")!
43
+ const cat = setting.options![0].category!
44
+ const p = projectPickerCatalog(setting, { category: cat })
45
+ expect(p.options!.every((o) => o.category === cat)).toBe(true)
46
+ expect(p.options!.length).toBeLessThan(setting.options!.length)
47
+ })
48
+
49
+ it("field filter narrows multi-dim to one dimension", () => {
50
+ const person = getPickerCatalog("person")!
51
+ const field = person.dimensions![0].field
52
+ const p = projectPickerCatalog(person, { field })
53
+ expect(p.kind).toBe("multi")
54
+ expect(p.dimensions!.length).toBe(1)
55
+ expect(p.dimensions![0].field).toBe(field)
56
+ })
57
+
58
+ it("unknown category on a single-dim catalog yields empty options, no throw", () => {
59
+ const setting = getPickerCatalog("setting")!
60
+ const p = projectPickerCatalog(setting, { category: "__nope__" })
61
+ expect(p.kind).toBe("single")
62
+ expect(p.options).toEqual([])
63
+ })
64
+
65
+ it("unknown field on a multi-dim catalog yields empty dimensions, no throw", () => {
66
+ const person = getPickerCatalog("person")!
67
+ const p = projectPickerCatalog(person, { field: "__nope__" })
68
+ expect(p.kind).toBe("multi")
69
+ expect(p.dimensions).toEqual([])
70
+ })
71
+
72
+ it("cross-kind opts are ignored: field on single-dim leaves options intact", () => {
73
+ const setting = getPickerCatalog("setting")!
74
+ const p = projectPickerCatalog(setting, { field: "__nope__" })
75
+ expect(p.kind).toBe("single")
76
+ expect(p.options!.length).toBe(setting.options!.length)
77
+ })
78
+
79
+ it("cross-kind opts are ignored: category on multi-dim leaves dimensions intact", () => {
80
+ const person = getPickerCatalog("person")!
81
+ const p = projectPickerCatalog(person, { category: "__nope__" })
82
+ expect(p.kind).toBe("multi")
83
+ expect(p.dimensions!.length).toBe(person.dimensions!.length)
84
+ })
85
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { PICKER_CATALOGS, getPickerCatalog, listPickerCatalogs } from "../picker-catalogs.js"
3
+ import { getParameterPromptHint } from "../parameter-prompt-hint.js"
4
+
5
+ describe("PICKER_CATALOGS", () => {
6
+ const singles = PICKER_CATALOGS.filter((c) => c.kind === "single")
7
+ const multis = PICKER_CATALOGS.filter((c) => c.kind === "multi")
8
+
9
+ it("every single picker exposes options, each with a defined promptHint string", () => {
10
+ // promptHint may be "" for no-op options like "auto"/"none"; it must never
11
+ // be undefined (that would mean a catalog entry was missing the field).
12
+ expect(singles.length).toBeGreaterThan(0)
13
+ for (const c of singles) {
14
+ expect(c.options, c.nodeType).toBeDefined()
15
+ expect(c.options!.length, c.nodeType).toBeGreaterThan(0)
16
+ for (const o of c.options!) {
17
+ expect(typeof o.promptHint, `${c.nodeType}:${o.id}`).toBe("string")
18
+ }
19
+ }
20
+ })
21
+
22
+ it("every multi picker exposes fields and no flattened options", () => {
23
+ expect(multis.length).toBeGreaterThan(0)
24
+ for (const c of multis) {
25
+ expect(c.fields, c.nodeType).toBeDefined()
26
+ expect(c.fields!.length, c.nodeType).toBeGreaterThan(0)
27
+ expect(c.options, c.nodeType).toBeUndefined()
28
+ }
29
+ })
30
+
31
+ // The four Object-entity catalogs (animal/vehicle/weapon/furniture) carry no
32
+ // `promptHint` field — the registry synthesizes it. Prove that synthesized text
33
+ // is byte-identical to what getParameterPromptHint actually injects, so the
34
+ // duplication cannot silently drift.
35
+ it.each(["animal", "vehicle", "weapon", "furniture"])(
36
+ "%s: option.promptHint matches getParameterPromptHint output",
37
+ (nodeType) => {
38
+ const c = getPickerCatalog(nodeType)
39
+ expect(c?.options?.length, nodeType).toBeGreaterThan(0)
40
+ for (const o of c!.options!) {
41
+ const hint = getParameterPromptHint({ type: nodeType, data: { [c!.valueField!]: o.id } })
42
+ expect(hint, `${nodeType}:${o.id}`).toBe(o.promptHint)
43
+ }
44
+ },
45
+ )
46
+
47
+ it("lookups resolve by nodeType and catalogId", () => {
48
+ expect(getPickerCatalog("mood")?.nodeType).toBe("mood")
49
+ expect(getPickerCatalog("setting")?.catalogId).toBe("setting")
50
+ expect(getPickerCatalog("does-not-exist")).toBeUndefined()
51
+ expect(listPickerCatalogs().length).toBe(PICKER_CATALOGS.length)
52
+ })
53
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { MAX_SELECTED_BY_STYLING_DIMENSION, getStylingDimensionLimit, MAX_SELECTED_BY_FRAMING_CATEGORY, getFramingCategoryLimit } from "../index.js"
3
+
4
+ describe("styling dimension limits", () => {
5
+ it("matches the picker's caps", () => {
6
+ expect(MAX_SELECTED_BY_STYLING_DIMENSION.jewelry).toBe(3)
7
+ expect(MAX_SELECTED_BY_STYLING_DIMENSION["wardrobe-state"]).toBe(3)
8
+ expect(MAX_SELECTED_BY_STYLING_DIMENSION["hair-state"]).toBe(2)
9
+ expect(getStylingDimensionLimit("jewelry")).toBe(3)
10
+ expect(getStylingDimensionLimit("makeup")).toBe(1) // unlisted → 1
11
+ })
12
+ })
13
+
14
+ describe("framing category limits", () => {
15
+ it("matches the picker's caps", () => {
16
+ expect(MAX_SELECTED_BY_FRAMING_CATEGORY.composition).toBe(2)
17
+ expect(getFramingCategoryLimit("composition")).toBe(2)
18
+ expect(getFramingCategoryLimit("angle")).toBe(1) // unlisted → 1
19
+ })
20
+ })
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt, buildImagePromptSegments } from "../prompt-builder.js"
3
+ import type { BuildImagePromptConfig, PromptSegment } from "../prompt-builder.js"
4
+ import type { ConnectedReference } from "@nodaro/shared"
5
+
6
+ /**
7
+ * Characterization fixtures for `buildImagePrompt` — written BEFORE the
8
+ * segments refactor (Task 16) so the refactor provably changes nothing.
9
+ *
10
+ * The matrix exercises every final-assembly branch the segment work touches:
11
+ * - directive prepend (connectedReferences path, `Use these references…`)
12
+ * - style append (both paths: `\nStyle: …`)
13
+ * - native-negative routing vs `Avoid:` append (both paths)
14
+ * - truncation (both paths, > 2000 chars → slice(0,1997) + "...")
15
+ * - empty body + style (legacy path, leading-`\n` Style suffix)
16
+ *
17
+ * After the Task-16 refactor these snapshots MUST NOT change — that is the
18
+ * whole point of this file.
19
+ */
20
+
21
+ /**
22
+ * connectedReferences fixture ref. Minimum REAL `ConnectedReference` fields
23
+ * (id / defaultName / source / url) plus a `description` so the emitted
24
+ * directive carries a descriptor. (The plan draft used a `defaultLabel` field
25
+ * + `as never` cast; `ConnectedReference` has no `defaultLabel` — the real
26
+ * descriptor field is `description`.) The token in the prompt is
27
+ * `{image:1:background}` (LABELED): a bare `{image:N}` yields NO identity
28
+ * directive (`collectIdentities` skips label-less tokens), and `wired-image`
29
+ * is excluded from the wired-location/object canonical fallback — so without a
30
+ * label the `Use these references…` block would never appear. The `background`
31
+ * label routes through the BACKGROUND_LABELS verb ("use as the
32
+ * background/setting"). `nano-banana-pro` is in
33
+ * MODELS_WITH_REFERENCE_IMAGE_SUPPORT, so the URL is also returned.
34
+ */
35
+ const castleRef: ConnectedReference = {
36
+ id: "r1",
37
+ defaultName: "Castle",
38
+ source: "wired-image",
39
+ url: "https://example.com/castle.png",
40
+ description: "a stone castle",
41
+ }
42
+
43
+ /**
44
+ * A `wired-object` ref with a URL + description but NO `{image:N}` token in the
45
+ * body. It earns a canonical-fallback directive (wired-object/wired-location are
46
+ * the two sources `buildNonCharacterDirectives` auto-emits), so the directive
47
+ * block PREPENDS via the "Use these references…/Compose them naturally into a
48
+ * single image: <body>" wrap — but because the body carries no token,
49
+ * `expandImageRefTokensForRefs` is a no-op and `applyReferenceOrder` is skipped
50
+ * (single URL). The modeled `directivesPrefix` + body therefore reconstruct the
51
+ * final string exactly — this is the clean prepend case the segment capture is
52
+ * designed for, distinct from SEGMENT_FIXTURES' castle fixture (whose
53
+ * `{image:1:background}` token gets rewritten AFTER the marks are captured, so
54
+ * its segments deliberately collapse to the single-segment fallback).
55
+ */
56
+ const swordRef: ConnectedReference = {
57
+ id: "o1",
58
+ defaultName: "Sword",
59
+ source: "wired-object",
60
+ url: "https://example.com/sword.png",
61
+ description: "an ornate longsword",
62
+ }
63
+
64
+ export const SEGMENT_FIXTURES: ReadonlyArray<{ name: string; config: BuildImagePromptConfig }> = [
65
+ { name: "plain prompt", config: { prompt: "a knight on a cliff", provider: "nano-banana-pro" } },
66
+ { name: "style appended", config: { prompt: "a knight", provider: "nano-banana-pro", style: "cinematic" } },
67
+ { name: "negative appended as Avoid (no native field)", config: { prompt: "a knight", provider: "gpt-image", negativePrompt: "blurry, watermark" } },
68
+ { name: "negative native (imagen4)", config: { prompt: "a knight", provider: "imagen4", negativePrompt: "blurry" } },
69
+ { name: "truncation", config: { prompt: "x".repeat(2100), provider: "nano-banana-pro" } },
70
+ { name: "empty prompt + style", config: { prompt: "", provider: "nano-banana-pro", style: "noir" } },
71
+ {
72
+ name: "connected references directives",
73
+ config: {
74
+ prompt: "a knight at {image:1:background}",
75
+ provider: "nano-banana-pro",
76
+ connectedReferences: [castleRef],
77
+ },
78
+ },
79
+ ]
80
+
81
+ describe("buildImagePrompt characterization (pre/post segment refactor)", () => {
82
+ it("produces stable output across the fixture matrix", () => {
83
+ // Snapshot the CURRENT behavior. After the Task-16 refactor these
84
+ // snapshots MUST NOT change — that is the whole point of this test.
85
+ for (const f of SEGMENT_FIXTURES) {
86
+ expect(buildImagePrompt(f.config)).toMatchSnapshot(f.name)
87
+ }
88
+ })
89
+
90
+ it("native-negative fixtures route negatives where expected", () => {
91
+ // Guards the two negative-routing branches the snapshots also capture, but
92
+ // as explicit assertions so a future enum change to NATIVE_NEGATIVE_PROMPT_MODELS
93
+ // surfaces here (not just as an opaque snapshot diff).
94
+ const imagen4 = buildImagePrompt({ prompt: "a knight", provider: "imagen4", negativePrompt: "blurry" })
95
+ expect(imagen4.nativeNegativePrompt).toBe("blurry")
96
+ expect(imagen4.prompt).toBe("a knight")
97
+
98
+ const gptImage = buildImagePrompt({ prompt: "a knight", provider: "gpt-image", negativePrompt: "blurry, watermark" })
99
+ expect(gptImage.nativeNegativePrompt).toBeUndefined()
100
+ expect(gptImage.prompt).toBe("a knight\nAvoid: blurry, watermark")
101
+ })
102
+
103
+ it("connected-references fixture exercises the directive block", () => {
104
+ // The whole reason the fixture uses a LABELED {image:1:background} token:
105
+ // it must actually produce the `Use these references…` directive branch.
106
+ const out = buildImagePrompt(SEGMENT_FIXTURES[6].config)
107
+ expect(out.prompt).toContain("Use these references for the output image:")
108
+ expect(out.prompt).toContain("Image 1 (background")
109
+ expect(out.referenceImageUrls).toEqual(["https://example.com/castle.png"])
110
+ })
111
+ })
112
+
113
+ describe("buildImagePromptSegments", () => {
114
+ it("join(segments) equals the legacy prompt for every fixture", () => {
115
+ // The absolute invariant: regardless of which assembly branch fires (and
116
+ // whether the modeled marks reconstruct or the join-fallback collapses to a
117
+ // single segment), the decomposition must rejoin to the byte-identical
118
+ // legacy prompt. Also asserts the other returned fields are untouched.
119
+ for (const f of SEGMENT_FIXTURES) {
120
+ const legacy = buildImagePrompt(f.config)
121
+ const seg = buildImagePromptSegments(f.config)
122
+ expect(seg.prompt).toBe(legacy.prompt)
123
+ expect(seg.nativeNegativePrompt).toBe(legacy.nativeNegativePrompt)
124
+ expect(seg.referenceImageUrls).toEqual(legacy.referenceImageUrls)
125
+ expect(seg.segments.map((s) => s.text).join("")).toBe(legacy.prompt)
126
+ }
127
+ })
128
+
129
+ it("tags style and Avoid suffixes", () => {
130
+ const seg = buildImagePromptSegments({ prompt: "a knight", provider: "gpt-image", style: "noir", negativePrompt: "blurry" })
131
+ expect(seg.segments.some((s) => s.origin === "style" && s.text.startsWith("\nStyle:"))).toBe(true)
132
+ expect(seg.segments.some((s) => s.origin === "negative" && s.text.startsWith("\nAvoid:"))).toBe(true)
133
+ // And of course the join still reconstructs the assembled prompt.
134
+ expect(seg.segments.map((s) => s.text).join("")).toBe(seg.prompt)
135
+ })
136
+
137
+ it("preserves caller body segments when the body is untouched", () => {
138
+ // Legacy path (no connectedReferences) with no style/negative/truncation →
139
+ // the body string equals config.prompt verbatim, so caller-provided segments
140
+ // that join to it survive with their origins intact.
141
+ const body: PromptSegment[] = [
142
+ { text: "a knight, ", origin: "user" },
143
+ { text: "golden hour light", origin: "picker" },
144
+ ]
145
+ const seg = buildImagePromptSegments({ prompt: "a knight, golden hour light", provider: "nano-banana-pro" }, body)
146
+ expect(seg.segments.filter((s) => s.origin === "picker")).toHaveLength(1)
147
+ expect(seg.segments).toEqual(body)
148
+ expect(seg.segments.map((s) => s.text).join("")).toBe(seg.prompt)
149
+ })
150
+
151
+ it("collapses body segments when they don't reconstruct the body", () => {
152
+ const seg = buildImagePromptSegments(
153
+ { prompt: "a knight", provider: "nano-banana-pro" },
154
+ [{ text: "different text", origin: "picker" }],
155
+ )
156
+ expect(seg.segments).toEqual([{ text: "a knight", origin: "user" }])
157
+ })
158
+
159
+ it("captures the directive prefix as a mention segment on the real prepend branch", () => {
160
+ // A token-free body + a wired-object ref → the directive block prepends via
161
+ // the "Use these references…/Compose them naturally into a single image: …"
162
+ // wrap, and (no token expansion, single URL → no reorder) the modeled marks
163
+ // reconstruct exactly. This proves the prepend capture works on the actual
164
+ // directive branch (not just the collapse fallback).
165
+ const config: BuildImagePromptConfig = {
166
+ prompt: "a knight in a courtyard",
167
+ provider: "nano-banana-pro",
168
+ connectedReferences: [swordRef],
169
+ }
170
+ const legacy = buildImagePrompt(config)
171
+ const seg = buildImagePromptSegments(config)
172
+ // Sanity: the fixture really does hit the prepend branch.
173
+ expect(legacy.prompt).toContain("Use these references for the output image:")
174
+ expect(legacy.prompt).toContain("Compose them naturally into a single image:")
175
+ // Join invariant holds AND a mention-origin segment carries the directives.
176
+ expect(seg.segments.map((s) => s.text).join("")).toBe(legacy.prompt)
177
+ const mention = seg.segments.find((s) => s.origin === "mention")
178
+ expect(mention).toBeDefined()
179
+ expect(mention?.text.startsWith("Use these references for the output image:")).toBe(true)
180
+ // The body survived as its own user segment (prefix correctly stripped).
181
+ expect(seg.segments.some((s) => s.origin === "user" && s.text === "a knight in a courtyard")).toBe(true)
182
+ })
183
+ })