@nodaro/prompts 1.9.0 → 1.11.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.
- package/dist/index.cjs +426 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +830 -51
- package/dist/index.d.ts +830 -51
- package/dist/index.js +410 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +3 -1
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/assemble-image-input.ts +45 -46
- package/src/assemble-video-input.ts +89 -0
- package/src/character-fx.ts +102 -19
- package/src/direction-registry.ts +354 -0
- package/src/index.ts +8 -2
- package/src/picker-catalogs.ts +18 -1
- package/src/prompt-builder.ts +188 -1
- package/src/prompt-hint-join.ts +30 -0
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/read-node-direction.ts +174 -0
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/video-reference-resolver.ts +78 -39
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
3
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* P3 — named-image mentions `@<name-slug>:<index>[:<role>]`.
|
|
7
|
+
*
|
|
8
|
+
* The image analog of `location-convergence-image.test.ts`. `referenceFormat`
|
|
9
|
+
* is passed EXPLICITLY on every hybrid case: `packages/prompts` is env-free
|
|
10
|
+
* (`content-free-contract.test.ts`), so `NODE_ENV=test` only steers the
|
|
11
|
+
* *callers*, never this package.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const town: ConnectedReference = {
|
|
15
|
+
id: "n1", defaultName: "Town", source: "wired-image", url: "https://cdn/town.png",
|
|
16
|
+
}
|
|
17
|
+
const plaza: ConnectedReference = {
|
|
18
|
+
id: "n2", defaultName: "Plaza", source: "wired-image", url: "https://cdn/plaza.png",
|
|
19
|
+
}
|
|
20
|
+
const kira: ConnectedReference = {
|
|
21
|
+
id: "c1", defaultName: "Kira", source: "wired-character",
|
|
22
|
+
url: "https://cdn/kira.png", characterSlug: "kira",
|
|
23
|
+
}
|
|
24
|
+
const library: ConnectedReference = {
|
|
25
|
+
id: "l1", defaultName: "Old Library", source: "wired-location",
|
|
26
|
+
url: "https://cdn/library.png", locationSlug: "old-library",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("named-image mentions resolve on the image hybrid path", () => {
|
|
30
|
+
it("bare @town:3 → the reference's bare binding, token consumed, URL attached once", () => {
|
|
31
|
+
const out = buildImagePrompt({
|
|
32
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
33
|
+
connectedReferences: [town],
|
|
34
|
+
provider: "nano-banana-pro",
|
|
35
|
+
referenceFormat: "hybrid",
|
|
36
|
+
})
|
|
37
|
+
// `wired-image`'s default role is "" (DEFAULT_LABEL_BY_SOURCE) →
|
|
38
|
+
// `roleToPhrase("", binding)` returns the BARE binding.
|
|
39
|
+
expect(out.prompt).toContain("reference image A")
|
|
40
|
+
expect(out.prompt).not.toContain("@town")
|
|
41
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png"])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("@town:3:background → 'the background from reference image A'", () => {
|
|
45
|
+
const out = buildImagePrompt({
|
|
46
|
+
prompt: "a wide shot of @town:3:background at dusk",
|
|
47
|
+
connectedReferences: [town],
|
|
48
|
+
provider: "nano-banana-pro",
|
|
49
|
+
referenceFormat: "hybrid",
|
|
50
|
+
})
|
|
51
|
+
expect(out.prompt).toContain("the background from reference image A")
|
|
52
|
+
expect(out.prompt).not.toContain("@town")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("a CUSTOM role passes through verbatim → 'the signage from reference image A'", () => {
|
|
56
|
+
const out = buildImagePrompt({
|
|
57
|
+
prompt: "a wide shot of @town:3:signage at dusk",
|
|
58
|
+
connectedReferences: [town],
|
|
59
|
+
provider: "nano-banana-pro",
|
|
60
|
+
referenceFormat: "hybrid",
|
|
61
|
+
})
|
|
62
|
+
expect(out.prompt).toContain("the signage from reference image A")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("the node's own defaultRole is the fallback when the token carries none", () => {
|
|
66
|
+
const out = buildImagePrompt({
|
|
67
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
68
|
+
connectedReferences: [{ ...town, defaultRole: "texture" } as ConnectedReference],
|
|
69
|
+
provider: "nano-banana-pro",
|
|
70
|
+
referenceFormat: "hybrid",
|
|
71
|
+
})
|
|
72
|
+
expect(out.prompt).toContain("the texture from reference image A")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("mixed character + location + image: A = character, B = location, C = image", () => {
|
|
76
|
+
const out = buildImagePrompt({
|
|
77
|
+
prompt: "@kira:1 walks past @old-library:2 toward @town:3:background",
|
|
78
|
+
connectedReferences: [kira, library, town],
|
|
79
|
+
provider: "nano-banana-pro",
|
|
80
|
+
referenceFormat: "hybrid",
|
|
81
|
+
})
|
|
82
|
+
// Mention URLs merge in pass order: characters, then locations, then images.
|
|
83
|
+
expect(out.referenceImageUrls).toEqual([
|
|
84
|
+
"https://cdn/kira.png",
|
|
85
|
+
"https://cdn/library.png",
|
|
86
|
+
"https://cdn/town.png",
|
|
87
|
+
])
|
|
88
|
+
// The letters are the assertion; the location pass's own default role
|
|
89
|
+
// wording belongs to `location-convergence-image.test.ts`, not here.
|
|
90
|
+
expect(out.prompt).toContain("from reference image A")
|
|
91
|
+
expect(out.prompt).toContain("from reference image B")
|
|
92
|
+
expect(out.prompt).toContain("the background from reference image C")
|
|
93
|
+
expect(out.prompt).not.toContain("@town")
|
|
94
|
+
expect(out.prompt).not.toContain("@kira")
|
|
95
|
+
expect(out.prompt).not.toContain("@old-library")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("PRECEDENCE: a character and an image sharing a name resolve as the CHARACTER", () => {
|
|
99
|
+
const townCharacter: ConnectedReference = {
|
|
100
|
+
id: "c2", defaultName: "Town", source: "wired-character",
|
|
101
|
+
url: "https://cdn/town-character.png", characterSlug: "town",
|
|
102
|
+
}
|
|
103
|
+
const out = buildImagePrompt({
|
|
104
|
+
prompt: "@town:1 stands in the square",
|
|
105
|
+
connectedReferences: [townCharacter, town],
|
|
106
|
+
provider: "nano-banana-pro",
|
|
107
|
+
referenceFormat: "hybrid",
|
|
108
|
+
})
|
|
109
|
+
// The character pass splices the token out first, so the image pass finds
|
|
110
|
+
// nothing to bind and the image ref keeps its plain auto-attach slot.
|
|
111
|
+
expect(out.prompt).toContain("the person from reference image A")
|
|
112
|
+
expect(out.referenceImageUrls?.[0]).toBe("https://cdn/town-character.png")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("DUPLICATE SLUGS bind FIRST-WINS", () => {
|
|
116
|
+
const first: ConnectedReference = {
|
|
117
|
+
id: "u1", defaultName: "Upload Image", source: "wired-image", url: "https://cdn/first.png",
|
|
118
|
+
}
|
|
119
|
+
const second: ConnectedReference = {
|
|
120
|
+
id: "u2", defaultName: "Upload Image", source: "wired-image", url: "https://cdn/second.png",
|
|
121
|
+
}
|
|
122
|
+
const out = buildImagePrompt({
|
|
123
|
+
prompt: "a shot of @upload-image:1",
|
|
124
|
+
connectedReferences: [first, second],
|
|
125
|
+
provider: "nano-banana-pro",
|
|
126
|
+
referenceFormat: "hybrid",
|
|
127
|
+
})
|
|
128
|
+
// The mention re-seats the FIRST ref, so it takes slot A; the unmentioned
|
|
129
|
+
// second ref still auto-attaches after it.
|
|
130
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/first.png", "https://cdn/second.png"])
|
|
131
|
+
expect(out.prompt).toContain("reference image A")
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("~lock forces an identity-lock line; ~nolock suppresses a ref-level one", () => {
|
|
135
|
+
const locked = buildImagePrompt({
|
|
136
|
+
prompt: "a shot of @town:1~lock",
|
|
137
|
+
connectedReferences: [{
|
|
138
|
+
...town,
|
|
139
|
+
identityLock: { enabled: false, text: "Keep {ref} pixel-exact." },
|
|
140
|
+
} as ConnectedReference],
|
|
141
|
+
provider: "nano-banana-pro",
|
|
142
|
+
referenceFormat: "hybrid",
|
|
143
|
+
})
|
|
144
|
+
expect(locked.prompt).toContain("Keep reference image A pixel-exact.")
|
|
145
|
+
|
|
146
|
+
const unlocked = buildImagePrompt({
|
|
147
|
+
prompt: "a shot of @town:1~nolock",
|
|
148
|
+
connectedReferences: [{
|
|
149
|
+
...town,
|
|
150
|
+
identityLock: { enabled: true, text: "Keep {ref} pixel-exact." },
|
|
151
|
+
} as ConnectedReference],
|
|
152
|
+
provider: "nano-banana-pro",
|
|
153
|
+
referenceFormat: "hybrid",
|
|
154
|
+
})
|
|
155
|
+
expect(unlocked.prompt).not.toContain("pixel-exact")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("a `manual` reference is mentionable too", () => {
|
|
159
|
+
const out = buildImagePrompt({
|
|
160
|
+
prompt: "a shot of @moodboard:1:style",
|
|
161
|
+
connectedReferences: [{
|
|
162
|
+
id: "m1", defaultName: "Moodboard", source: "manual", url: "https://cdn/mood.png",
|
|
163
|
+
} as ConnectedReference],
|
|
164
|
+
provider: "nano-banana-pro",
|
|
165
|
+
referenceFormat: "hybrid",
|
|
166
|
+
})
|
|
167
|
+
expect(out.prompt).toContain("the style from reference image A")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("{image:N} and @town:3 coexist — each renders exactly once", () => {
|
|
171
|
+
const out = buildImagePrompt({
|
|
172
|
+
prompt: "put {image:1} beside @plaza:2:background",
|
|
173
|
+
connectedReferences: [town, plaza],
|
|
174
|
+
provider: "nano-banana-pro",
|
|
175
|
+
referenceFormat: "hybrid",
|
|
176
|
+
})
|
|
177
|
+
expect(out.prompt).not.toContain("{image:")
|
|
178
|
+
expect(out.prompt).not.toContain("@plaza")
|
|
179
|
+
// Both URLs attach, each exactly once (the mention does NOT filter its ref
|
|
180
|
+
// out of `connectedReferences`, and the New-path merge dedups by URL).
|
|
181
|
+
expect(out.referenceImageUrls?.filter((u) => u === "https://cdn/town.png")).toHaveLength(1)
|
|
182
|
+
expect(out.referenceImageUrls?.filter((u) => u === "https://cdn/plaza.png")).toHaveLength(1)
|
|
183
|
+
const backgroundPhrases = out.prompt.match(/the background from reference image/g) ?? []
|
|
184
|
+
expect(backgroundPhrases).toHaveLength(1)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* THE GATE-ARM GUARD (program plan §6, Leg C: "must exist or the leg silently
|
|
190
|
+
* no-ops"). An IMAGE-ONLY reference list — no wired character, no location, no
|
|
191
|
+
* extras — is the commonest studio payload, and it is exactly the case that
|
|
192
|
+
* reaches Phase 0 ONLY through the `hasImageMentionTokens` arm added to the
|
|
193
|
+
* `prompt-builder.ts` gate. Delete that arm and this test is the one that fails.
|
|
194
|
+
*/
|
|
195
|
+
describe("Phase-0 gate arm: image-only references", () => {
|
|
196
|
+
it("image-only refs + one mention → resolves (no character, no location, no extras)", () => {
|
|
197
|
+
const out = buildImagePrompt({
|
|
198
|
+
prompt: "a wide shot of @town:1:background at dusk",
|
|
199
|
+
connectedReferences: [town],
|
|
200
|
+
provider: "nano-banana-pro",
|
|
201
|
+
referenceFormat: "hybrid",
|
|
202
|
+
})
|
|
203
|
+
expect(out.prompt).toContain("the background from reference image A")
|
|
204
|
+
expect(out.prompt).not.toContain("@town")
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* THE BYTE-PARITY GUARD (program plan §6, Leg C, the second mandatory test).
|
|
210
|
+
*
|
|
211
|
+
* The expected values below are FIXTURES CAPTURED FROM THE PRE-CHANGE TREE (the
|
|
212
|
+
* branch point's `prompt-builder.ts`), not re-derived from the function under
|
|
213
|
+
* test — a self-comparison would pin nothing. The gate is TOKEN presence, so a
|
|
214
|
+
* mention-free graph never enters Phase 0 and its output is unchanged BY
|
|
215
|
+
* CONSTRUCTION; these fixtures are what make that claim falsifiable.
|
|
216
|
+
*/
|
|
217
|
+
describe("mention-free graphs are byte-identical to the pre-change tree", () => {
|
|
218
|
+
it("image-only, no mention", () => {
|
|
219
|
+
const out = buildImagePrompt({
|
|
220
|
+
prompt: "a wide shot of a quiet town square",
|
|
221
|
+
connectedReferences: [town],
|
|
222
|
+
provider: "nano-banana-pro",
|
|
223
|
+
referenceFormat: "hybrid",
|
|
224
|
+
})
|
|
225
|
+
expect(out.prompt).toBe("A wide shot of a quiet town square")
|
|
226
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png"])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("two images, no mention", () => {
|
|
230
|
+
const out = buildImagePrompt({
|
|
231
|
+
prompt: "a wide shot of a quiet town square",
|
|
232
|
+
connectedReferences: [town, plaza],
|
|
233
|
+
provider: "nano-banana-pro",
|
|
234
|
+
referenceFormat: "hybrid",
|
|
235
|
+
})
|
|
236
|
+
expect(out.prompt).toBe("A wide shot of a quiet town square")
|
|
237
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/town.png", "https://cdn/plaza.png"])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("character mention + an unmentioned image", () => {
|
|
241
|
+
const out = buildImagePrompt({
|
|
242
|
+
prompt: "@kira:1 walks through the square",
|
|
243
|
+
connectedReferences: [kira, town],
|
|
244
|
+
provider: "nano-banana-pro",
|
|
245
|
+
referenceFormat: "hybrid",
|
|
246
|
+
})
|
|
247
|
+
expect(out.prompt).toBe("the person from reference image A walks through the square")
|
|
248
|
+
expect(out.referenceImageUrls).toEqual(["https://cdn/kira.png", "https://cdn/town.png"])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("a ref whose name slugs to a GRAMMAR-INVALID slug never arms the gate", () => {
|
|
252
|
+
// "3D Render" → "3d-render": non-empty, but a leading digit is unparseable,
|
|
253
|
+
// so `knownImageSlugsFromRefs` drops it and no token can ever match it.
|
|
254
|
+
const out = buildImagePrompt({
|
|
255
|
+
prompt: "a shot of @3d-render:1",
|
|
256
|
+
connectedReferences: [{
|
|
257
|
+
id: "r1", defaultName: "3D Render", source: "wired-image", url: "https://cdn/r.png",
|
|
258
|
+
} as ConnectedReference],
|
|
259
|
+
provider: "nano-banana-pro",
|
|
260
|
+
referenceFormat: "hybrid",
|
|
261
|
+
})
|
|
262
|
+
expect(out.prompt).toContain("@3d-render:1")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("an isExtraRef media ref is NOT mentionable (it renders through the extras path)", () => {
|
|
266
|
+
const out = buildImagePrompt({
|
|
267
|
+
prompt: "a shot of @town:1:background",
|
|
268
|
+
connectedReferences: [{ ...town, isExtraRef: true } as ConnectedReference],
|
|
269
|
+
provider: "nano-banana-pro",
|
|
270
|
+
referenceFormat: "hybrid",
|
|
271
|
+
})
|
|
272
|
+
expect(out.prompt).toContain("@town:1:background")
|
|
273
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* CROSS-GRAMMAR: a LOCATION `bucket/variant` token must never be claimed — not
|
|
279
|
+
* even in truncated form — by the image pass. The image pass is the only one of
|
|
280
|
+
* the three that SPLICES its match, so a truncated claim does not merely fail
|
|
281
|
+
* to resolve, it corrupts the model-facing prompt with a dangling `/variant`.
|
|
282
|
+
*/
|
|
283
|
+
describe("a location bucket/variant token is never claimed by the image pass", () => {
|
|
284
|
+
const oldLibraryImage: ConnectedReference = {
|
|
285
|
+
id: "n3", defaultName: "Old Library", source: "wired-image",
|
|
286
|
+
url: "https://cdn/old-library-photo.png",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
it("images-only graph: the whole token stays literal, no `/rain` left dangling", () => {
|
|
290
|
+
const out = buildImagePrompt({
|
|
291
|
+
prompt: "a shot of @old-library:1:weather/rain",
|
|
292
|
+
connectedReferences: [oldLibraryImage],
|
|
293
|
+
provider: "nano-banana-pro",
|
|
294
|
+
referenceFormat: "hybrid",
|
|
295
|
+
})
|
|
296
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain")
|
|
297
|
+
// The corruption this pins shut: the truncated claim spliced
|
|
298
|
+
// `@old-library:1:weather` and left `/rain` welded to the binding phrase.
|
|
299
|
+
expect(out.prompt).not.toContain("reference image A/rain")
|
|
300
|
+
expect(out.prompt).not.toContain("/rain\n")
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("4-part location token (variant + mode) is left alone too", () => {
|
|
304
|
+
const out = buildImagePrompt({
|
|
305
|
+
prompt: "a shot of @old-library:1:weather/rain:style",
|
|
306
|
+
connectedReferences: [oldLibraryImage],
|
|
307
|
+
provider: "nano-banana-pro",
|
|
308
|
+
referenceFormat: "hybrid",
|
|
309
|
+
})
|
|
310
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain:style")
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("PRECEDENCE: a shared name + an unresolvable variant does not demote the location to slot B", () => {
|
|
314
|
+
// The location pass leaves the token alone (the node carries no
|
|
315
|
+
// `weather/rain` variant); the image pass must not then mangle it, bind the
|
|
316
|
+
// upload to slot A and push the user's actual location image to slot B.
|
|
317
|
+
const out = buildImagePrompt({
|
|
318
|
+
prompt: "a shot of @old-library:1:weather/rain",
|
|
319
|
+
connectedReferences: [library, oldLibraryImage],
|
|
320
|
+
provider: "nano-banana-pro",
|
|
321
|
+
referenceFormat: "hybrid",
|
|
322
|
+
})
|
|
323
|
+
expect(out.prompt).toContain("@old-library:1:weather/rain")
|
|
324
|
+
expect(out.referenceImageUrls[0]).toBe("https://cdn/library.png")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("a slash BETWEEN two image mentions still resolves both", () => {
|
|
328
|
+
const out = buildImagePrompt({
|
|
329
|
+
prompt: "@town:1/@plaza:2",
|
|
330
|
+
connectedReferences: [town, plaza],
|
|
331
|
+
provider: "nano-banana-pro",
|
|
332
|
+
referenceFormat: "hybrid",
|
|
333
|
+
})
|
|
334
|
+
expect(out.prompt).not.toContain("@town")
|
|
335
|
+
expect(out.prompt).not.toContain("@plaza")
|
|
336
|
+
expect(out.referenceImageUrls).toEqual([
|
|
337
|
+
"https://cdn/town.png",
|
|
338
|
+
"https://cdn/plaza.png",
|
|
339
|
+
])
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* LEGACY (the kill-switch path). `IMAGE_REFERENCE_FORMAT=legacy` reverts the
|
|
345
|
+
* whole leg: there is no legacy image resolver, so a token stays literal text
|
|
346
|
+
* and the reference attaches exactly as it does today.
|
|
347
|
+
*/
|
|
348
|
+
describe("LEGACY reference format: image mentions stay literal", () => {
|
|
349
|
+
it("@town:3 is left verbatim and the URL still attaches", () => {
|
|
350
|
+
const out = buildImagePrompt({
|
|
351
|
+
prompt: "a wide shot of @town:3 at dusk",
|
|
352
|
+
connectedReferences: [town],
|
|
353
|
+
provider: "nano-banana-pro",
|
|
354
|
+
// no referenceFormat → legacy (the prod default)
|
|
355
|
+
})
|
|
356
|
+
expect(out.prompt).toContain("@town:3")
|
|
357
|
+
expect(out.prompt).not.toContain("reference image A")
|
|
358
|
+
expect(out.referenceImageUrls).toContain("https://cdn/town.png")
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("@town:3:background is left verbatim too", () => {
|
|
362
|
+
const out = buildImagePrompt({
|
|
363
|
+
prompt: "a wide shot of @town:3:background at dusk",
|
|
364
|
+
connectedReferences: [town],
|
|
365
|
+
provider: "nano-banana-pro",
|
|
366
|
+
})
|
|
367
|
+
expect(out.prompt).toContain("@town:3:background")
|
|
368
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
369
|
+
})
|
|
370
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { readDirectionFields, readStructuredFields } from "../read-node-direction.js"
|
|
3
|
+
import {
|
|
4
|
+
DIRECTION_ARRAY_CEILING,
|
|
5
|
+
DIRECTION_KEYS,
|
|
6
|
+
renderDirectionHints,
|
|
7
|
+
} from "../direction-registry.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The narrow readers are the ONLY thing standing between untrusted persisted
|
|
11
|
+
* node JSONB (`workflows.nodes` — import, MCP write, node preset, a
|
|
12
|
+
* Studio-emitted graph) and the prompt text a model sees. These cases pin the
|
|
13
|
+
* VALUE SHAPE contract; the accepted KEY SET is not pinned here on purpose —
|
|
14
|
+
* it is derived from `DIRECTION_FIELDS`, whose order + membership are pinned by
|
|
15
|
+
* `direction-registry.test.ts`. A hand list here would be exactly the
|
|
16
|
+
* "remember to update the list" the registry walk removes.
|
|
17
|
+
*/
|
|
18
|
+
describe("readDirectionFields", () => {
|
|
19
|
+
it("rejects non-object roots", () => {
|
|
20
|
+
expect(readDirectionFields(undefined)).toBeUndefined()
|
|
21
|
+
expect(readDirectionFields(null)).toBeUndefined()
|
|
22
|
+
expect(readDirectionFields("shotSize")).toBeUndefined()
|
|
23
|
+
expect(readDirectionFields(42)).toBeUndefined()
|
|
24
|
+
expect(readDirectionFields([])).toBeUndefined()
|
|
25
|
+
expect(readDirectionFields([{ shotSize: "medium-shot" }])).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns undefined, never {}, when nothing survives", () => {
|
|
29
|
+
// Load-bearing: `{}` is a DEFINED direction, which would flip the call
|
|
30
|
+
// sites' `...(x !== undefined ? { x } : {})` spread on and take the join
|
|
31
|
+
// branch instead of the exact no-op branch.
|
|
32
|
+
expect(readDirectionFields({})).toBeUndefined()
|
|
33
|
+
expect(readDirectionFields({ shotSize: "" })).toBeUndefined()
|
|
34
|
+
expect(readDirectionFields({ mood: [] })).toBeUndefined()
|
|
35
|
+
expect(readDirectionFields({ mood: ["", 5, null] })).toBeUndefined()
|
|
36
|
+
expect(readDirectionFields({ nothing: "here" })).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("keeps a registry key and drops everything unrecognised", () => {
|
|
40
|
+
expect(readDirectionFields({ shotSize: "medium-shot", bogus: "x" })).toEqual({
|
|
41
|
+
shotSize: "medium-shot",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("accepts an array on any key and filters junk entries out of it", () => {
|
|
46
|
+
expect(readDirectionFields({ mood: ["serene", "", 5, null, "tense"] })).toEqual({
|
|
47
|
+
mood: ["serene", "tense"],
|
|
48
|
+
})
|
|
49
|
+
// An array on a single-pick key is legal at the reader; the per-dimension
|
|
50
|
+
// cap is the renderer's slice, never a drop here.
|
|
51
|
+
expect(readDirectionFields({ style: ["anime", "cinematic"] })).toEqual({
|
|
52
|
+
style: ["anime", "cinematic"],
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("drops non-string / over-length ids", () => {
|
|
57
|
+
expect(readDirectionFields({ shotSize: 123, style: "anime" })).toEqual({ style: "anime" })
|
|
58
|
+
expect(readDirectionFields({ shotSize: { id: "medium-shot" } })).toBeUndefined()
|
|
59
|
+
const overLong = "x".repeat(101)
|
|
60
|
+
expect(readDirectionFields({ shotSize: overLong })).toBeUndefined()
|
|
61
|
+
expect(readDirectionFields({ shotSize: "x".repeat(100) })).toEqual({
|
|
62
|
+
shotSize: "x".repeat(100),
|
|
63
|
+
})
|
|
64
|
+
expect(readDirectionFields({ mood: [overLong, "serene"] })).toEqual({ mood: ["serene"] })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("caps an array at the wire ceiling, filtering junk BEFORE the cap", () => {
|
|
68
|
+
// Cardinality is bounded, not just per-id length: node data is validated
|
|
69
|
+
// only as `z.record(z.string(), z.unknown())` on write, so an unbounded
|
|
70
|
+
// array would otherwise reach `renderDirectionHints`' includes-dedupe.
|
|
71
|
+
const many = Array.from({ length: DIRECTION_ARRAY_CEILING + 20 }, (_, i) => `id-${i}`)
|
|
72
|
+
expect(readDirectionFields({ mood: many })).toEqual({
|
|
73
|
+
mood: many.slice(0, DIRECTION_ARRAY_CEILING),
|
|
74
|
+
})
|
|
75
|
+
// Junk entries must not consume the budget: 8 unusable entries in front of
|
|
76
|
+
// the real ids still leaves a full ceiling of real ids.
|
|
77
|
+
const junk = [...Array.from({ length: DIRECTION_ARRAY_CEILING }, () => ""), ...many]
|
|
78
|
+
expect(readDirectionFields({ mood: junk })).toEqual({
|
|
79
|
+
mood: many.slice(0, DIRECTION_ARRAY_CEILING),
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("bounds the work a stored blob can force on the renderer", () => {
|
|
84
|
+
// Pre-fix this pair was quadratic in the stored array's length (the dedupe
|
|
85
|
+
// scan ran over the FULL array before the per-row slice), so a node any
|
|
86
|
+
// authenticated user can persist blocked the event loop for seconds in the
|
|
87
|
+
// orchestrator and in the user's tab. No timing assertion — the default
|
|
88
|
+
// vitest timeout is the guard.
|
|
89
|
+
const huge = Array.from({ length: 200_000 }, (_, i) => `id-${i}`)
|
|
90
|
+
const direction = readDirectionFields({ mood: huge, lightingStyle: huge })
|
|
91
|
+
expect(direction?.mood).toHaveLength(DIRECTION_ARRAY_CEILING)
|
|
92
|
+
expect(() => renderDirectionHints(direction, { surface: "image" })).not.toThrow()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("honors every registry key by construction (a new dimension needs no edit here)", () => {
|
|
96
|
+
const everyKey = Object.fromEntries(DIRECTION_KEYS.map((k) => [k, "some-id"]))
|
|
97
|
+
expect(Object.keys(readDirectionFields(everyKey) ?? {}).sort()).toEqual(
|
|
98
|
+
[...DIRECTION_KEYS].sort(),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe("readStructuredFields", () => {
|
|
104
|
+
it("rejects non-object roots and returns undefined when nothing survives", () => {
|
|
105
|
+
expect(readStructuredFields(undefined)).toBeUndefined()
|
|
106
|
+
expect(readStructuredFields(null)).toBeUndefined()
|
|
107
|
+
expect(readStructuredFields("person")).toBeUndefined()
|
|
108
|
+
expect(readStructuredFields([])).toBeUndefined()
|
|
109
|
+
expect(readStructuredFields({})).toBeUndefined()
|
|
110
|
+
expect(readStructuredFields({ person: "hello" })).toBeUndefined()
|
|
111
|
+
expect(readStructuredFields({ person: {}, styling: [] })).toBeUndefined()
|
|
112
|
+
expect(readStructuredFields({ nothing: "here" })).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("drops junk field values instead of rendering them verbatim", () => {
|
|
116
|
+
// `renderStructuredFields` never throws on junk but DOES render it —
|
|
117
|
+
// `person: { age: "drop table" }` would become "Subject: drop table years
|
|
118
|
+
// old." inside `jobs.input_data.prompt`. The field table is what blocks it.
|
|
119
|
+
expect(readStructuredFields({ person: { age: "drop table" } })).toBeUndefined()
|
|
120
|
+
expect(readStructuredFields({ person: { age: Number.NaN } })).toBeUndefined()
|
|
121
|
+
expect(readStructuredFields({ person: { gender: "robot" } })).toBeUndefined()
|
|
122
|
+
expect(readStructuredFields({ person: { hair: 7 } })).toBeUndefined()
|
|
123
|
+
expect(readStructuredFields({ person: { hair: "x".repeat(201) } })).toBeUndefined()
|
|
124
|
+
expect(readStructuredFields({ person: { age: 34, bogus: "x" } })).toEqual({
|
|
125
|
+
person: { age: 34 },
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("round-trips every group plus the mood shorthand", () => {
|
|
130
|
+
const full = {
|
|
131
|
+
person: {
|
|
132
|
+
age: 34,
|
|
133
|
+
gender: "woman",
|
|
134
|
+
hair: "auburn",
|
|
135
|
+
eyes: "green",
|
|
136
|
+
expression: "wry",
|
|
137
|
+
profession: "archivist",
|
|
138
|
+
warriorType: "ranger",
|
|
139
|
+
},
|
|
140
|
+
styling: { mood: "brooding", lighting: "soft", aesthetic: "noir", colorLook: "teal" },
|
|
141
|
+
setting: { era: "1970s", atmosphere: "humid", backdrop: "a rain-slick street" },
|
|
142
|
+
camera: { framing: "medium", motion: "slow push", format: "35mm" },
|
|
143
|
+
lens: { focalLength: "85mm", aperture: "1.4" },
|
|
144
|
+
mood: "brooding",
|
|
145
|
+
}
|
|
146
|
+
expect(readStructuredFields(full)).toEqual(full)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("keeps the surviving fields of a partially-junk group", () => {
|
|
150
|
+
expect(
|
|
151
|
+
readStructuredFields({ person: { age: 34, gender: "robot" }, styling: "nope" }),
|
|
152
|
+
).toEqual({ person: { age: 34 } })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -143,9 +143,11 @@ describe("transition timing catalogs", () => {
|
|
|
143
143
|
})
|
|
144
144
|
|
|
145
145
|
it("leaves every other single-dim catalog without dimensions", () => {
|
|
146
|
+
// `character-fx` carries the same three timing fields with its own,
|
|
147
|
+
// deliberately different wording (see `character-fx-timing-catalogs.test.ts`).
|
|
146
148
|
const singlesWithDims = PICKER_CATALOGS.filter(
|
|
147
149
|
(c) => c.kind === "single" && c.dimensions,
|
|
148
150
|
).map((c) => c.nodeType)
|
|
149
|
-
expect(singlesWithDims).toEqual(["transition"])
|
|
151
|
+
expect(singlesWithDims).toEqual(["transition", "character-fx"])
|
|
150
152
|
})
|
|
151
153
|
})
|