@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.
- package/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- 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
|
+
})
|