@nodaro/prompts 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.cjs +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ /**
6
+ * Seam whitespace at a resolved mention (image path).
7
+ *
8
+ * A live prod payload assembled to
9
+ *
10
+ * "the person from reference image A and reference image C walking …"
11
+ *
12
+ * — TWO spaces after the character phrase. An editor serializes a mention chip
13
+ * as its token plus its own trailing space, and the prose that follows carries
14
+ * the space the author typed, so an ordinary sentence reaches the route as
15
+ * `@panda:1 and @panda2:2 …`. The VIDEO core never shows this: every return of
16
+ * `resolveVideoReferenceCore` runs `resolveReferenceTokens`, whose
17
+ * `[^\S\r\n]{2,}` collapse tidies the gap. The image path had no such tidy, so
18
+ * the doubled space landed in the model's prompt exactly where the reference is
19
+ * being described.
20
+ *
21
+ * `spliceMentionPhrase` now collapses the horizontal whitespace on either side
22
+ * of each splice seam — scoped to the seam, so prose the author double-spaced
23
+ * elsewhere is left alone and an already-single-spaced prompt is byte-identical.
24
+ */
25
+
26
+ const panda: ConnectedReference = {
27
+ id: "c1", defaultName: "Panda", source: "wired-character",
28
+ url: "https://cdn/panda.png", characterSlug: "panda",
29
+ }
30
+ const panda2: ConnectedReference = {
31
+ id: "n2", defaultName: "Panda2", source: "wired-image", url: "https://cdn/panda2.png",
32
+ }
33
+ const beach: ConnectedReference = {
34
+ id: "l1", defaultName: "Beach", source: "wired-location",
35
+ url: "https://cdn/beach.png", locationSlug: "beach",
36
+ }
37
+
38
+ const REFS = [panda, panda2, beach]
39
+
40
+ /** Mention URLs merge character → location → image, so the letters are
41
+ * A = the character, B = the location, C = the named image. */
42
+ const EXPECTED =
43
+ "the person from reference image A and reference image C walking in "
44
+ + "the location from reference image B at dusk"
45
+
46
+ const build = (prompt: string) => buildImagePrompt({
47
+ prompt,
48
+ connectedReferences: REFS,
49
+ provider: "nano-banana-pro",
50
+ referenceFormat: "hybrid",
51
+ }).prompt
52
+
53
+ describe("mention splice — seam whitespace", () => {
54
+ it("Tal's shape: character + image + location in one sentence, single-spaced throughout", () => {
55
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
56
+ })
57
+
58
+ it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
59
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
60
+ })
61
+
62
+ it("collapses on the LEADING side of a seam too", () => {
63
+ expect(build("@panda:1 and @panda2:2 walking in @beach:3 at dusk")).toBe(EXPECTED)
64
+ })
65
+
66
+ it("never emits a doubled space, whichever side of the chip carries it", () => {
67
+ for (const prompt of [
68
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
69
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
70
+ "@panda:1 and @panda2:2 walking in @beach:3 at dusk",
71
+ ]) {
72
+ expect(build(prompt)).toBe(EXPECTED)
73
+ }
74
+ })
75
+
76
+ it("leaves a doubled space the author put in their own prose alone (seam-scoped, not a body tidy)", () => {
77
+ expect(build("@panda:1 walks slowly past @beach:3")).toBe(
78
+ "the person from reference image A walks slowly past the location from reference image B",
79
+ )
80
+ })
81
+
82
+ it("preserves newlines — the collapse class is horizontal-only", () => {
83
+ expect(build("@panda:1 runs\n\n@beach:3 at dusk")).toBe(
84
+ "the person from reference image A runs\n\nthe location from reference image B at dusk",
85
+ )
86
+ })
87
+ })
88
+
89
+ // The `{image:N:label}` positional pill is a mention chip like any other:
90
+ // `buildRefPillNodes` appends its own trailing space to EVERY pill it builds,
91
+ // the positional `imageRef` node included. Its hybrid expansion used a raw
92
+ // `.replace`, so the same doubled space survived on this path after the three
93
+ // `@`-mention resolvers were fixed.
94
+ describe("mention splice — the {image:N:label} positional pill", () => {
95
+ const hat: ConnectedReference = {
96
+ id: "n1", defaultName: "Hat", source: "wired-image", url: "https://cdn/hat.png",
97
+ }
98
+ const coat: ConnectedReference = {
99
+ id: "n2", defaultName: "Coat", source: "wired-image", url: "https://cdn/coat.png",
100
+ }
101
+ const buildTokens = (prompt: string, refs: readonly ConnectedReference[]) => buildImagePrompt({
102
+ prompt,
103
+ connectedReferences: [...refs],
104
+ referenceImageUrls: refs.map((r) => r.url!),
105
+ provider: "nano-banana-pro",
106
+ referenceFormat: "hybrid",
107
+ }).prompt
108
+
109
+ it("collapses the trailing seam the pill's own space creates", () => {
110
+ expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
111
+ "A man wearing the hat from reference image A in the park",
112
+ )
113
+ })
114
+
115
+ it("the already-single-spaced prompt is byte-identical", () => {
116
+ expect(buildTokens("a man wearing {image:1:hat} in the park", [hat])).toBe(
117
+ "A man wearing the hat from reference image A in the park",
118
+ )
119
+ })
120
+
121
+ it("two pills in one sentence both splice cleanly (right-to-left offsets stay valid)", () => {
122
+ expect(buildTokens("a man wearing {image:1:hat} and {image:2:coat} walks", [hat, coat])).toBe(
123
+ "A man wearing the hat from reference image A and the coat from reference image B walks",
124
+ )
125
+ })
126
+
127
+ it("leaves a doubled space the author put in their own prose alone", () => {
128
+ expect(buildTokens("{image:1:hat} worn loosely", [hat])).toBe(
129
+ "The hat from reference image A worn loosely",
130
+ )
131
+ })
132
+
133
+ it("an out-of-range token stays visible, and its whitespace with it", () => {
134
+ expect(buildTokens("a man wearing {image:7:hat} in the park", [hat])).toContain(
135
+ "{image:7:hat} in the park",
136
+ )
137
+ })
138
+ })
139
+
140
+ // Indentation is structure, not a seam. A run that OPENS a line is the author's
141
+ // layout (a shot list, a numbered beat sheet), so the leading collapse is
142
+ // anchored to prose on the same line via `(?<=\S)`. Without that anchor a
143
+ // mention starting an indented line flattened the indent to a single space —
144
+ // the same class of damage as merging paragraphs, and it would have made the
145
+ // "an already-single-spaced prompt is byte-identical" claim false for every
146
+ // multi-line prompt.
147
+ describe("mention splice — line-initial indentation survives", () => {
148
+ /** One character ref only, so no unmentioned ref appends a canonical phrase
149
+ * and the assertions can be byte-exact on the multi-line shape. */
150
+ const buildOne = (prompt: string) => buildImagePrompt({
151
+ prompt,
152
+ connectedReferences: [panda],
153
+ provider: "nano-banana-pro",
154
+ referenceFormat: "hybrid",
155
+ }).prompt
156
+
157
+ it("a mention that opens an indented line keeps the indent verbatim", () => {
158
+ expect(buildOne("Scene:\n @panda:1 stands")).toBe(
159
+ "Scene:\n the person from reference image A stands",
160
+ )
161
+ })
162
+
163
+ it("indent preserved AND the trailing seam still collapses on the same mention", () => {
164
+ expect(buildOne("Scene:\n @panda:1 stands")).toBe(
165
+ "Scene:\n the person from reference image A stands",
166
+ )
167
+ })
168
+
169
+ it("a leading run at the very start of the prompt is indentation too", () => {
170
+ expect(buildOne(" @panda:1 stands")).toBe(
171
+ " the person from reference image A stands",
172
+ )
173
+ })
174
+
175
+ it("mid-line prose before the run still collapses (the anchor is `\\S`, not `^`)", () => {
176
+ expect(buildOne("Scene:\n a man and @panda:1 stands")).toBe(
177
+ "Scene:\n a man and the person from reference image A stands",
178
+ )
179
+ })
180
+ })
181
+
182
+ // Wired CREATURE / OBJECT mentions (`@<name-slug>:<index>[:<role>]`) are chips
183
+ // too — `buildRefPillNodes` gives them the same trailing space every other pill
184
+ // gets — so their resolver splices through `spliceMentionPhrase` alongside the
185
+ // character, location and named-image ones. Without that, the seam bug would
186
+ // have been fixed for three mention kinds and reintroduced by the fourth.
187
+ describe("mention splice — wired creature / object mentions", () => {
188
+ const nessie: ConnectedReference = {
189
+ id: "cr1", defaultName: "Nessie", source: "wired-creature", url: "https://cdn/nessie.png",
190
+ }
191
+ const chair: ConnectedReference = {
192
+ id: "ob1", defaultName: "Chair", source: "wired-object", url: "https://cdn/chair.png",
193
+ }
194
+
195
+ /** BOTH entities are mentioned in every case, so neither appends a trailing
196
+ * canonical phrase and the assertions stay byte-exact on one line. */
197
+ const buildEntities = (prompt: string) => buildImagePrompt({
198
+ prompt,
199
+ connectedReferences: [nessie, chair],
200
+ provider: "nano-banana-pro",
201
+ referenceFormat: "hybrid",
202
+ }).prompt
203
+
204
+ const ENTITIES_EXPECTED =
205
+ "the creature from reference image A looms over "
206
+ + "the object from reference image B in the fog"
207
+
208
+ it("collapses the seam whitespace the chips' own trailing spaces create", () => {
209
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
210
+ })
211
+
212
+ it("the already-single-spaced prompt is byte-identical (the collapse needs a 2+ run)", () => {
213
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
214
+ })
215
+
216
+ it("collapses on the LEADING side of an entity seam too", () => {
217
+ expect(buildEntities("@nessie:1 looms over @chair:2 in the fog")).toBe(ENTITIES_EXPECTED)
218
+ })
219
+
220
+ it("never emits a doubled space, whichever side of the chip carries it", () => {
221
+ for (const prompt of [
222
+ "@nessie:1 looms over @chair:2 in the fog",
223
+ "@nessie:1 looms over @chair:2 in the fog",
224
+ "@nessie:1 looms over @chair:2 in the fog",
225
+ ]) {
226
+ expect(buildEntities(prompt)).toBe(ENTITIES_EXPECTED)
227
+ }
228
+ })
229
+
230
+ it("a ROLE segment splices the same way", () => {
231
+ expect(buildEntities("@nessie:1:markings over @chair:2:material in the fog")).toBe(
232
+ "the markings from reference image A over "
233
+ + "the material from reference image B in the fog",
234
+ )
235
+ })
236
+
237
+ it("leaves a doubled space the author put in their own prose alone (seam-scoped)", () => {
238
+ expect(buildEntities("@nessie:1 looms slowly over @chair:2 in the fog")).toBe(
239
+ "the creature from reference image A looms slowly over "
240
+ + "the object from reference image B in the fog",
241
+ )
242
+ })
243
+
244
+ it("preserves newlines — the collapse class is horizontal-only", () => {
245
+ expect(buildEntities("@nessie:1 looms\n\n@chair:2 in the fog")).toBe(
246
+ "the creature from reference image A looms\n\n"
247
+ + "the object from reference image B in the fog",
248
+ )
249
+ })
250
+
251
+ it("an entity mention that opens an indented line keeps the indent verbatim", () => {
252
+ expect(buildEntities("Scene:\n @nessie:1 looms over @chair:2 in the fog")).toBe(
253
+ "Scene:\n the creature from reference image A looms over "
254
+ + "the object from reference image B in the fog",
255
+ )
256
+ })
257
+ })
@@ -0,0 +1,345 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import {
3
+ STYLE_SECTION_HEADER,
4
+ asBodyClauses,
5
+ composeSectionedPrompt,
6
+ endsInsideStyleSection,
7
+ insertBeforeStyleSection,
8
+ partitionStyleClauses,
9
+ renderStyleSection,
10
+ sectionedClauseCosts,
11
+ splitStyleSection,
12
+ styleSectionFromClauses,
13
+ } from "../prompt-style-section.js"
14
+ import {
15
+ DIRECTION_FIELDS,
16
+ FILM_STYLE_KEYS,
17
+ IMAGE_HINT_MODE_DEFAULT,
18
+ VIDEO_HINT_MODE_DEFAULT,
19
+ directionFieldsForSurface,
20
+ renderDirectionHints,
21
+ } from "../direction-registry.js"
22
+ import { joinPromptHints } from "../prompt-hint-join.js"
23
+ import { getStylePromptHint } from "../style.js"
24
+ import { getColorLookPromptHint } from "../color-look.js"
25
+ import { getEraPromptHint } from "../era.js"
26
+ import { getCameraFormatPromptHint } from "../camera-format.js"
27
+ import { getFramingPromptHint } from "../framing.js"
28
+ import { getLightingPromptHint } from "../lighting.js"
29
+ import { getCameraMotionTerm } from "../camera-motions.js"
30
+ import { getTransitionTerm } from "../transitions.js"
31
+
32
+ /**
33
+ * THE `[style]` SECTION CONTRACT, at the level it is defined: clauses in, one
34
+ * string out. The composers (`assembleImageInput`, `composeVideoPromptText`)
35
+ * are pinned against the same shape in their own suites; what lives here is the
36
+ * grammar itself — which clause lands on which line, and the exact bytes.
37
+ *
38
+ * The header is asserted as a LITERAL everywhere below, never through
39
+ * `STYLE_SECTION_HEADER`, so a typo in the constant fails here instead of
40
+ * silently redefining the contract.
41
+ */
42
+
43
+ // Real catalog ids: every `get*PromptHint` returns "" on a miss, so a made-up
44
+ // id would make most of these assertions vacuously pass.
45
+ const STYLE = "anime" // look • film
46
+ const COLOR_LOOK = "teal-orange" // look • film
47
+ const ERA = "1920s-flapper" // look • film
48
+ const CAMERA_FORMAT = "16mm-film" // look • film
49
+ const SHOT_SIZE = "wide-shot" // look • scene
50
+ const TIME_OF_DAY = "golden-hour" // look • scene
51
+ const CAMERA_MOTION = "handheld" // motion • body
52
+ const TRANSITION = "cross-dissolve" // motion • body
53
+
54
+ const IMAGE = { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT } as const
55
+ const VIDEO = { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT } as const
56
+
57
+ describe("the section header", () => {
58
+ it("is exactly `[style]:`, lowercase", () => {
59
+ expect(STYLE_SECTION_HEADER).toBe("[style]:")
60
+ })
61
+ })
62
+
63
+ describe("FILM_STYLE_KEYS — the film/scene split lives in the registry", () => {
64
+ it("names the five film rows, in table order", () => {
65
+ expect(FILM_STYLE_KEYS).toEqual([
66
+ "cameraFormat",
67
+ "colorLook",
68
+ "style",
69
+ "era",
70
+ "cameraFormatId",
71
+ ])
72
+ })
73
+
74
+ it("derives from the table's own `styleGroup` column (no second list)", () => {
75
+ expect(FILM_STYLE_KEYS).toEqual(
76
+ DIRECTION_FIELDS.filter((f) => "styleGroup" in f && f.styleGroup === "film").map(
77
+ (f) => f.key,
78
+ ),
79
+ )
80
+ })
81
+
82
+ it("marks only LOOK rows as film (a motion row could never reach the section)", () => {
83
+ for (const spec of DIRECTION_FIELDS) {
84
+ if ("styleGroup" in spec && spec.styleGroup === "film") {
85
+ expect(spec.family, spec.key).toBe("look")
86
+ }
87
+ }
88
+ })
89
+ })
90
+
91
+ describe("partitionStyleClauses — which slot a clause lands in", () => {
92
+ it("sends the MOTION family to the body and the LOOK family to the section", () => {
93
+ const slots = new Map(
94
+ partitionStyleClauses(
95
+ { cameraMotion: CAMERA_MOTION, transition: TRANSITION, style: STYLE, shotSize: SHOT_SIZE },
96
+ VIDEO,
97
+ ).map((c) => [c.text, c.slot]),
98
+ )
99
+ expect(slots.get(getCameraMotionTerm(CAMERA_MOTION))).toBe("body")
100
+ expect(slots.get(getTransitionTerm(TRANSITION))).toBe("body")
101
+ expect(slots.get(getStylePromptHint(STYLE))).toBe("film")
102
+ expect(slots.get(getFramingPromptHint(SHOT_SIZE))).toBe("scene")
103
+ })
104
+
105
+ it("leaves the image surface with no body clause at all (no motion row folds there)", () => {
106
+ // The surface filter and the family split are deliberately aligned: every
107
+ // image-surface direction row is `look`, so an image `[style]` section
108
+ // carries the WHOLE direction fold and the body carries none of it.
109
+ expect(directionFieldsForSurface("image").every((f) => f.family === "look")).toBe(true)
110
+ const everyImageKey = Object.fromEntries(
111
+ directionFieldsForSurface("image").map((f) => [f.key, ""]),
112
+ )
113
+ expect(
114
+ partitionStyleClauses({ ...everyImageKey, style: STYLE, shotSize: SHOT_SIZE }, IMAGE).every(
115
+ (c) => c.slot !== "body",
116
+ ),
117
+ ).toBe(true)
118
+ })
119
+
120
+ it("keeps registry table order inside each slot", () => {
121
+ const direction = { style: STYLE, colorLook: COLOR_LOOK, shotSize: SHOT_SIZE, timeOfDay: TIME_OF_DAY }
122
+ expect(partitionStyleClauses(direction, IMAGE).map((c) => c.text)).toEqual(
123
+ renderDirectionHints(direction, IMAGE),
124
+ )
125
+ })
126
+
127
+ it("returns nothing for an absent, empty or unresolvable direction", () => {
128
+ expect(partitionStyleClauses(undefined, IMAGE)).toEqual([])
129
+ expect(partitionStyleClauses({}, IMAGE)).toEqual([])
130
+ expect(partitionStyleClauses({ style: "__no_such_id__" }, IMAGE)).toEqual([])
131
+ })
132
+ })
133
+
134
+ describe("renderStyleSection — the two lines", () => {
135
+ it("puts the film line first and the scene line second, each `. `-joined", () => {
136
+ expect(
137
+ renderStyleSection(
138
+ {
139
+ cameraFormat: CAMERA_FORMAT,
140
+ colorLook: COLOR_LOOK,
141
+ style: STYLE,
142
+ era: ERA,
143
+ shotSize: SHOT_SIZE,
144
+ timeOfDay: TIME_OF_DAY,
145
+ },
146
+ IMAGE,
147
+ ),
148
+ ).toBe(
149
+ "[style]:\n" +
150
+ [
151
+ getCameraFormatPromptHint(CAMERA_FORMAT),
152
+ getColorLookPromptHint(COLOR_LOOK),
153
+ getStylePromptHint(STYLE),
154
+ getEraPromptHint(ERA),
155
+ ].join(". ") +
156
+ "\n" +
157
+ [getFramingPromptHint(SHOT_SIZE), getLightingPromptHint(TIME_OF_DAY)].join(". "),
158
+ )
159
+ })
160
+
161
+ it("omits the film line entirely when no film dimension is selected", () => {
162
+ expect(renderStyleSection({ shotSize: SHOT_SIZE }, IMAGE)).toBe(
163
+ `[style]:\n${getFramingPromptHint(SHOT_SIZE)}`,
164
+ )
165
+ })
166
+
167
+ it("omits the scene line entirely when no other look dimension is selected", () => {
168
+ expect(renderStyleSection({ style: STYLE }, IMAGE)).toBe(
169
+ `[style]:\n${getStylePromptHint(STYLE)}`,
170
+ )
171
+ })
172
+
173
+ it("renders NOTHING when the fold carries no look clause", () => {
174
+ expect(renderStyleSection(undefined, VIDEO)).toBe("")
175
+ expect(renderStyleSection({}, VIDEO)).toBe("")
176
+ expect(renderStyleSection({ cameraMotion: CAMERA_MOTION, transition: TRANSITION }, VIDEO)).toBe("")
177
+ })
178
+
179
+ it("never indents a line and never ends with a newline", () => {
180
+ // The video reference resolver collapses 2+ HORIZONTAL spaces unanchored,
181
+ // so an indented section line would come back flattened — the section is
182
+ // written flush-left instead of relying on the collapse leaving it alone.
183
+ const section = renderStyleSection(
184
+ { style: STYLE, colorLook: COLOR_LOOK, shotSize: SHOT_SIZE },
185
+ IMAGE,
186
+ )
187
+ for (const line of section.split("\n")) expect(line).toBe(line.trimStart())
188
+ expect(section.endsWith("\n")).toBe(false)
189
+ expect(section).not.toMatch(/[^\S\r\n]{2,}/)
190
+ })
191
+
192
+ it("agrees with the clause-level renderer the composers use", () => {
193
+ const direction = { style: STYLE, shotSize: SHOT_SIZE }
194
+ expect(renderStyleSection(direction, VIDEO)).toBe(
195
+ styleSectionFromClauses(partitionStyleClauses(direction, VIDEO)),
196
+ )
197
+ })
198
+ })
199
+
200
+ describe("composeSectionedPrompt — body, gap, section", () => {
201
+ const FILM = getStylePromptHint(STYLE)
202
+ const SCENE = getFramingPromptHint(SHOT_SIZE)
203
+ const clauses = [
204
+ { text: "a knight rides", slot: "body" },
205
+ { text: FILM, slot: "film" },
206
+ { text: SCENE, slot: "scene" },
207
+ ] as const
208
+
209
+ it("joins body clauses with `. ` and hangs the section off a blank line", () => {
210
+ expect(composeSectionedPrompt("at dusk", clauses, "")).toBe(
211
+ `at dusk. a knight rides\n\n[style]:\n${FILM}\n${SCENE}`,
212
+ )
213
+ })
214
+
215
+ it("keeps the structured fragment last IN THE BODY, ahead of the section", () => {
216
+ expect(composeSectionedPrompt("at dusk", clauses, "Subject: a knight.")).toBe(
217
+ `at dusk. a knight rides. Subject: a knight.\n\n[style]:\n${FILM}\n${SCENE}`,
218
+ )
219
+ })
220
+
221
+ it("emits NO header and no extra newline when nothing reaches the section", () => {
222
+ // Byte-identical to the plain hint join — this is what keeps every
223
+ // look-free caller (and every fully-shed one) exactly where it was.
224
+ const bodyOnly = [{ text: "a knight rides", slot: "body" }] as const
225
+ expect(composeSectionedPrompt("at dusk", bodyOnly, "")).toBe(
226
+ joinPromptHints("at dusk", ["a knight rides"]),
227
+ )
228
+ expect(composeSectionedPrompt("at dusk", bodyOnly, "")).not.toContain("[style]")
229
+ })
230
+
231
+ it("returns the prompt VERBATIM AND UNTRIMMED with no clause and no fragment", () => {
232
+ expect(composeSectionedPrompt(" a knight \n", [], "")).toBe(" a knight \n")
233
+ expect(composeSectionedPrompt(undefined, [], "")).toBeUndefined()
234
+ })
235
+
236
+ it("TRIMS the prompt when the section is the only thing folded", () => {
237
+ // The section counts as "something folded", so the body is trimmed exactly
238
+ // as the hint-join branch trims it — otherwise the blank line would inherit
239
+ // the prompt's trailing whitespace.
240
+ expect(composeSectionedPrompt(" a knight \n", [{ text: FILM, slot: "film" }], "")).toBe(
241
+ `a knight\n\n[style]:\n${FILM}`,
242
+ )
243
+ })
244
+
245
+ it("drops the gap for a blank or absent prompt (never a leading newline)", () => {
246
+ const only = [{ text: FILM, slot: "film" }] as const
247
+ expect(composeSectionedPrompt("", only, "")).toBe(`[style]:\n${FILM}`)
248
+ expect(composeSectionedPrompt(" ", only, "")).toBe(`[style]:\n${FILM}`)
249
+ expect(composeSectionedPrompt(undefined, only, "")).toBe(`[style]:\n${FILM}`)
250
+ })
251
+
252
+ it("never ends the composed prompt with a newline", () => {
253
+ for (const prompt of ["a knight", "", undefined]) {
254
+ expect(composeSectionedPrompt(prompt, clauses, "Subject: a knight.")!.endsWith("\n")).toBe(
255
+ false,
256
+ )
257
+ }
258
+ })
259
+
260
+ it("marks every subject clause as body", () => {
261
+ expect(asBodyClauses(["a woman in her 30s", "wearing a red coat"])).toEqual([
262
+ { text: "a woman in her 30s", slot: "body" },
263
+ { text: "wearing a red coat", slot: "body" },
264
+ ])
265
+ })
266
+ })
267
+
268
+ describe("sectionedClauseCosts — what each clause really costs", () => {
269
+ const clauses = [
270
+ { text: "a knight rides", slot: "body" },
271
+ { text: getStylePromptHint(STYLE), slot: "film" },
272
+ { text: getFramingPromptHint(SHOT_SIZE), slot: "scene" },
273
+ ] as const
274
+
275
+ it("is the exact composed-length delta of each clause, tail-first", () => {
276
+ const costs = sectionedClauseCosts("at dusk", clauses, "")
277
+ expect(costs).toHaveLength(clauses.length)
278
+ for (let kept = 0; kept < clauses.length; kept++) {
279
+ const below = composeSectionedPrompt("at dusk", clauses.slice(0, kept), "")?.length ?? 0
280
+ const at = composeSectionedPrompt("at dusk", clauses.slice(0, kept + 1), "")?.length ?? 0
281
+ expect(costs[kept]).toBe(at - below)
282
+ }
283
+ })
284
+
285
+ it("charges the LAST surviving look clause for the header it keeps alive", () => {
286
+ // "\n\n[style]:\n" is 11 characters that only come back when the section
287
+ // disappears entirely — so the first look clause carries them, and a shed
288
+ // that drops it reclaims more than the clause's own text.
289
+ const costs = sectionedClauseCosts("at dusk", clauses, "")
290
+ expect(costs[1]).toBe(getStylePromptHint(STYLE).length + "\n\n[style]:\n".length)
291
+ // The second look clause only brings its own line separator.
292
+ expect(costs[2]).toBe(getFramingPromptHint(SHOT_SIZE).length + "\n".length)
293
+ // A body clause brings the ". " it was joined with.
294
+ expect(costs[0]).toBe("a knight rides".length + ". ".length)
295
+ })
296
+ })
297
+
298
+ describe("the section boundary — what a later assembler may append", () => {
299
+ const FILM = getStylePromptHint(STYLE)
300
+ const clauses = [{ text: FILM, slot: "film" }] as const
301
+ const composed = composeSectionedPrompt("a knight", clauses, "")!
302
+ const bodyless = composeSectionedPrompt("", clauses, "")!
303
+
304
+ it("splits a composed prompt into its body and its section", () => {
305
+ expect(splitStyleSection(composed)).toEqual({ body: "a knight", section: `[style]:\n${FILM}` })
306
+ })
307
+
308
+ it("splits the body-less form, where the section IS the prompt", () => {
309
+ expect(splitStyleSection(bodyless)).toEqual({ body: "", section: `[style]:\n${FILM}` })
310
+ })
311
+
312
+ it("reports no section for a prompt that carries none", () => {
313
+ expect(splitStyleSection("a knight")).toEqual({ body: "a knight", section: "" })
314
+ })
315
+
316
+ it("inserts body lines AHEAD of the section, keeping the look clauses last", () => {
317
+ expect(insertBeforeStyleSection(composed, ["the person from reference image A"])).toBe(
318
+ `a knight\nthe person from reference image A\n\n[style]:\n${FILM}`,
319
+ )
320
+ expect(insertBeforeStyleSection(bodyless, ["the person from reference image A"])).toBe(
321
+ `the person from reference image A\n\n[style]:\n${FILM}`,
322
+ )
323
+ })
324
+
325
+ it("is the plain `\\n` join with no section — the byte-parity path", () => {
326
+ // What every appender emitted before the section existed, including the
327
+ // leading newline an empty prompt produces. Anything else would move bytes
328
+ // on the look-free runs, which are most of them.
329
+ expect(insertBeforeStyleSection("a knight", ["a", "b"])).toBe("a knight\na\nb")
330
+ expect(insertBeforeStyleSection("", ["a"])).toBe("\na")
331
+ })
332
+
333
+ it("is a no-op with no lines to add", () => {
334
+ expect(insertBeforeStyleSection(composed, [])).toBe(composed)
335
+ })
336
+
337
+ it("knows when a prompt ends INSIDE the section", () => {
338
+ expect(endsInsideStyleSection(composed)).toBe(true)
339
+ expect(endsInsideStyleSection(bodyless)).toBe(true)
340
+ // A blank line closes the header's scope, and the next appender sees it.
341
+ expect(endsInsideStyleSection(`${composed}\n\nStyle: cinematic`)).toBe(false)
342
+ expect(endsInsideStyleSection("a knight")).toBe(false)
343
+ expect(endsInsideStyleSection("")).toBe(false)
344
+ })
345
+ })