@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,374 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { buildImagePrompt } from "../prompt-builder.js"
3
+ import type { ConnectedReference } from "@nodaro/shared"
4
+
5
+ /**
6
+ * Wired-entity mentions — `@<name-slug>:<index>[:<role>]` for `wired-creature`
7
+ * and `wired-object`.
8
+ *
9
+ * The entity analog of `image-convergence-image.test.ts`, plus the two things
10
+ * only this leg has: FALLBACK SUPPRESSION (a mentioned entity must not ALSO emit
11
+ * its trailing canonical phrase — the live bug) and CROSS-KIND PRECEDENCE
12
+ * (character → location → image → creature → object).
13
+ *
14
+ * `referenceFormat` is passed EXPLICITLY on every hybrid case: `packages/prompts`
15
+ * is env-free (`content-free-contract.test.ts`), so `NODE_ENV=test` only steers
16
+ * the *callers*, never this package.
17
+ */
18
+
19
+ const nessie: ConnectedReference = {
20
+ id: "cr1", defaultName: "Nessie", source: "wired-creature", url: "https://cdn/nessie.png",
21
+ }
22
+ const chair: ConnectedReference = {
23
+ id: "ob1", defaultName: "Chair", source: "wired-object", url: "https://cdn/chair.png",
24
+ }
25
+ const kira: ConnectedReference = {
26
+ id: "c1", defaultName: "Kira", source: "wired-character",
27
+ url: "https://cdn/kira.png", characterSlug: "kira",
28
+ }
29
+ const library: ConnectedReference = {
30
+ id: "l1", defaultName: "Old Library", source: "wired-location",
31
+ url: "https://cdn/library.png", locationSlug: "old-library",
32
+ }
33
+ const town: ConnectedReference = {
34
+ id: "n1", defaultName: "Town", source: "wired-image", url: "https://cdn/town.png",
35
+ }
36
+
37
+ /** Count non-overlapping occurrences — the double-render detector. */
38
+ const occurrences = (haystack: string, needle: string): number =>
39
+ haystack.split(needle).length - 1
40
+
41
+ describe("wired-entity mentions resolve on the image hybrid path", () => {
42
+ it("bare @nessie:4 → the SOURCE-default creature phrase, inline, token consumed", () => {
43
+ const out = buildImagePrompt({
44
+ prompt: "a wide shot of @nessie:4 rising from the lake",
45
+ connectedReferences: [nessie],
46
+ provider: "nano-banana-pro",
47
+ referenceFormat: "hybrid",
48
+ })
49
+ expect(out.prompt).toContain("the creature from reference image A")
50
+ expect(out.prompt).not.toContain("@nessie")
51
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
52
+ })
53
+
54
+ it("the phrase renders where the user TYPED it, not appended after the scene", () => {
55
+ const out = buildImagePrompt({
56
+ prompt: "a wide shot of @nessie:4 rising from the lake",
57
+ connectedReferences: [nessie],
58
+ provider: "nano-banana-pro",
59
+ referenceFormat: "hybrid",
60
+ })
61
+ // The whole point: bound INSIDE the sentence, so "rising from the lake"
62
+ // still attaches to the reference rather than to a bare name.
63
+ expect(out.prompt).toContain("the creature from reference image A rising from the lake")
64
+ })
65
+
66
+ it("SUPPRESSES the trailing canonical fallback for the mentioned ref (the live bug)", () => {
67
+ const out = buildImagePrompt({
68
+ prompt: "a wide shot of @nessie:4 rising from the lake",
69
+ connectedReferences: [nessie],
70
+ provider: "nano-banana-pro",
71
+ referenceFormat: "hybrid",
72
+ })
73
+ // Before this leg the same ref rendered TWICE: the name as plain prose in
74
+ // the body, plus a dangling trailing phrase after the style hints.
75
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(1)
76
+ })
77
+
78
+ it("@nessie:4:markings → the curated creature role", () => {
79
+ const out = buildImagePrompt({
80
+ prompt: "a close shot of @nessie:4:markings",
81
+ connectedReferences: [nessie],
82
+ provider: "nano-banana-pro",
83
+ referenceFormat: "hybrid",
84
+ })
85
+ expect(out.prompt).toContain("the markings from reference image A")
86
+ expect(occurrences(out.prompt, "reference image A")).toBe(1)
87
+ })
88
+
89
+ it("@chair:2:material → the curated OBJECT role, fallback suppressed", () => {
90
+ const out = buildImagePrompt({
91
+ prompt: "a still life of @chair:2:material",
92
+ connectedReferences: [chair],
93
+ provider: "nano-banana-pro",
94
+ referenceFormat: "hybrid",
95
+ })
96
+ expect(out.prompt).toContain("the material from reference image A")
97
+ expect(out.prompt).not.toContain("the object from reference image A")
98
+ })
99
+
100
+ it("a CUSTOM role passes through verbatim", () => {
101
+ const out = buildImagePrompt({
102
+ prompt: "a close shot of @nessie:4:dorsal-fin",
103
+ connectedReferences: [nessie],
104
+ provider: "nano-banana-pro",
105
+ referenceFormat: "hybrid",
106
+ })
107
+ expect(out.prompt).toContain("the dorsal-fin from reference image A")
108
+ })
109
+
110
+ it("the node's own defaultRole is the fallback when the token carries none", () => {
111
+ const out = buildImagePrompt({
112
+ prompt: "a close shot of @nessie:4",
113
+ connectedReferences: [{ ...nessie, defaultRole: "anatomy" } as ConnectedReference],
114
+ provider: "nano-banana-pro",
115
+ referenceFormat: "hybrid",
116
+ })
117
+ expect(out.prompt).toContain("the anatomy from reference image A")
118
+ expect(out.prompt).not.toContain("the creature from reference image A")
119
+ })
120
+
121
+ it("a token role BEATS the node's defaultRole", () => {
122
+ const out = buildImagePrompt({
123
+ prompt: "a close shot of @nessie:4:pose",
124
+ connectedReferences: [{ ...nessie, defaultRole: "anatomy" } as ConnectedReference],
125
+ provider: "nano-banana-pro",
126
+ referenceFormat: "hybrid",
127
+ })
128
+ expect(out.prompt).toContain("the pose from reference image A")
129
+ expect(out.prompt).not.toContain("the anatomy from")
130
+ })
131
+
132
+ it("mentions ONE of two wired entities — the other keeps its trailing canonical", () => {
133
+ const out = buildImagePrompt({
134
+ prompt: "@nessie:1 looms over the room",
135
+ connectedReferences: [nessie, chair],
136
+ provider: "nano-banana-pro",
137
+ referenceFormat: "hybrid",
138
+ })
139
+ expect(out.prompt).toContain("the creature from reference image A looms over the room")
140
+ // The unmentioned chair still auto-attaches with its trailing phrase — the
141
+ // pre-mention behavior, untouched.
142
+ expect(out.prompt).toContain("the object from reference image B")
143
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(1)
144
+ })
145
+
146
+ it("two mentions of the SAME entity render twice inline but attach ONE URL", () => {
147
+ const out = buildImagePrompt({
148
+ prompt: "@nessie:1 circles, then @nessie:1 dives",
149
+ connectedReferences: [nessie],
150
+ provider: "nano-banana-pro",
151
+ referenceFormat: "hybrid",
152
+ })
153
+ expect(occurrences(out.prompt, "the creature from reference image A")).toBe(2)
154
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
155
+ })
156
+
157
+ it("a mention whose slug matches NO wired entity stays literal text", () => {
158
+ const out = buildImagePrompt({
159
+ prompt: "a shot of @dragon:1 over the lake",
160
+ connectedReferences: [nessie],
161
+ provider: "nano-banana-pro",
162
+ referenceFormat: "hybrid",
163
+ })
164
+ expect(out.prompt).toContain("@dragon:1")
165
+ // Nessie was never mentioned, so it keeps its canonical trailing phrase.
166
+ expect(out.prompt).toContain("the creature from reference image A")
167
+ })
168
+ })
169
+
170
+ describe("wired-entity mention locks and element injections", () => {
171
+ it("~lock forces the creature's identity lock ON, emitted ONCE", () => {
172
+ const locked = buildImagePrompt({
173
+ prompt: "@nessie:1 rises",
174
+ connectedReferences: [nessie],
175
+ provider: "nano-banana-pro",
176
+ referenceFormat: "hybrid",
177
+ })
178
+ const forced = buildImagePrompt({
179
+ prompt: "@nessie:1~lock rises",
180
+ connectedReferences: [nessie],
181
+ provider: "nano-banana-pro",
182
+ referenceFormat: "hybrid",
183
+ })
184
+ // `wired-creature` lock wording is OFF by default, so the sentinel is the
185
+ // only difference between these two prompts.
186
+ expect(forced.prompt.length).toBeGreaterThan(locked.prompt.length)
187
+ expect(forced.prompt).not.toContain("~lock")
188
+ expect(forced.prompt).toContain("reference image A")
189
+ // The ONCE half: the mention pass emits the lock line INSTEAD of the
190
+ // suppressed canonical one, never in addition to it. Matched on the stable
191
+ // prefix of `DEFAULT_LOCK_TEXT["wired-creature"]`, not the em-dashed tail.
192
+ expect(occurrences(forced.prompt, "Lock the exact identity of the creature")).toBe(1)
193
+ expect(occurrences(locked.prompt, "Lock the exact identity of the creature")).toBe(0)
194
+ })
195
+
196
+ it("a lock-less mention INHERITS the ref's own lock — emitted ONCE", () => {
197
+ const out = buildImagePrompt({
198
+ prompt: "@nessie:1 rises",
199
+ connectedReferences: [{ ...nessie, identityLock: { enabled: true } } as ConnectedReference],
200
+ provider: "nano-banana-pro",
201
+ referenceFormat: "hybrid",
202
+ })
203
+ expect(occurrences(out.prompt, "Lock the exact identity of the creature")).toBe(1)
204
+ })
205
+
206
+ it("~nolock forces the lock OFF even when the ref itself enables it", () => {
207
+ const out = buildImagePrompt({
208
+ prompt: "@nessie:1~nolock rises",
209
+ connectedReferences: [{ ...nessie, identityLock: { enabled: true } } as ConnectedReference],
210
+ provider: "nano-banana-pro",
211
+ referenceFormat: "hybrid",
212
+ })
213
+ expect(out.prompt).not.toContain("Lock the exact identity of the creature")
214
+ expect(out.prompt).not.toContain("~nolock")
215
+ expect(out.prompt).toContain("the creature from reference image A")
216
+ })
217
+
218
+ it("carries the ref's elementInjection ONCE — not once inline and once canonical", () => {
219
+ const inject = "keep the bioluminescent spines visible"
220
+ const out = buildImagePrompt({
221
+ prompt: "@nessie:1 rises",
222
+ connectedReferences: [{ ...nessie, elementInjection: inject } as ConnectedReference],
223
+ provider: "nano-banana-pro",
224
+ referenceFormat: "hybrid",
225
+ })
226
+ expect(occurrences(out.prompt, inject)).toBe(1)
227
+ })
228
+ })
229
+
230
+ describe("cross-kind precedence: character → location → image → creature → object", () => {
231
+ it("a slug known to a CHARACTER and a CREATURE resolves as the CHARACTER", () => {
232
+ const twin: ConnectedReference = {
233
+ id: "cr2", defaultName: "Kira", source: "wired-creature", url: "https://cdn/kira-creature.png",
234
+ }
235
+ const out = buildImagePrompt({
236
+ prompt: "@kira:1 steps forward",
237
+ connectedReferences: [kira, twin],
238
+ provider: "nano-banana-pro",
239
+ referenceFormat: "hybrid",
240
+ })
241
+ // The character pass spliced the token, so the creature pass saw nothing.
242
+ expect(out.prompt).toContain("the person from reference image A")
243
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/kira.png")
244
+ // The creature was never mentioned → it keeps its trailing canonical phrase.
245
+ expect(out.prompt).toContain("the creature from reference image B")
246
+ })
247
+
248
+ it("a slug known to a LOCATION and a CREATURE resolves as the LOCATION", () => {
249
+ const twin: ConnectedReference = {
250
+ id: "cr3", defaultName: "Old Library", source: "wired-creature", url: "https://cdn/lib-creature.png",
251
+ }
252
+ const out = buildImagePrompt({
253
+ prompt: "a shot inside @old-library:1",
254
+ connectedReferences: [library, twin],
255
+ provider: "nano-banana-pro",
256
+ referenceFormat: "hybrid",
257
+ })
258
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/library.png")
259
+ expect(out.prompt).not.toContain("@old-library")
260
+ })
261
+
262
+ it("a slug known to an IMAGE and a CREATURE resolves as the IMAGE", () => {
263
+ const twin: ConnectedReference = {
264
+ id: "cr4", defaultName: "Town", source: "wired-creature", url: "https://cdn/town-creature.png",
265
+ }
266
+ const out = buildImagePrompt({
267
+ prompt: "a wide shot of @town:1 at dusk",
268
+ connectedReferences: [town, twin],
269
+ provider: "nano-banana-pro",
270
+ referenceFormat: "hybrid",
271
+ })
272
+ // `wired-image`'s default role is "" → the BARE binding, and the image URL
273
+ // is the one that got the mention's slot.
274
+ expect(out.referenceImageUrls?.[0]).toBe("https://cdn/town.png")
275
+ expect(out.prompt).not.toContain("@town")
276
+ // The creature was never mentioned → trailing canonical, at its own letter.
277
+ expect(out.prompt).toContain("the creature from reference image B")
278
+ })
279
+
280
+ it("a slug known to a CREATURE and an OBJECT resolves as the CREATURE", () => {
281
+ // The tail of the chain, settled by the creature-first slug → ref map.
282
+ const twinCreature: ConnectedReference = {
283
+ id: "cr5", defaultName: "Totem", source: "wired-creature", url: "https://cdn/totem-creature.png",
284
+ }
285
+ const twinObject: ConnectedReference = {
286
+ id: "ob5", defaultName: "Totem", source: "wired-object", url: "https://cdn/totem-object.png",
287
+ }
288
+ // Object listed FIRST, so ref order cannot be what decides it.
289
+ const out = buildImagePrompt({
290
+ prompt: "@totem:1 stands in the clearing",
291
+ connectedReferences: [twinObject, twinCreature],
292
+ provider: "nano-banana-pro",
293
+ referenceFormat: "hybrid",
294
+ })
295
+ expect(out.prompt).toContain("the creature from")
296
+ expect(out.prompt).toContain("stands in the clearing")
297
+ })
298
+
299
+ it("all five kinds in one prompt each bind their own reference", () => {
300
+ const out = buildImagePrompt({
301
+ prompt: "@kira:1 walks past @old-library:2 toward @town:3 with @nessie:4 and @chair:5",
302
+ connectedReferences: [kira, library, town, nessie, chair],
303
+ provider: "nano-banana-pro",
304
+ referenceFormat: "hybrid",
305
+ })
306
+ for (const t of ["@kira", "@old-library", "@town", "@nessie", "@chair"]) {
307
+ expect(out.prompt).not.toContain(t)
308
+ }
309
+ expect(out.referenceImageUrls).toHaveLength(5)
310
+ // Both entities were mentioned → NO trailing canonical phrases for them.
311
+ expect(occurrences(out.prompt, "the creature from")).toBe(1)
312
+ expect(occurrences(out.prompt, "the object from")).toBe(1)
313
+ })
314
+ })
315
+
316
+ describe("byte-identity guarantees", () => {
317
+ const cases: Array<[string, Parameters<typeof buildImagePrompt>[0]]> = [
318
+ ["mention-free prompt with a wired creature", {
319
+ prompt: "a wide shot of a lake monster at dusk",
320
+ connectedReferences: [nessie],
321
+ provider: "nano-banana-pro",
322
+ referenceFormat: "hybrid",
323
+ }],
324
+ ["mention-free prompt with a wired creature AND object", {
325
+ prompt: "a still life in a dim room",
326
+ connectedReferences: [nessie, chair],
327
+ provider: "nano-banana-pro",
328
+ referenceFormat: "hybrid",
329
+ }],
330
+ ["prompt with an @-token that matches no entity", {
331
+ prompt: "a shot of @dragon:1 over the lake",
332
+ connectedReferences: [nessie],
333
+ provider: "nano-banana-pro",
334
+ referenceFormat: "hybrid",
335
+ }],
336
+ ["no connectedReferences at all", {
337
+ prompt: "a wide shot of a lake at dusk",
338
+ provider: "nano-banana-pro",
339
+ referenceFormat: "hybrid",
340
+ }],
341
+ ]
342
+
343
+ // PINNED OUTPUTS. The entity pass is gated on TOKEN presence, so an
344
+ // unmentioned graph never enters it. These snapshots were VERIFIED against the
345
+ // pre-leg resolver (the entity wiring reverted, the snapshots kept) and matched
346
+ // byte-for-byte, so they are a real regression pin and not a recording of
347
+ // whatever the new code happens to emit. A diff here means the gate leaked.
348
+ it.each(cases)("%s is unchanged", (_name, config) => {
349
+ expect(buildImagePrompt(config).prompt).toMatchSnapshot()
350
+ })
351
+
352
+ it("an unmentioned creature still renders its trailing canonical phrase", () => {
353
+ const out = buildImagePrompt({
354
+ prompt: "a wide shot of a lake monster at dusk",
355
+ connectedReferences: [nessie],
356
+ provider: "nano-banana-pro",
357
+ referenceFormat: "hybrid",
358
+ })
359
+ expect(out.prompt).toContain("the creature from reference image A")
360
+ expect(out.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
361
+ })
362
+
363
+ it("LEGACY leaves the token literal and attaches the entity exactly as today", () => {
364
+ const withMention = buildImagePrompt({
365
+ prompt: "a wide shot of @nessie:4 rising",
366
+ connectedReferences: [nessie],
367
+ provider: "nano-banana-pro",
368
+ referenceFormat: "legacy",
369
+ })
370
+ // No hybrid resolver runs under legacy — the token survives as typed.
371
+ expect(withMention.prompt).toContain("@nessie:4")
372
+ expect(withMention.referenceImageUrls).toEqual(["https://cdn/nessie.png"])
373
+ })
374
+ })
@@ -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 background from reference image A")
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
+ })