@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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `subject` fold where it actually lands: `assembleImageInput` (stills) and
|
|
3
|
+
* `composeVideoPromptText` (clips).
|
|
4
|
+
*
|
|
5
|
+
* THE LOAD-BEARING TEST IN THIS FILE is the NO-SUBJECT ORACLE: with no
|
|
6
|
+
* `subject` — which is every caller in the world on the day this ships — the
|
|
7
|
+
* assembled prompt must be byte-for-byte what it was before the channel
|
|
8
|
+
* existed. That is what makes the rollout dark. The oracle is written as an
|
|
9
|
+
* independent recomputation (`joinPromptHints` over `renderDirectionHints`),
|
|
10
|
+
* not as a snapshot, so it keeps testing the claim if the catalogs change.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "vitest"
|
|
13
|
+
import { assembleImageInput } from "../assemble-image-input.js"
|
|
14
|
+
import { composeVideoPromptText } from "../assemble-video-input.js"
|
|
15
|
+
import { buildImagePrompt } from "../prompt-builder.js"
|
|
16
|
+
import { joinPromptHints, PROMPT_HINT_SEPARATOR } from "../prompt-hint-join.js"
|
|
17
|
+
import { renderDirectionHints, IMAGE_HINT_MODE_DEFAULT, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
|
|
18
|
+
import {
|
|
19
|
+
renderSubjectHints,
|
|
20
|
+
SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
21
|
+
SUBJECT_VIDEO_HINT_MODE_DEFAULT,
|
|
22
|
+
} from "../subject-registry.js"
|
|
23
|
+
import { getMaxImagePromptChars } from "@nodaro/shared"
|
|
24
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
25
|
+
|
|
26
|
+
const DIRECTION = {
|
|
27
|
+
shotSize: "wide-shot",
|
|
28
|
+
timeOfDay: "golden-hour",
|
|
29
|
+
lens: "wide-24mm",
|
|
30
|
+
style: "anime",
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
const SUBJECT = {
|
|
34
|
+
type: "woman",
|
|
35
|
+
ethnicity: "east-asian",
|
|
36
|
+
hairBase: "base-short-straight",
|
|
37
|
+
makeup: "makeup-smoky",
|
|
38
|
+
animal: "dog-corgi",
|
|
39
|
+
} as const
|
|
40
|
+
|
|
41
|
+
const STRUCTURED = { person: { age: 34, hair: "auburn" } } as const
|
|
42
|
+
|
|
43
|
+
const KIRA: ConnectedReference = {
|
|
44
|
+
id: "kira-id",
|
|
45
|
+
defaultName: "Kira",
|
|
46
|
+
source: "wired-character",
|
|
47
|
+
url: "https://r2.example/kira.png",
|
|
48
|
+
characterSlug: "kira",
|
|
49
|
+
variantSlug: undefined,
|
|
50
|
+
characterCanonicalDescription: "a young woman with copper hair",
|
|
51
|
+
variantDescription: null,
|
|
52
|
+
variantDisplayName: "canonical",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("the no-subject oracle — the fold is dark until a caller opts in", () => {
|
|
56
|
+
const cases: ReadonlyArray<{ name: string; input: Parameters<typeof assembleImageInput>[0] }> = [
|
|
57
|
+
{
|
|
58
|
+
name: "bare prompt (the exact no-op contract, untrimmed)",
|
|
59
|
+
input: { userPrompt: " a knight on a hill ", provider: "nano-banana" },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "direction only",
|
|
63
|
+
input: { userPrompt: "a knight on a hill", provider: "nano-banana", direction: DIRECTION },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "structured only",
|
|
67
|
+
input: { userPrompt: "a knight on a hill", provider: "nano-banana", structured: STRUCTURED },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "direction + structured + a bound reference",
|
|
71
|
+
input: {
|
|
72
|
+
userPrompt: "@kira:1 on a hill",
|
|
73
|
+
provider: "nano-banana",
|
|
74
|
+
connectedReferences: [KIRA],
|
|
75
|
+
direction: DIRECTION,
|
|
76
|
+
structured: STRUCTURED,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
for (const { name, input } of cases) {
|
|
82
|
+
it(`is unchanged by the channel's existence — ${name}`, () => {
|
|
83
|
+
const withNothing = assembleImageInput(input)
|
|
84
|
+
// An explicitly-undefined subject, an empty bag, and a bag of keys the
|
|
85
|
+
// platform does not know are all the same as not passing one.
|
|
86
|
+
expect(assembleImageInput({ ...input, subject: undefined })).toEqual(withNothing)
|
|
87
|
+
expect(assembleImageInput({ ...input, subject: {} })).toEqual(withNothing)
|
|
88
|
+
expect(assembleImageInput({ ...input, subject: { notAField: "x" } })).toEqual(withNothing)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it("returns the user's prompt VERBATIM AND UNTRIMMED with no lever at all", () => {
|
|
93
|
+
const userPrompt = " a knight on a hill "
|
|
94
|
+
expect(assembleImageInput({ userPrompt, provider: "nano-banana" }).prompt).toBe(userPrompt)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("matches an independent recomputation of the direction-only fold", () => {
|
|
98
|
+
const userPrompt = "a knight on a hill"
|
|
99
|
+
const expected = buildImagePrompt({
|
|
100
|
+
prompt: joinPromptHints(
|
|
101
|
+
userPrompt,
|
|
102
|
+
renderDirectionHints(DIRECTION, { surface: "image", mode: IMAGE_HINT_MODE_DEFAULT }),
|
|
103
|
+
),
|
|
104
|
+
provider: "nano-banana",
|
|
105
|
+
})
|
|
106
|
+
expect(assembleImageInput({ userPrompt, provider: "nano-banana", direction: DIRECTION })).toEqual(
|
|
107
|
+
expected,
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("leaves the video composer's no-op contract intact (undefined stays undefined)", () => {
|
|
112
|
+
expect(composeVideoPromptText(undefined, undefined)).toBeUndefined()
|
|
113
|
+
expect(composeVideoPromptText(undefined, undefined, undefined, { subject: {} })).toBeUndefined()
|
|
114
|
+
expect(
|
|
115
|
+
composeVideoPromptText(undefined, undefined, undefined, { subject: { notAField: "x" } }),
|
|
116
|
+
).toBeUndefined()
|
|
117
|
+
expect(composeVideoPromptText(" a clip ", undefined, undefined, { subject: {} })).toBe(
|
|
118
|
+
" a clip ",
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("the subject fold — position and content", () => {
|
|
124
|
+
it("lands the subject clauses AHEAD of the direction clauses", () => {
|
|
125
|
+
const userPrompt = "on the seawall"
|
|
126
|
+
const subjectHints = renderSubjectHints(SUBJECT, {
|
|
127
|
+
surface: "image",
|
|
128
|
+
mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
129
|
+
})
|
|
130
|
+
const directionHints = renderDirectionHints(DIRECTION, {
|
|
131
|
+
surface: "image",
|
|
132
|
+
mode: IMAGE_HINT_MODE_DEFAULT,
|
|
133
|
+
})
|
|
134
|
+
const result = assembleImageInput({
|
|
135
|
+
userPrompt,
|
|
136
|
+
provider: "nano-banana",
|
|
137
|
+
subject: SUBJECT,
|
|
138
|
+
direction: DIRECTION,
|
|
139
|
+
})
|
|
140
|
+
expect(result.prompt).toBe(
|
|
141
|
+
buildImagePrompt({
|
|
142
|
+
prompt: joinPromptHints(userPrompt, [...subjectHints, ...directionHints]),
|
|
143
|
+
provider: "nano-banana",
|
|
144
|
+
}).prompt,
|
|
145
|
+
)
|
|
146
|
+
expect(result.prompt.indexOf(subjectHints[0]!)).toBeLessThan(
|
|
147
|
+
result.prompt.indexOf(directionHints[0]!),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("joins person and styling as ONE clause each, not N sentence fragments", () => {
|
|
152
|
+
const result = assembleImageInput({
|
|
153
|
+
userPrompt: "on the seawall",
|
|
154
|
+
provider: "nano-banana",
|
|
155
|
+
subject: { type: "woman", ethnicity: "east-asian", makeup: "makeup-smoky" },
|
|
156
|
+
})
|
|
157
|
+
// Three catalog fragments, but only ONE sentence separator from the person
|
|
158
|
+
// clause: the person row comma-joins its own fragments.
|
|
159
|
+
const sentences = result.prompt.split(PROMPT_HINT_SEPARATOR)
|
|
160
|
+
expect(sentences).toHaveLength(3) // prose. person clause. styling clause.
|
|
161
|
+
expect(sentences[1]).toContain(", ")
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("folds the subject on the video surface, compact, ahead of direction", () => {
|
|
165
|
+
const composed = composeVideoPromptText("she walks", DIRECTION, undefined, { subject: SUBJECT })
|
|
166
|
+
expect(composed).toBe(
|
|
167
|
+
joinPromptHints("she walks", [
|
|
168
|
+
...renderSubjectHints(SUBJECT, {
|
|
169
|
+
surface: "video",
|
|
170
|
+
mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
|
|
171
|
+
}),
|
|
172
|
+
...renderDirectionHints(DIRECTION, { surface: "video", mode: VIDEO_HINT_MODE_DEFAULT }),
|
|
173
|
+
]),
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("honors an explicit subject verbosity override", () => {
|
|
178
|
+
const full = composeVideoPromptText("she walks", undefined, undefined, {
|
|
179
|
+
subject: SUBJECT,
|
|
180
|
+
subjectHintMode: "full",
|
|
181
|
+
})
|
|
182
|
+
const compact = composeVideoPromptText("she walks", undefined, undefined, { subject: SUBJECT })
|
|
183
|
+
expect(full).not.toBe(compact)
|
|
184
|
+
expect(full!.length).toBeGreaterThan(compact!.length)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe("the subject fold — under the provider cap", () => {
|
|
189
|
+
const SEEDREAM_CAP = getMaxImagePromptChars("seedream")
|
|
190
|
+
// A broad direction fold plus a broad subject fold, on prose long enough that
|
|
191
|
+
// the three together clear the lowest cap in the catalog.
|
|
192
|
+
const BIG_DIRECTION = {
|
|
193
|
+
...DIRECTION,
|
|
194
|
+
angle: "low-angle",
|
|
195
|
+
cameraFormat: "16mm-film",
|
|
196
|
+
isoValue: "iso-100",
|
|
197
|
+
lightingStyle: "rembrandt",
|
|
198
|
+
colorLook: "teal-orange",
|
|
199
|
+
atmosphere: ["fog"],
|
|
200
|
+
mood: ["happy", "joyful"],
|
|
201
|
+
photographer: ["annie-leibovitz"],
|
|
202
|
+
setting: "forest",
|
|
203
|
+
} as const
|
|
204
|
+
const PROSE = "She walks the seawall at dusk. " + "The waves are loud. ".repeat(90)
|
|
205
|
+
|
|
206
|
+
it("sheds the direction fold BEFORE the subject fold", () => {
|
|
207
|
+
const subjectHints = renderSubjectHints(SUBJECT, {
|
|
208
|
+
surface: "image",
|
|
209
|
+
mode: SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
210
|
+
})
|
|
211
|
+
const directionHints = renderDirectionHints(BIG_DIRECTION, {
|
|
212
|
+
surface: "image",
|
|
213
|
+
mode: IMAGE_HINT_MODE_DEFAULT,
|
|
214
|
+
})
|
|
215
|
+
// Non-vacuity: the un-shed fold really does overflow.
|
|
216
|
+
expect(
|
|
217
|
+
joinPromptHints(PROSE, [...subjectHints, ...directionHints]).length,
|
|
218
|
+
).toBeGreaterThan(SEEDREAM_CAP)
|
|
219
|
+
|
|
220
|
+
const result = assembleImageInput({
|
|
221
|
+
userPrompt: PROSE,
|
|
222
|
+
provider: "seedream",
|
|
223
|
+
subject: SUBJECT,
|
|
224
|
+
direction: BIG_DIRECTION,
|
|
225
|
+
})
|
|
226
|
+
expect(result.prompt.length).toBeLessThanOrEqual(SEEDREAM_CAP)
|
|
227
|
+
expect(result.prompt.endsWith("...")).toBe(false)
|
|
228
|
+
// Who is in the shot outlived how it was lit.
|
|
229
|
+
expect(result.prompt).toContain(subjectHints[0])
|
|
230
|
+
expect(result.prompt).not.toContain(directionHints[directionHints.length - 1])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subject registry is the platform-owned contract for the flat `subject`
|
|
3
|
+
* wire channel: WHICH keys ride it, in WHAT order the rows fold, and HOW each
|
|
4
|
+
* catalog renders its selection. Every assertion here is a pin on that
|
|
5
|
+
* contract — a failure means a reorder / retable was intentional and the
|
|
6
|
+
* changeset has to say so.
|
|
7
|
+
*
|
|
8
|
+
* Two pins are load-bearing beyond mere table hygiene:
|
|
9
|
+
* - the FLAT-BAG dedupe (`lipState` suppressing `makeup-bold-lips`), which is
|
|
10
|
+
* the reason the wire is flat and which fails on any future nesting;
|
|
11
|
+
* - the GRAMMAR pin (person/styling arrive as ONE comma-joined clause, never N
|
|
12
|
+
* `". "`-joined fragments), which is why those rows are `kind: "group"`.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect } from "vitest"
|
|
15
|
+
import {
|
|
16
|
+
MAX_SUBJECT_KEYS,
|
|
17
|
+
SUBJECT_ARRAY_CEILING,
|
|
18
|
+
SUBJECT_CUSTOM_AGE_KEY,
|
|
19
|
+
SUBJECT_FIELDS,
|
|
20
|
+
SUBJECT_FOLD_KEYS,
|
|
21
|
+
SUBJECT_ID_MAX_CHARS,
|
|
22
|
+
SUBJECT_IMAGE_HINT_MODE_DEFAULT,
|
|
23
|
+
SUBJECT_KEYS,
|
|
24
|
+
SUBJECT_VIDEO_HINT_MODE_DEFAULT,
|
|
25
|
+
normalizeSubjectFields,
|
|
26
|
+
renderSubjectHints,
|
|
27
|
+
subjectFieldsForSurface,
|
|
28
|
+
} from "../subject-registry.js"
|
|
29
|
+
import {
|
|
30
|
+
DIRECTION_ARRAY_CEILING,
|
|
31
|
+
DIRECTION_ID_MAX_CHARS,
|
|
32
|
+
DIRECTION_KEYS,
|
|
33
|
+
} from "../direction-registry.js"
|
|
34
|
+
import {
|
|
35
|
+
PERSON_DIMENSION_ORDER,
|
|
36
|
+
PERSON_FIELD_BY_DIMENSION,
|
|
37
|
+
buildPersonHints,
|
|
38
|
+
getPersonPromptHint,
|
|
39
|
+
getPersonTerm,
|
|
40
|
+
} from "../person.js"
|
|
41
|
+
import {
|
|
42
|
+
STYLING_DIMENSION_ORDER,
|
|
43
|
+
STYLING_FIELD_BY_DIMENSION,
|
|
44
|
+
getStylingPromptHint,
|
|
45
|
+
} from "../styling.js"
|
|
46
|
+
import { getHeldPropPromptHint } from "../held-prop.js"
|
|
47
|
+
import { getAnimalPromptHint, getAnimalTerm } from "@nodaro/shared"
|
|
48
|
+
|
|
49
|
+
const IMAGE = { surface: "image" } as const
|
|
50
|
+
const VIDEO = { surface: "video" } as const
|
|
51
|
+
|
|
52
|
+
// Real catalog ids — every getter returns "" on a miss, so a fake id would make
|
|
53
|
+
// most of these assertions vacuously pass.
|
|
54
|
+
const NO_SUCH_ID = "__no_such_id__"
|
|
55
|
+
|
|
56
|
+
describe("SUBJECT_FIELDS — table integrity", () => {
|
|
57
|
+
it("has unique row keys", () => {
|
|
58
|
+
const keys = SUBJECT_FIELDS.map((f) => f.key)
|
|
59
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("gives every row a render function, and every ids row a positive maxPicks", () => {
|
|
63
|
+
for (const spec of SUBJECT_FIELDS) {
|
|
64
|
+
expect(typeof spec.render, spec.key).toBe("function")
|
|
65
|
+
if (spec.kind === "ids") expect(spec.maxPicks, spec.key).toBeGreaterThanOrEqual(1)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("exports SUBJECT_FOLD_KEYS in table order", () => {
|
|
70
|
+
expect(SUBJECT_FOLD_KEYS).toEqual(SUBJECT_FIELDS.map((f) => f.key))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("pins the fold order (a reorder is a deliberate, changeset-worthy change)", () => {
|
|
74
|
+
expect(SUBJECT_FOLD_KEYS).toEqual(["person", "styling", "heldProp", "material", "animal"])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("folds both group rows before any prop row", () => {
|
|
78
|
+
const lastGroup = SUBJECT_FIELDS.map((f) => f.kind).lastIndexOf("group")
|
|
79
|
+
const firstIds = SUBJECT_FIELDS.map((f) => f.kind).indexOf("ids")
|
|
80
|
+
expect(lastGroup).toBeLessThan(firstIds)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("folds every row on both surfaces today", () => {
|
|
84
|
+
expect(subjectFieldsForSurface("image").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
|
|
85
|
+
expect(subjectFieldsForSurface("video").map((f) => f.key)).toEqual([...SUBJECT_FOLD_KEYS])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("takes its bounds FROM the direction registry (one literal, both channels)", () => {
|
|
89
|
+
expect(SUBJECT_ID_MAX_CHARS).toBe(DIRECTION_ID_MAX_CHARS)
|
|
90
|
+
expect(SUBJECT_ARRAY_CEILING).toBe(DIRECTION_ARRAY_CEILING)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("defaults image to full clauses and video to compact terms", () => {
|
|
94
|
+
expect(SUBJECT_IMAGE_HINT_MODE_DEFAULT).toBe("full")
|
|
95
|
+
expect(SUBJECT_VIDEO_HINT_MODE_DEFAULT).toBe("compact")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("SUBJECT_KEYS — the derived wire vocabulary", () => {
|
|
100
|
+
it("is unique and fits the record bound", () => {
|
|
101
|
+
expect(new Set(SUBJECT_KEYS).size).toBe(SUBJECT_KEYS.length)
|
|
102
|
+
expect(SUBJECT_KEYS.length).toBeLessThanOrEqual(MAX_SUBJECT_KEYS)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("carries every person field, every styling field, customAge and the three props", () => {
|
|
106
|
+
for (const d of PERSON_DIMENSION_ORDER) {
|
|
107
|
+
expect(SUBJECT_KEYS, d).toContain(PERSON_FIELD_BY_DIMENSION[d])
|
|
108
|
+
}
|
|
109
|
+
for (const d of STYLING_DIMENSION_ORDER) {
|
|
110
|
+
expect(SUBJECT_KEYS, d).toContain(STYLING_FIELD_BY_DIMENSION[d])
|
|
111
|
+
}
|
|
112
|
+
expect(SUBJECT_KEYS).toContain(SUBJECT_CUSTOM_AGE_KEY)
|
|
113
|
+
expect(SUBJECT_KEYS).toContain("heldProp")
|
|
114
|
+
expect(SUBJECT_KEYS).toContain("material")
|
|
115
|
+
expect(SUBJECT_KEYS).toContain("animal")
|
|
116
|
+
expect(SUBJECT_KEYS.length).toBe(
|
|
117
|
+
PERSON_DIMENSION_ORDER.length + STYLING_DIMENSION_ORDER.length + 1 + 3,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("excludes the free-text pre/post fields (the v1 carve-out)", () => {
|
|
122
|
+
expect(SUBJECT_KEYS).not.toContain("preText")
|
|
123
|
+
expect(SUBJECT_KEYS).not.toContain("postText")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("is DISJOINT from DIRECTION_KEYS — nothing folds twice", () => {
|
|
127
|
+
const direction = new Set<string>(DIRECTION_KEYS)
|
|
128
|
+
expect(SUBJECT_KEYS.filter((k) => direction.has(k))).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe("renderSubjectHints — inertness", () => {
|
|
133
|
+
it("returns [] for undefined, {} and a non-object", () => {
|
|
134
|
+
expect(renderSubjectHints(undefined, IMAGE)).toEqual([])
|
|
135
|
+
expect(renderSubjectHints({}, IMAGE)).toEqual([])
|
|
136
|
+
expect(renderSubjectHints([] as never, IMAGE)).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("ignores unknown wire keys", () => {
|
|
140
|
+
expect(renderSubjectHints({ notAField: "man", hairColor: NO_SUCH_ID }, IMAGE)).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("skips unknown ids instead of 400ing on them", () => {
|
|
144
|
+
expect(renderSubjectHints({ type: NO_SUCH_ID, animal: NO_SUCH_ID }, IMAGE)).toEqual([])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("drops preText / postText — the carve-out that stops a double emission", () => {
|
|
148
|
+
expect(
|
|
149
|
+
renderSubjectHints(
|
|
150
|
+
{ preText: "a lone wanderer", postText: "seen from behind" } as never,
|
|
151
|
+
IMAGE,
|
|
152
|
+
),
|
|
153
|
+
).toEqual([])
|
|
154
|
+
const withPerson = renderSubjectHints(
|
|
155
|
+
{ type: "woman", preText: "a lone wanderer" } as never,
|
|
156
|
+
IMAGE,
|
|
157
|
+
)
|
|
158
|
+
expect(withPerson).toEqual([getPersonPromptHint("woman")])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe("renderSubjectHints — grammar (the R4 pin)", () => {
|
|
163
|
+
it("emits person as ONE comma-joined clause, not N fragments", () => {
|
|
164
|
+
const bag = { type: "woman", ethnicity: "east-asian", hairBase: "base-short-straight" }
|
|
165
|
+
const out = renderSubjectHints(bag, IMAGE)
|
|
166
|
+
expect(out).toHaveLength(1)
|
|
167
|
+
expect(out[0]).toBe(buildPersonHints(bag, "full").join(", "))
|
|
168
|
+
expect(out[0]).toContain(", ")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("emits styling as its own single clause, after person", () => {
|
|
172
|
+
const out = renderSubjectHints({ type: "woman", makeup: "makeup-smoky" }, IMAGE)
|
|
173
|
+
expect(out).toEqual([getPersonPromptHint("woman"), getStylingPromptHint("makeup-smoky")])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("folds the prop rows after both group rows, in table order", () => {
|
|
177
|
+
const out = renderSubjectHints(
|
|
178
|
+
{ type: "man", heldProp: "smartphone", material: "silk", animal: "dog-corgi" },
|
|
179
|
+
IMAGE,
|
|
180
|
+
)
|
|
181
|
+
expect(out[0]).toBe(getPersonPromptHint("man"))
|
|
182
|
+
expect(out).toContain(getHeldPropPromptHint("smartphone"))
|
|
183
|
+
expect(out[out.length - 1]).toBe(getAnimalPromptHint("dog-corgi"))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("renders compact terms in compact mode", () => {
|
|
187
|
+
const bag = { type: "not-defined", animal: "dog-corgi" }
|
|
188
|
+
expect(renderSubjectHints(bag, { ...VIDEO, mode: "compact" })).toEqual([
|
|
189
|
+
getPersonTerm("not-defined"),
|
|
190
|
+
getAnimalTerm("dog-corgi"),
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe("renderSubjectHints — the flat-bag behaviors", () => {
|
|
196
|
+
it("dedupes the lipstick clause across the person and styling catalogs (R3)", () => {
|
|
197
|
+
const out = renderSubjectHints(
|
|
198
|
+
{ lipState: "lip-state-bold-red", makeup: "makeup-bold-lips" },
|
|
199
|
+
IMAGE,
|
|
200
|
+
)
|
|
201
|
+
expect(out).toEqual([getPersonPromptHint("lip-state-bold-red")])
|
|
202
|
+
expect(out.join(" ")).not.toContain(getStylingPromptHint("makeup-bold-lips"))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("keeps a NON-twin makeup pick alongside the bold-red lip state", () => {
|
|
206
|
+
const out = renderSubjectHints(
|
|
207
|
+
{ lipState: "lip-state-bold-red", makeup: "makeup-smoky" },
|
|
208
|
+
IMAGE,
|
|
209
|
+
)
|
|
210
|
+
expect(out).toEqual([
|
|
211
|
+
getPersonPromptHint("lip-state-bold-red"),
|
|
212
|
+
getStylingPromptHint("makeup-smoky"),
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("folds midriff + navel into the single neutral safety clause", () => {
|
|
217
|
+
const out = renderSubjectHints(
|
|
218
|
+
{ distinctiveFeature: ["feature-midriff-visible", "feature-navel-visible"] },
|
|
219
|
+
IMAGE,
|
|
220
|
+
)
|
|
221
|
+
expect(out).toEqual(["wearing a cropped style, midriff and navel visible"])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("de-duplicates an exact repeated clause, first occurrence winning", () => {
|
|
225
|
+
const out = renderSubjectHints({ heldProp: ["smartphone", "smartphone"] }, IMAGE)
|
|
226
|
+
expect(out).toEqual([getHeldPropPromptHint("smartphone")])
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("normalizeSubjectFields — the cap the builders do not apply", () => {
|
|
231
|
+
it("slices a multi-pick dimension to its registry limit (jewelry = 3)", () => {
|
|
232
|
+
const ids = ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver", "jewelry-layered"]
|
|
233
|
+
expect(normalizeSubjectFields({ jewelry: ids })).toEqual({ jewelry: ids.slice(0, 3) })
|
|
234
|
+
const out = renderSubjectHints({ jewelry: ids }, IMAGE)
|
|
235
|
+
expect(out).toHaveLength(1)
|
|
236
|
+
expect(out[0]).toBe(ids.slice(0, 3).map(getStylingPromptHint).join(", "))
|
|
237
|
+
expect(out[0]).not.toContain(getStylingPromptHint("jewelry-silver"))
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("slices a 2-pick dimension handed the full array ceiling", () => {
|
|
241
|
+
const eight = Array.from({ length: SUBJECT_ARRAY_CEILING }, (_, i) => `texture-${i}`)
|
|
242
|
+
const normalized = normalizeSubjectFields({ skinTexture: eight }) as Record<string, unknown>
|
|
243
|
+
expect(normalized.skinTexture).toEqual(eight.slice(0, 2))
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("slices the prop rows to their row maxPicks", () => {
|
|
247
|
+
const props = ["smartphone", "smartphone-raised", "polaroid-camera"]
|
|
248
|
+
const normalized = normalizeSubjectFields({ heldProp: props }) as Record<string, unknown>
|
|
249
|
+
expect(normalized.heldProp).toEqual(props.slice(0, 2))
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("collapses a single-pick dimension's array to a bare string — without this it folds to NOTHING", () => {
|
|
253
|
+
expect(normalizeSubjectFields({ hairBase: ["base-buzz"] })).toEqual({ hairBase: "base-buzz" })
|
|
254
|
+
expect(renderSubjectHints({ hairBase: ["base-buzz"] }, IMAGE)).toEqual([
|
|
255
|
+
getPersonPromptHint("base-buzz"),
|
|
256
|
+
])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("drops unknown keys, so the persisted input_data stays platform vocabulary", () => {
|
|
260
|
+
expect(normalizeSubjectFields({ type: "man", notAField: "x", preText: "y" })).toEqual({
|
|
261
|
+
type: "man",
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("drops empty strings and non-string entries", () => {
|
|
266
|
+
expect(normalizeSubjectFields({ type: "", jewelry: ["", 7 as never, "jewelry-gold"] })).toEqual({
|
|
267
|
+
jewelry: "jewelry-gold",
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("returns undefined for an empty or all-unknown bag (never {})", () => {
|
|
272
|
+
expect(normalizeSubjectFields(undefined)).toBeUndefined()
|
|
273
|
+
expect(normalizeSubjectFields({})).toBeUndefined()
|
|
274
|
+
expect(normalizeSubjectFields({ notAField: "x" })).toBeUndefined()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("never mutates the caller's object", () => {
|
|
278
|
+
const bag = { jewelry: ["jewelry-subtle", "jewelry-statement", "jewelry-gold", "jewelry-silver"] }
|
|
279
|
+
const before = JSON.stringify(bag)
|
|
280
|
+
normalizeSubjectFields(bag)
|
|
281
|
+
expect(JSON.stringify(bag)).toBe(before)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("is idempotent (a door may normalize before the renderer does)", () => {
|
|
285
|
+
const bag = { type: "man", jewelry: ["jewelry-gold", "jewelry-silver"], customAge: 34.6 }
|
|
286
|
+
const once = normalizeSubjectFields(bag)
|
|
287
|
+
expect(normalizeSubjectFields(once)).toEqual(once)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe("customAge — the one number on the wire", () => {
|
|
292
|
+
it("renders the literal age when age === 'age-custom'", () => {
|
|
293
|
+
expect(renderSubjectHints({ age: "age-custom", customAge: 34 }, IMAGE)).toEqual([
|
|
294
|
+
"34 years old",
|
|
295
|
+
])
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("emits nothing for age-custom with no customAge", () => {
|
|
299
|
+
expect(renderSubjectHints({ age: "age-custom" }, IMAGE)).toEqual([])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("clamps and rounds to a whole 0..120", () => {
|
|
303
|
+
expect(normalizeSubjectFields({ customAge: 34.6 })).toEqual({ customAge: 35 })
|
|
304
|
+
expect(normalizeSubjectFields({ customAge: -5 })).toEqual({ customAge: 0 })
|
|
305
|
+
expect(normalizeSubjectFields({ customAge: 9999 })).toEqual({ customAge: 120 })
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("drops a non-finite or non-number customAge", () => {
|
|
309
|
+
expect(normalizeSubjectFields({ customAge: Number.NaN })).toBeUndefined()
|
|
310
|
+
expect(normalizeSubjectFields({ customAge: "34" })).toBeUndefined()
|
|
311
|
+
})
|
|
312
|
+
})
|