@nodaro/prompts 1.10.0 → 1.12.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 +693 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1084 -27
- package/dist/index.d.ts +1084 -27
- package/dist/index.js +664 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
- package/src/__tests__/animal-getters-parity.test.ts +82 -0
- package/src/__tests__/assemble-image-input-cap.test.ts +212 -0
- package/src/__tests__/assemble-image-input.test.ts +93 -3
- package/src/__tests__/assemble-video-input-cap.test.ts +356 -0
- package/src/__tests__/assemble-video-input.test.ts +301 -0
- package/src/__tests__/direction-hint-token-safety.test.ts +113 -0
- package/src/__tests__/direction-registry.test.ts +393 -0
- package/src/__tests__/entity-convergence-image.test.ts +374 -0
- package/src/__tests__/image-convergence-image.test.ts +370 -0
- package/src/__tests__/location-convergence-image.test.ts +29 -1
- package/src/__tests__/location-default-role-image.test.ts +166 -0
- package/src/__tests__/mention-splice-spacing.test.ts +257 -0
- package/src/__tests__/read-node-direction.test.ts +154 -0
- package/src/__tests__/read-node-subject.test.ts +140 -0
- package/src/__tests__/subject-fold.test.ts +232 -0
- package/src/__tests__/subject-registry.test.ts +312 -0
- package/src/assemble-image-input.ts +160 -58
- package/src/assemble-video-input.ts +244 -0
- package/src/direction-registry.ts +371 -0
- package/src/hint-shedding.ts +68 -0
- package/src/index.ts +10 -2
- package/src/parameter-prompt-hint.ts +8 -7
- package/src/picker-catalogs.ts +14 -7
- package/src/prompt-builder.ts +728 -58
- package/src/prompt-hint-join.ts +30 -0
- package/src/read-node-direction.ts +233 -0
- package/src/subject-registry.ts +464 -0
|
@@ -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
|
+
})
|
|
@@ -45,6 +45,34 @@ describe("location reference converges onto the image hybrid form", () => {
|
|
|
45
45
|
expect(out.prompt).not.toContain("Use these locations:")
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
// A PLACE, NOT A BACKDROP — the `identical` usage mode (which IS
|
|
49
|
+
// `DEFAULT_LOCATION_USAGE_MODE`, i.e. what a bare `@old-library:1` resolves to)
|
|
50
|
+
// used to render "the background from reference image A". That is the exact
|
|
51
|
+
// wording measured on gpt-image-2 to produce cut-out composites, and the reason
|
|
52
|
+
// `DEFAULT_LABEL_BY_SOURCE["wired-location"]` became "location" on 2026-08-05;
|
|
53
|
+
// the mode map missed that fix. An EXPLICIT `:background` token still renders
|
|
54
|
+
// the backdrop wording (pinned in the Phase-D guard below).
|
|
55
|
+
it("bare @-mention (identical = the default mode) → 'the location from reference image A'", () => {
|
|
56
|
+
const out = buildImagePrompt({
|
|
57
|
+
prompt: "@old-library:1 a chase scene",
|
|
58
|
+
connectedReferences: [library],
|
|
59
|
+
provider: "nano-banana-pro", referenceFormat: "hybrid",
|
|
60
|
+
})
|
|
61
|
+
expect(out.prompt).toContain("the location from reference image A")
|
|
62
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
63
|
+
expect(out.prompt).not.toContain("Use these locations:")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("explicit :identical mode → the same 'the location from …' phrase", () => {
|
|
67
|
+
const out = buildImagePrompt({
|
|
68
|
+
prompt: "@old-library:1:identical a chase scene",
|
|
69
|
+
connectedReferences: [library],
|
|
70
|
+
provider: "nano-banana-pro", referenceFormat: "hybrid",
|
|
71
|
+
})
|
|
72
|
+
expect(out.prompt).toContain("the location from reference image A")
|
|
73
|
+
expect(out.prompt).not.toContain("the background from reference image")
|
|
74
|
+
})
|
|
75
|
+
|
|
48
76
|
it("opt-in identity lock surfaces a lock line; default OFF emits none", () => {
|
|
49
77
|
const locked = buildImagePrompt({
|
|
50
78
|
prompt: "@old-library:1 a chase scene",
|
|
@@ -54,7 +82,7 @@ describe("location reference converges onto the image hybrid form", () => {
|
|
|
54
82
|
} as ConnectedReference],
|
|
55
83
|
provider: "nano-banana-pro", referenceFormat: "hybrid",
|
|
56
84
|
})
|
|
57
|
-
expect(locked.prompt).toContain("the
|
|
85
|
+
expect(locked.prompt).toContain("the location from reference image A")
|
|
58
86
|
expect(locked.prompt).toContain("Lock the exact setting in reference image A.")
|
|
59
87
|
|
|
60
88
|
const unlocked = buildImagePrompt({
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Location `defaultRole` (the ref-level hybrid role pick) must be honored by
|
|
2
|
+
// BOTH image location paths — the @-mention resolver (un-roled token) and the
|
|
3
|
+
// canonical fallback (wired, unmentioned) — with the precedence:
|
|
4
|
+
// token role → token mode → ref defaultRole → defaultUsageMode-derived → "location".
|
|
5
|
+
//
|
|
6
|
+
// `ConnectedReference.defaultRole` is on the wire schema for every source and the
|
|
7
|
+
// character / named-image mention paths have always read it (`resolveDefaultRole`);
|
|
8
|
+
// the location paths read only the usage mode, so a caller's ref-level role was
|
|
9
|
+
// silently dropped. It is the ONLY channel a location has for a custom default
|
|
10
|
+
// role — a location mention's 3rd segment is a bucket/variant or a role, so a
|
|
11
|
+
// caller cannot pin a per-mention role AND keep the canonical image.
|
|
12
|
+
//
|
|
13
|
+
// Legacy format ignores `defaultRole` entirely (byte-identical guard).
|
|
14
|
+
import { describe, it, expect } from "vitest"
|
|
15
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
16
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
17
|
+
|
|
18
|
+
const library: ConnectedReference = {
|
|
19
|
+
id: "l", defaultName: "Old Library", source: "wired-location",
|
|
20
|
+
url: "https://cdn/library.png", locationSlug: "old-library",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const build = (prompt: string, over: Partial<ConnectedReference> = {}, hybrid = true) =>
|
|
24
|
+
buildImagePrompt({
|
|
25
|
+
provider: "nano-banana-pro",
|
|
26
|
+
prompt,
|
|
27
|
+
connectedReferences: [{ ...library, ...over } as ConnectedReference],
|
|
28
|
+
...(hybrid ? { referenceFormat: "hybrid" as const } : {}),
|
|
29
|
+
}).prompt
|
|
30
|
+
|
|
31
|
+
describe("location @-mention honors the ref defaultRole for un-roled tokens", () => {
|
|
32
|
+
it("bare '@old-library:1' with defaultRole 'atmosphere' → 'the atmosphere from reference image A'", () => {
|
|
33
|
+
expect(build("@old-library:1 a chase scene", { defaultRole: "atmosphere" }))
|
|
34
|
+
.toContain("the atmosphere from reference image A")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("a Custom defaultRole survives verbatim", () => {
|
|
38
|
+
expect(build("@old-library:1 a chase scene", { defaultRole: "brickwork" }))
|
|
39
|
+
.toContain("the brickwork from reference image A")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("a multi-word preset slug is normalized to its phrase key, like a token role", () => {
|
|
43
|
+
expect(build("@old-library:1 a chase scene", { defaultRole: "empty-background" }))
|
|
44
|
+
.toContain("the background from reference image A (without its foreground objects)")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("an explicit token ROLE still overrides the ref defaultRole", () => {
|
|
48
|
+
expect(build("@old-library:1:lighting a chase scene", { defaultRole: "atmosphere" }))
|
|
49
|
+
.toContain("the lighting from reference image A")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("an explicit ROLE-BEARING token MODE still overrides the ref defaultRole", () => {
|
|
53
|
+
expect(build("@old-library:1:layout a chase scene", { defaultRole: "atmosphere" }))
|
|
54
|
+
.toContain("the layout from reference image A")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("the other role-bearing mode (style) overrides it too", () => {
|
|
58
|
+
expect(build("@old-library:1:style a chase scene", { defaultRole: "atmosphere" }))
|
|
59
|
+
.toContain("the style from reference image A")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// `identical` and `none` express no role opinion — `identical` IS
|
|
63
|
+
// `DEFAULT_LOCATION_USAGE_MODE` (the un-roled state) and the location pill's
|
|
64
|
+
// `renderText` emits a mode segment whenever the attr is set, so an ungated
|
|
65
|
+
// mode step would let a round-tripped `@old-library:1:identical` suppress the
|
|
66
|
+
// ref-level role on the MAJORITY of real tokens. The character chain already
|
|
67
|
+
// falls through for its directive-only modes (identical / name / none) — this
|
|
68
|
+
// is the same rule, and `roleBearingLocationMode` runs the same
|
|
69
|
+
// `presets.includes(...)` test the character resolver and the pill view use.
|
|
70
|
+
it("a directive-only token mode (:identical) falls THROUGH to the ref defaultRole", () => {
|
|
71
|
+
expect(build("@old-library:1:identical a chase scene", { defaultRole: "atmosphere" }))
|
|
72
|
+
.toContain("the atmosphere from reference image A")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("a directive-only token mode (:none) falls THROUGH to the ref defaultRole", () => {
|
|
76
|
+
expect(build("@old-library:1:none a chase scene", { defaultRole: "atmosphere" }))
|
|
77
|
+
.toContain("the atmosphere from reference image A")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("matches the character chain: a directive-only segment never beats the ref role", () => {
|
|
81
|
+
const location = build("@old-library:1:identical a chase scene", { defaultRole: "atmosphere" })
|
|
82
|
+
const character = buildImagePrompt({
|
|
83
|
+
provider: "nano-banana-pro",
|
|
84
|
+
referenceFormat: "hybrid",
|
|
85
|
+
prompt: "@kira:1:identical a chase scene",
|
|
86
|
+
connectedReferences: [{
|
|
87
|
+
id: "c", defaultName: "Kira", source: "wired-character",
|
|
88
|
+
url: "https://cdn/kira.png", characterSlug: "kira", defaultRole: "clothes",
|
|
89
|
+
}],
|
|
90
|
+
}).prompt
|
|
91
|
+
expect(location).toContain("the atmosphere from reference image A")
|
|
92
|
+
expect(character).toContain("the clothes from reference image A")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("defaultRole beats a legacy defaultUsageMode on the same ref", () => {
|
|
96
|
+
expect(build("@old-library:1 a chase scene", { defaultRole: "atmosphere", defaultUsageMode: "style" }))
|
|
97
|
+
.toContain("the atmosphere from reference image A")
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("location canonical fallback honors the ref defaultRole", () => {
|
|
102
|
+
it("unmentioned wired location with defaultRole 'atmosphere' → 'the atmosphere from reference image A'", () => {
|
|
103
|
+
expect(build("a detective at her desk", { defaultRole: "atmosphere" }))
|
|
104
|
+
.toContain("the atmosphere from reference image A")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// The mention and the canonical paths run ONE role chain, so the same wired
|
|
108
|
+
// location can never phrase itself one way mentioned and another unmentioned.
|
|
109
|
+
it("mentioned and unmentioned agree on the phrase for the same ref", () => {
|
|
110
|
+
const mentioned = build("@old-library:1 a chase scene", { defaultRole: "brickwork" })
|
|
111
|
+
const unmentioned = build("a chase scene", { defaultRole: "brickwork" })
|
|
112
|
+
expect(mentioned).toContain("the brickwork from reference image A")
|
|
113
|
+
expect(unmentioned).toContain("the brickwork from reference image A")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("NO defaultRole → byte-identical to the pre-change derivation", () => {
|
|
118
|
+
it("bare mention, no ref-level defaults → the source default", () => {
|
|
119
|
+
expect(build("@old-library:1 a chase scene")).toBe(
|
|
120
|
+
"the location from reference image A a chase scene",
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Gating step 2 on role-bearing modes changes nothing when there is no
|
|
125
|
+
// `defaultRole` to fall through to: `:identical` lands on step 4 → the source
|
|
126
|
+
// default, the same string the ungated step 2 produced.
|
|
127
|
+
it("':identical' with no ref-level defaults → the source default, unchanged", () => {
|
|
128
|
+
expect(build("@old-library:1:identical a chase scene")).toBe(
|
|
129
|
+
"the location from reference image A a chase scene",
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("':none' with no ref-level defaults → the source default, unchanged", () => {
|
|
134
|
+
expect(build("@old-library:1:none a chase scene")).toBe(
|
|
135
|
+
"the location from reference image A a chase scene",
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("bare mention with defaultUsageMode 'style' → the mode-derived role, unchanged", () => {
|
|
140
|
+
expect(build("@old-library:1 a chase scene", { defaultUsageMode: "style" })).toBe(
|
|
141
|
+
"the style from reference image A a chase scene",
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// No mention converged, so the hybrid scene render still capitalizes the
|
|
146
|
+
// line-initial; the canonical phrase is appended after that pass, lowercase.
|
|
147
|
+
it("unmentioned, no ref-level defaults → the source default", () => {
|
|
148
|
+
expect(build("a detective at her desk")).toBe(
|
|
149
|
+
"A detective at her desk\nthe location from reference image A",
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("unmentioned with defaultUsageMode 'layout' → the mode-derived role, unchanged", () => {
|
|
154
|
+
expect(build("a detective at her desk", { defaultUsageMode: "layout" as never })).toBe(
|
|
155
|
+
"A detective at her desk\nthe layout from reference image A",
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe("legacy format ignores defaultRole (byte-identical guard)", () => {
|
|
161
|
+
it("legacy assembly with a defaultRole-carrying location emits no role phrase", () => {
|
|
162
|
+
const out = build("@old-library:1 a chase scene", { defaultRole: "atmosphere" }, false)
|
|
163
|
+
expect(out).not.toContain("the atmosphere from reference image A")
|
|
164
|
+
expect(out).not.toContain("from reference image A")
|
|
165
|
+
})
|
|
166
|
+
})
|