@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,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,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
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `readSubjectFields` is the CANVAS door into the subject fold — the twin of
|
|
3
|
+
* the route's `subjectSchema`. It reads untrusted persisted node JSONB (a
|
|
4
|
+
* workflow write is validated only as `z.record(z.string(), z.unknown())`), so
|
|
5
|
+
* every assertion here is on the three contracts it shares with
|
|
6
|
+
* `readDirectionFields`: drop-never-throw, `undefined` never `{}`, and bounds
|
|
7
|
+
* that MATCH the wire door by shared constant.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
10
|
+
import { readSubjectFields } from "../read-node-direction.js"
|
|
11
|
+
import {
|
|
12
|
+
SUBJECT_ARRAY_CEILING,
|
|
13
|
+
SUBJECT_ID_MAX_CHARS,
|
|
14
|
+
SUBJECT_KEYS,
|
|
15
|
+
getRegisteredSubjectKeys,
|
|
16
|
+
renderSubjectHints,
|
|
17
|
+
} from "../subject-registry.js"
|
|
18
|
+
import { registerPersonPack, resetPersonPacks } from "../person-packs.js"
|
|
19
|
+
import { resetCatalogPacks } from "../catalog-packs.js"
|
|
20
|
+
import { getPersonPromptHint } from "../person.js"
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
resetPersonPacks()
|
|
24
|
+
resetCatalogPacks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const pack = {
|
|
28
|
+
id: "test/person-sector",
|
|
29
|
+
dimensions: [{ dimension: "sector-attire", field: "sectorAttire", label: "Sector Attire" }],
|
|
30
|
+
entries: [
|
|
31
|
+
{
|
|
32
|
+
id: "attire-modest-suit",
|
|
33
|
+
label: "Modest Suit",
|
|
34
|
+
group: "Attire",
|
|
35
|
+
dimension: "sector-attire",
|
|
36
|
+
description: "a modest tailored suit",
|
|
37
|
+
promptHint: "wearing a modest tailored suit",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("readSubjectFields — shape", () => {
|
|
43
|
+
it("round-trips the ids a node actually stores", () => {
|
|
44
|
+
expect(
|
|
45
|
+
readSubjectFields({
|
|
46
|
+
type: "woman",
|
|
47
|
+
hairBase: "base-buzz",
|
|
48
|
+
jewelry: ["jewelry-gold", "jewelry-silver"],
|
|
49
|
+
animal: "dog-corgi",
|
|
50
|
+
}),
|
|
51
|
+
).toEqual({
|
|
52
|
+
type: "woman",
|
|
53
|
+
hairBase: "base-buzz",
|
|
54
|
+
jewelry: ["jewelry-gold", "jewelry-silver"],
|
|
55
|
+
animal: "dog-corgi",
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("returns undefined — never {} — for a blob with nothing readable", () => {
|
|
60
|
+
expect(readSubjectFields(undefined)).toBeUndefined()
|
|
61
|
+
expect(readSubjectFields(null)).toBeUndefined()
|
|
62
|
+
expect(readSubjectFields("nope")).toBeUndefined()
|
|
63
|
+
expect(readSubjectFields([])).toBeUndefined()
|
|
64
|
+
expect(readSubjectFields({})).toBeUndefined()
|
|
65
|
+
expect(readSubjectFields({ notAField: "man" })).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("drops junk instead of throwing on it", () => {
|
|
69
|
+
expect(
|
|
70
|
+
readSubjectFields({
|
|
71
|
+
type: { nested: true },
|
|
72
|
+
hairBase: 42,
|
|
73
|
+
jewelry: [null, { x: 1 }, "jewelry-gold"],
|
|
74
|
+
makeup: "",
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({ jewelry: ["jewelry-gold"] })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("filters junk BEFORE the ceiling, so valid ids behind it survive", () => {
|
|
80
|
+
const v = [...Array.from({ length: SUBJECT_ARRAY_CEILING }, () => 1), "jewelry-gold"]
|
|
81
|
+
expect(readSubjectFields({ jewelry: v })).toEqual({ jewelry: ["jewelry-gold"] })
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("readSubjectFields — bounds shared with the wire door", () => {
|
|
86
|
+
it("keeps the first SUBJECT_ARRAY_CEILING entries of an over-long array", () => {
|
|
87
|
+
const many = Array.from({ length: SUBJECT_ARRAY_CEILING + 5 }, (_, i) => `id-${i}`)
|
|
88
|
+
const out = readSubjectFields({ jewelry: many }) as Record<string, unknown>
|
|
89
|
+
expect(out.jewelry).toEqual(many.slice(0, SUBJECT_ARRAY_CEILING))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("drops an id longer than SUBJECT_ID_MAX_CHARS, keeping one exactly at the bound", () => {
|
|
93
|
+
const ok = "x".repeat(SUBJECT_ID_MAX_CHARS)
|
|
94
|
+
const tooLong = "x".repeat(SUBJECT_ID_MAX_CHARS + 1)
|
|
95
|
+
expect(readSubjectFields({ type: tooLong })).toBeUndefined()
|
|
96
|
+
expect(readSubjectFields({ type: ok })).toEqual({ type: ok })
|
|
97
|
+
expect(readSubjectFields({ jewelry: [tooLong, ok] })).toEqual({ jewelry: [ok] })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("does NOT apply the per-dimension cap — that stays the renderer's slice", () => {
|
|
101
|
+
const four = ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver"]
|
|
102
|
+
expect((readSubjectFields({ jewelry: four }) as Record<string, unknown>).jewelry).toEqual(four)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("readSubjectFields — customAge, the one number", () => {
|
|
107
|
+
it("passes a finite number through for the renderer to clamp", () => {
|
|
108
|
+
expect(readSubjectFields({ age: "age-custom", customAge: 34 })).toEqual({
|
|
109
|
+
age: "age-custom",
|
|
110
|
+
customAge: 34,
|
|
111
|
+
})
|
|
112
|
+
expect(readSubjectFields({ customAge: 9999 })).toEqual({ customAge: 9999 })
|
|
113
|
+
expect(renderSubjectHints(readSubjectFields({ age: "age-custom", customAge: 9999 }), {
|
|
114
|
+
surface: "image",
|
|
115
|
+
})).toEqual(["120 years old"])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("drops a non-finite or non-number customAge", () => {
|
|
119
|
+
expect(readSubjectFields({ customAge: Number.NaN })).toBeUndefined()
|
|
120
|
+
expect(readSubjectFields({ customAge: "34" })).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("readSubjectFields — pack awareness", () => {
|
|
125
|
+
it("with no packs registered, the accepted key set IS SUBJECT_KEYS", () => {
|
|
126
|
+
expect(getRegisteredSubjectKeys()).toEqual([...SUBJECT_KEYS])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("reads a deployment-registered pack dimension, and folds its hint", () => {
|
|
130
|
+
expect(readSubjectFields({ sectorAttire: "attire-modest-suit" })).toBeUndefined()
|
|
131
|
+
registerPersonPack(pack)
|
|
132
|
+
expect(getRegisteredSubjectKeys()).toContain("sectorAttire")
|
|
133
|
+
expect(readSubjectFields({ sectorAttire: "attire-modest-suit" })).toEqual({
|
|
134
|
+
sectorAttire: "attire-modest-suit",
|
|
135
|
+
})
|
|
136
|
+
expect(
|
|
137
|
+
renderSubjectHints({ type: "woman", sectorAttire: "attire-modest-suit" }, { surface: "image" }),
|
|
138
|
+
).toEqual([`${getPersonPromptHint("woman")}, wearing a modest tailored suit`])
|
|
139
|
+
})
|
|
140
|
+
})
|