@nodaro/prompts 1.7.3 → 1.8.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 +5422 -4269
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1198 -61
- package/dist/index.d.ts +1198 -61
- package/dist/index.js +5336 -4271
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/b7-person-pack-e2e.test.ts +37 -0
- package/src/__tests__/catalog-funnel-ratchet.test.ts +97 -0
- package/src/__tests__/catalog-packs.test.ts +130 -0
- package/src/__tests__/catalog-sidecar-coverage.test.ts +27 -0
- package/src/__tests__/catalog-terms.test.ts +162 -0
- package/src/__tests__/character-default-role.test.ts +3 -2
- package/src/__tests__/content-free-contract.test.ts +45 -0
- package/src/__tests__/dod-replace-pack-acceptance.test.ts +41 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +2959 -0
- package/src/__tests__/fixtures/person-sector-pack.ts +27 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +5 -2
- package/src/__tests__/parameter-hint-mode.test.ts +385 -0
- package/src/__tests__/parameter-prompt-hint-pack-fallback.test.ts +25 -0
- package/src/__tests__/person-packs.test.ts +166 -0
- package/src/__tests__/project-all-catalogs.test.ts +26 -0
- package/src/__tests__/prompt-builder.test.ts +53 -0
- package/src/__tests__/registered-catalogs-funnel.test.ts +30 -0
- package/src/__tests__/registered-catalogs-guard.test.ts +11 -0
- package/src/__tests__/term.test.ts +104 -0
- package/src/__tests__/transitions.test.ts +8 -5
- package/src/__tests__/upstream-immutability.test.ts +23 -0
- package/src/action-fx.ts +67 -17
- package/src/aesthetic.ts +54 -6
- package/src/atmosphere.ts +64 -23
- package/src/backdrop.ts +44 -30
- package/src/camera-format.ts +33 -11
- package/src/camera-motions.ts +89 -0
- package/src/catalog-packs.ts +125 -0
- package/src/catalog-sidecar-coverage.ts +36 -0
- package/src/character-fx.ts +112 -39
- package/src/color-look.ts +44 -27
- package/src/composition-effects.ts +21 -7
- package/src/era.ts +24 -0
- package/src/exposure-settings.ts +76 -18
- package/src/framing.ts +100 -0
- package/src/held-prop.ts +125 -63
- package/src/identity-lock.ts +12 -5
- package/src/image-reference-doctrine.ts +55 -0
- package/src/index.ts +5 -0
- package/src/instrumentation.ts +148 -57
- package/src/lens.ts +31 -15
- package/src/lighting.ts +120 -59
- package/src/loop-subject.ts +27 -1
- package/src/materials.ts +123 -69
- package/src/mood.ts +123 -51
- package/src/music-genre.ts +171 -67
- package/src/music-mood.ts +85 -17
- package/src/parameter-prompt-hint.ts +168 -56
- package/src/person-packs.ts +182 -0
- package/src/person.ts +506 -408
- package/src/photo-genre.ts +37 -22
- package/src/photographer.ts +138 -1
- package/src/picker-catalogs.ts +86 -40
- package/src/pose.ts +105 -40
- package/src/post-process-effects.ts +51 -8
- package/src/prompt-builder.ts +16 -1
- package/src/render-quality.ts +25 -7
- package/src/setting.ts +30 -14
- package/src/style.ts +33 -15
- package/src/styling.ts +155 -94
- package/src/temporal.ts +80 -18
- package/src/term.ts +155 -0
- package/src/transitions.ts +73 -26
- package/src/voice-character.ts +190 -90
- package/src/voice-delivery.ts +83 -12
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PersonPack } from "../../person-packs.js"
|
|
2
|
+
// A neutral, non-SAI fixture exercising the B7 shape: a new person dimension
|
|
3
|
+
// with entries, a Hebrew sidecar, and the other 10 locales declared exempt.
|
|
4
|
+
export const PERSON_SECTOR_PACK: PersonPack = {
|
|
5
|
+
id: "fixture/person-sector",
|
|
6
|
+
dimensions: [{ dimension: "sector-attire", field: "sectorAttire", label: "Sector Attire" }],
|
|
7
|
+
entries: [
|
|
8
|
+
{
|
|
9
|
+
id: "attire-modest-suit",
|
|
10
|
+
label: "Modest Suit",
|
|
11
|
+
group: "Attire",
|
|
12
|
+
dimension: "sector-attire",
|
|
13
|
+
description: "a modest tailored suit",
|
|
14
|
+
promptHint: "wearing a modest tailored suit",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "attire-long-coat",
|
|
18
|
+
label: "Long Coat",
|
|
19
|
+
group: "Attire",
|
|
20
|
+
dimension: "sector-attire",
|
|
21
|
+
description: "a long formal coat",
|
|
22
|
+
promptHint: "wearing a long formal coat",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
sidecars: { he: { "attire-modest-suit": { label: "חליפה צנועה" }, "attire-long-coat": { label: "מעיל ארוך" } } },
|
|
26
|
+
exemptSidecarLocales: ["es", "fr", "de", "pt-BR", "ru", "hi", "ja", "ko", "zh-CN", "ar"],
|
|
27
|
+
}
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
*/
|
|
65
65
|
|
|
66
66
|
import { describe, it, expect } from "vitest"
|
|
67
|
-
import {
|
|
67
|
+
import { getRegisteredPickerCatalogs, type PickerOption } from "../picker-catalogs.js"
|
|
68
68
|
import { NON_EN_LOCALE_IDS, type LocaleId, type LocaleCatalogMap } from "@nodaro/shared"
|
|
69
69
|
|
|
70
70
|
const LOCALES = NON_EN_LOCALE_IDS
|
|
@@ -81,7 +81,10 @@ interface CatalogEntries {
|
|
|
81
81
|
readonly options: readonly PickerOption[]
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
// Enumerate the REGISTERED (pack-composed) set — inert/identical to the base
|
|
85
|
+
// with no packs registered (mainline default), so the standing per-entry cases
|
|
86
|
+
// stay green; a registered pack's added ids are then covered here too.
|
|
87
|
+
const CATALOGS: readonly CatalogEntries[] = getRegisteredPickerCatalogs().map((c) => {
|
|
85
88
|
const collected: PickerOption[] = []
|
|
86
89
|
const seen = new Set<string>()
|
|
87
90
|
const push = (opts: readonly PickerOption[] | undefined) => {
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
|
|
4
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
5
|
+
import { PICKER_CATALOGS, type PickerCatalog, type PickerOption } from "../picker-catalogs.js"
|
|
6
|
+
import type { HintGraphContext, HintNodeLike } from "@nodaro/shared"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Node-level hint mode: a picker node may set `data.hintMode = "compact"` to
|
|
10
|
+
* inject its short professional `term` instead of the long `promptHint`.
|
|
11
|
+
*
|
|
12
|
+
* Two properties are load-bearing and tested here:
|
|
13
|
+
*
|
|
14
|
+
* (a) FULL IS BYTE-IDENTICAL. `hintMode` absent — and `hintMode: "full"` —
|
|
15
|
+
* must reproduce the golden fixture exactly, so a diff here means verbose
|
|
16
|
+
* prompt text moved for real users.
|
|
17
|
+
*
|
|
18
|
+
* What the fixture IS: the byte-exact output of
|
|
19
|
+
* `getParameterPromptHint` at the moment it was captured by
|
|
20
|
+
* `scripts/gen-parameter-hint-golden.ts`, which ran AFTER the catalogs
|
|
21
|
+
* grew their `term` fields. It therefore pins full mode against
|
|
22
|
+
* regressions from here on; it is not by itself evidence that full mode
|
|
23
|
+
* still matches the pre-compact-mode implementation. That equivalence was
|
|
24
|
+
* verified separately, by replaying every pinned case against the
|
|
25
|
+
* pre-change `parameter-prompt-hint.ts` on `origin/dev` (0 mismatches),
|
|
26
|
+
* and it holds because compact mode only ever ADDS a branch: `term` is a
|
|
27
|
+
* new field, and every `promptHint` that predates this branch is
|
|
28
|
+
* untouched.
|
|
29
|
+
*
|
|
30
|
+
* (b) COMPACT SWAPS ONLY THE BASE FRAGMENT. Every catalog injects its term
|
|
31
|
+
* and not its paragraph; pre/post free text, timing clauses, multi-pick
|
|
32
|
+
* joining and multi-dimension composition are unchanged.
|
|
33
|
+
*
|
|
34
|
+
* Plus (c): an unrecognized `hintMode` falls back to full — compact is opt-in
|
|
35
|
+
* and a typo must never silently shorten a prompt.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
interface GoldenCase {
|
|
39
|
+
readonly key: string
|
|
40
|
+
readonly note: string
|
|
41
|
+
readonly node: HintNodeLike
|
|
42
|
+
readonly ctx: HintGraphContext | null
|
|
43
|
+
readonly expected: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const golden = JSON.parse(
|
|
47
|
+
readFileSync(new URL("./fixtures/parameter-hint-golden.json", import.meta.url), "utf8"),
|
|
48
|
+
) as { readonly cases: readonly GoldenCase[] }
|
|
49
|
+
|
|
50
|
+
/** The fixture pins its own node/data, so it never re-derives ids at run time. */
|
|
51
|
+
const GOLDEN_CASES = golden.cases
|
|
52
|
+
|
|
53
|
+
function withMode(node: HintNodeLike, hintMode: unknown): HintNodeLike {
|
|
54
|
+
return { ...node, data: { ...(node.data as Record<string, unknown>), hintMode } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// (a) full mode is byte-identical to the pre-compact-mode implementation
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe("hint mode: full output is byte-identical to the golden fixture", () => {
|
|
62
|
+
it("the fixture is non-trivial and covers every registered catalog", () => {
|
|
63
|
+
expect(GOLDEN_CASES.length).toBeGreaterThan(150)
|
|
64
|
+
const coveredTypes = new Set(GOLDEN_CASES.map((c) => c.node.type))
|
|
65
|
+
for (const cat of PICKER_CATALOGS) {
|
|
66
|
+
expect(coveredTypes.has(cat.nodeType), `no golden case for ${cat.nodeType}`).toBe(true)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it.each(GOLDEN_CASES.map((c) => [c.key, c] as const))(
|
|
71
|
+
"%s — hintMode absent reproduces the golden",
|
|
72
|
+
(_key, c) => {
|
|
73
|
+
expect(getParameterPromptHint(c.node, c.ctx ?? undefined)).toBe(c.expected)
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
it.each(GOLDEN_CASES.map((c) => [c.key, c] as const))(
|
|
78
|
+
'%s — hintMode "full" reproduces the golden',
|
|
79
|
+
(_key, c) => {
|
|
80
|
+
expect(getParameterPromptHint(withMode(c.node, "full"), c.ctx ?? undefined)).toBe(c.expected)
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// (b) compact mode injects the term, never the paragraph
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Dimensions whose node-data field is array-ONLY (never a bare string). */
|
|
90
|
+
const ARRAY_ONLY_FIELDS = new Set(["instruments"])
|
|
91
|
+
|
|
92
|
+
function fieldValue(field: string, id: string): unknown {
|
|
93
|
+
return ARRAY_ONLY_FIELDS.has(field) ? [id] : id
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CatalogProbe {
|
|
97
|
+
readonly nodeType: string
|
|
98
|
+
readonly field: string
|
|
99
|
+
readonly option: PickerOption
|
|
100
|
+
/** The term is a genuine shortening of this entry's hint. */
|
|
101
|
+
readonly shortens: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One probe per registered catalog: the first option that injects something,
|
|
106
|
+
* PREFERRING one whose term is a genuine shortening of its hint.
|
|
107
|
+
*
|
|
108
|
+
* Not every catalog has one. The Sound catalogs (music-mood, voice-character,
|
|
109
|
+
* voice-delivery, and much of music-genre / instrumentation) are already
|
|
110
|
+
* written as terse vocabulary — "mellow", "drum machine", "drawled" — so their
|
|
111
|
+
* hint IS the trade term and there is nothing for compact mode to shorten.
|
|
112
|
+
* Those entries are still exercised (compact must inject the term), only the
|
|
113
|
+
* "shorter than full" claim is skipped, and the count of genuinely shortening
|
|
114
|
+
* catalogs is asserted below so this can't quietly become the norm.
|
|
115
|
+
*/
|
|
116
|
+
function probeFor(cat: PickerCatalog): CatalogProbe | undefined {
|
|
117
|
+
const field = cat.kind === "single" ? cat.valueField : cat.dimensions?.[0]?.field
|
|
118
|
+
const options = cat.kind === "single" ? cat.options : cat.dimensions?.[0]?.options
|
|
119
|
+
if (!field || !options) return undefined
|
|
120
|
+
const injecting = options.filter((o) => o.promptHint.length > 0 && o.term.length > 0)
|
|
121
|
+
const shortens = (o: PickerOption) => !o.term.includes(o.promptHint) && o.term.length < o.promptHint.length
|
|
122
|
+
const option = injecting.find(shortens) ?? injecting[0]
|
|
123
|
+
return option ? { nodeType: cat.nodeType, field, option, shortens: shortens(option) } : undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PROBES: CatalogProbe[] = PICKER_CATALOGS.map(probeFor).filter(
|
|
127
|
+
(p): p is CatalogProbe => p !== undefined,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
describe("hint mode: compact injects the term instead of the promptHint", () => {
|
|
131
|
+
it("every registered catalog yields a probe", () => {
|
|
132
|
+
expect(PROBES).toHaveLength(PICKER_CATALOGS.length)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("the large majority of catalogs genuinely shorten", () => {
|
|
136
|
+
expect(PROBES.filter((p) => p.shortens).length).toBeGreaterThanOrEqual(30)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it.each(PROBES.map((p) => [p.nodeType, p] as const))("%s", (_type, probe) => {
|
|
140
|
+
const node: HintNodeLike = {
|
|
141
|
+
id: "n1",
|
|
142
|
+
type: probe.nodeType,
|
|
143
|
+
data: { [probe.field]: fieldValue(probe.field, probe.option.id), hintMode: "compact" },
|
|
144
|
+
}
|
|
145
|
+
const compact = getParameterPromptHint(node)
|
|
146
|
+
const full = getParameterPromptHint(withMode(node, "full"))
|
|
147
|
+
|
|
148
|
+
expect(compact).toContain(probe.option.term)
|
|
149
|
+
expect(full).toContain(probe.option.promptHint)
|
|
150
|
+
if (probe.shortens) {
|
|
151
|
+
expect(compact).not.toContain(probe.option.promptHint)
|
|
152
|
+
expect(compact.length).toBeLessThan(full.length)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// (b continued) everything wrapped around the base fragment is mode-invariant
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("hint mode: compact preserves everything but the base fragment", () => {
|
|
162
|
+
it("preText and postText still wrap the fragment", () => {
|
|
163
|
+
const data = { setting: "forest", preText: "shot on location", postText: "no text overlays" }
|
|
164
|
+
const compact = getParameterPromptHint({ id: "n1", type: "setting", data: { ...data, hintMode: "compact" } })
|
|
165
|
+
expect(compact.startsWith("shot on location, ")).toBe(true)
|
|
166
|
+
expect(compact.endsWith(", no text overlays")).toBe(true)
|
|
167
|
+
// ...and the middle is the term, not the paragraph.
|
|
168
|
+
const full = getParameterPromptHint({ id: "n1", type: "setting", data })
|
|
169
|
+
expect(compact).not.toBe(full)
|
|
170
|
+
expect(compact.length).toBeLessThan(full.length)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("transition timing clauses are emitted identically in compact mode", () => {
|
|
174
|
+
const data = {
|
|
175
|
+
transition: "cross-dissolve",
|
|
176
|
+
position: "middle",
|
|
177
|
+
duration: "short",
|
|
178
|
+
intensity: "dynamic",
|
|
179
|
+
}
|
|
180
|
+
const compact = getParameterPromptHint({ id: "n1", type: "transition", data: { ...data, hintMode: "compact" } })
|
|
181
|
+
expect(compact).toContain("the transition occurs in the middle of the clip")
|
|
182
|
+
expect(compact).toContain("lasting approximately 1 second")
|
|
183
|
+
expect(compact).toContain("with dynamic energy and assertive flourish")
|
|
184
|
+
expect(compact).toContain("cross-dissolve")
|
|
185
|
+
expect(compact.length).toBeLessThan(getParameterPromptHint({ id: "n1", type: "transition", data }).length)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("character-fx timing clauses are emitted identically in compact mode", () => {
|
|
189
|
+
const fx = firstInjectingId("character-fx")
|
|
190
|
+
const data = { characterFx: fx, position: "start", duration: "medium", intensity: "crazy" }
|
|
191
|
+
const compact = getParameterPromptHint({ id: "n1", type: "character-fx", data: { ...data, hintMode: "compact" } })
|
|
192
|
+
expect(compact).toContain("the effect occurs at the opening of the clip")
|
|
193
|
+
expect(compact).toContain("manifesting over approximately 2 seconds")
|
|
194
|
+
expect(compact).toContain("with extreme exaggerated energy, wild flourishes, and dramatic distortion")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("the wired character is NAMED in both modes", () => {
|
|
198
|
+
// Regression: compact mode used to run the full mode's substitution — a
|
|
199
|
+
// regex replace of "the subject" — over a `term` that never contains those
|
|
200
|
+
// words, so the target silently vanished from every compact character-fx
|
|
201
|
+
// fragment while full mode named it.
|
|
202
|
+
const fx = firstInjectingId("character-fx")
|
|
203
|
+
const ctx: HintGraphContext = {
|
|
204
|
+
nodes: [{ id: "c1", type: "character-ref", data: { characterName: "Mira" } }],
|
|
205
|
+
edges: [{ source: "c1", target: "n1", targetHandle: "target" }],
|
|
206
|
+
}
|
|
207
|
+
const node: HintNodeLike = { id: "n1", type: "character-fx", data: { characterFx: fx } }
|
|
208
|
+
const full = getParameterPromptHint(node, ctx)
|
|
209
|
+
const compact = getParameterPromptHint(withMode(node, "compact"), ctx)
|
|
210
|
+
|
|
211
|
+
expect(full).toContain("Mira")
|
|
212
|
+
expect(full).not.toContain("the subject")
|
|
213
|
+
expect(compact).toContain("Mira")
|
|
214
|
+
// ...and compact is still the term, not the paragraph.
|
|
215
|
+
const term = optionFor("character-fx", "characterFx", fx).term
|
|
216
|
+
expect(compact).toContain(term)
|
|
217
|
+
expect(compact.length).toBeLessThan(full.length)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("an unwired character-fx names no target in either mode", () => {
|
|
221
|
+
const fx = firstInjectingId("character-fx")
|
|
222
|
+
const term = optionFor("character-fx", "characterFx", fx).term
|
|
223
|
+
const compact = getParameterPromptHint({
|
|
224
|
+
id: "n1",
|
|
225
|
+
type: "character-fx",
|
|
226
|
+
data: { characterFx: fx, hintMode: "compact" },
|
|
227
|
+
})
|
|
228
|
+
// No target wired ⇒ no prefix at all, just the term.
|
|
229
|
+
expect(compact).toBe(term)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("multi-pick joins the TERMS with the same ', and ' separator", () => {
|
|
233
|
+
const options = registeredOptions("transition").filter((o) => o.promptHint.length > 0)
|
|
234
|
+
const [a, b] = options
|
|
235
|
+
const compact = getParameterPromptHint({
|
|
236
|
+
id: "n1",
|
|
237
|
+
type: "transition",
|
|
238
|
+
data: { transition: [a.id, b.id], hintMode: "compact" },
|
|
239
|
+
})
|
|
240
|
+
expect(compact).toBe(`${a.term}, and ${b.term}`)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it("multi-dimension composition still walks every dimension", () => {
|
|
244
|
+
const data = {
|
|
245
|
+
shotSize: "medium-shot",
|
|
246
|
+
angle: "low-angle",
|
|
247
|
+
hintMode: "compact",
|
|
248
|
+
}
|
|
249
|
+
const compact = getParameterPromptHint({ id: "n1", type: "framing", data })
|
|
250
|
+
const shotSize = optionFor("framing", "shotSize", "medium-shot")
|
|
251
|
+
const angle = optionFor("framing", "angle", "low-angle")
|
|
252
|
+
expect(compact).toContain(shotSize.term)
|
|
253
|
+
expect(compact).toContain(angle.term)
|
|
254
|
+
expect(compact).not.toContain(shotSize.promptHint)
|
|
255
|
+
expect(compact).not.toContain(angle.promptHint)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("object entities inject the bare term without the framing verb or description", () => {
|
|
259
|
+
const compact = getParameterPromptHint({
|
|
260
|
+
id: "n1",
|
|
261
|
+
type: "animal",
|
|
262
|
+
data: { animal: "dog-golden-retriever", hintMode: "compact" },
|
|
263
|
+
})
|
|
264
|
+
expect(compact).toBe("golden retriever")
|
|
265
|
+
const full = getParameterPromptHint({ id: "n1", type: "animal", data: { animal: "dog-golden-retriever" } })
|
|
266
|
+
expect(full.startsWith("featuring a golden retriever, ")).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("free-text nodes are identical in both modes", () => {
|
|
270
|
+
for (const [type, data] of [
|
|
271
|
+
["text-prompt", { text: "a lighthouse at dusk" }],
|
|
272
|
+
["style-guide", { text: "Always warm, never neon." }],
|
|
273
|
+
["tone", { tone: "wry and understated" }],
|
|
274
|
+
] as const) {
|
|
275
|
+
const full = getParameterPromptHint({ id: "n1", type, data })
|
|
276
|
+
const compact = getParameterPromptHint({ id: "n1", type, data: { ...data, hintMode: "compact" } })
|
|
277
|
+
expect(compact).toBe(full)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('a no-op "auto" entry injects nothing in either mode', () => {
|
|
282
|
+
for (const [type, field] of [
|
|
283
|
+
["transition", "transition"],
|
|
284
|
+
["character-fx", "characterFx"],
|
|
285
|
+
["camera-motion", "cameraMotion"],
|
|
286
|
+
] as const) {
|
|
287
|
+
expect(getParameterPromptHint({ id: "n1", type, data: { [field]: "auto", hintMode: "compact" } })).toBe("")
|
|
288
|
+
expect(getParameterPromptHint({ id: "n1", type, data: { [field]: "auto" } })).toBe("")
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// (b continued) the mode rides down into startState / endState inputs
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
describe("hint mode: graph-composed clauses stay at one level of detail", () => {
|
|
298
|
+
const ctx: HintGraphContext = {
|
|
299
|
+
nodes: [
|
|
300
|
+
{ id: "s1", type: "framing", data: { shotSize: "wide-shot" } },
|
|
301
|
+
{ id: "s2", type: "mood", data: { mood: "calm" } },
|
|
302
|
+
],
|
|
303
|
+
edges: [
|
|
304
|
+
{ source: "s1", target: "n1", targetHandle: "startState" },
|
|
305
|
+
{ source: "s2", target: "n1", targetHandle: "endState" },
|
|
306
|
+
],
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
it("a compact camera-motion composes compact start/end clauses", () => {
|
|
310
|
+
const compact = getParameterPromptHint(
|
|
311
|
+
{ id: "n1", type: "camera-motion", data: { cameraMotion: "dolly-in", hintMode: "compact" } },
|
|
312
|
+
ctx,
|
|
313
|
+
)
|
|
314
|
+
const shotSize = optionFor("framing", "shotSize", "wide-shot")
|
|
315
|
+
expect(compact).toContain("beginning with ")
|
|
316
|
+
expect(compact).toContain("ending with ")
|
|
317
|
+
expect(compact).toContain(shotSize.term)
|
|
318
|
+
expect(compact).not.toContain(shotSize.promptHint)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("an upstream node that declares its OWN mode wins over the inherited one", () => {
|
|
322
|
+
const fullChildCtx: HintGraphContext = {
|
|
323
|
+
nodes: [{ id: "s1", type: "framing", data: { shotSize: "wide-shot", hintMode: "full" } }],
|
|
324
|
+
edges: [{ source: "s1", target: "n1", targetHandle: "startState" }],
|
|
325
|
+
}
|
|
326
|
+
const compact = getParameterPromptHint(
|
|
327
|
+
{ id: "n1", type: "camera-motion", data: { cameraMotion: "dolly-in", hintMode: "compact" } },
|
|
328
|
+
fullChildCtx,
|
|
329
|
+
)
|
|
330
|
+
expect(compact).toContain(optionFor("framing", "shotSize", "wide-shot").promptHint)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// (c) unknown / malformed hintMode values fall back to full
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe("hint mode: unrecognized values fall back to full", () => {
|
|
339
|
+
const BOGUS: readonly unknown[] = ["Compact", "COMPACT", "short", "terse", "", 1, true, null, {}, ["compact"]]
|
|
340
|
+
|
|
341
|
+
it.each(GOLDEN_CASES.filter((c) => c.expected.length > 0).slice(0, 12).map((c) => [c.key, c] as const))(
|
|
342
|
+
"%s — every bogus hintMode reproduces the golden",
|
|
343
|
+
(_key, c) => {
|
|
344
|
+
for (const bogus of BOGUS) {
|
|
345
|
+
expect(getParameterPromptHint(withMode(c.node, bogus), c.ctx ?? undefined)).toBe(c.expected)
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
it("a bogus mode does not leak compact into inherited start/end clauses either", () => {
|
|
351
|
+
const ctx: HintGraphContext = {
|
|
352
|
+
nodes: [{ id: "s1", type: "framing", data: { shotSize: "wide-shot" } }],
|
|
353
|
+
edges: [{ source: "s1", target: "n1", targetHandle: "startState" }],
|
|
354
|
+
}
|
|
355
|
+
const out = getParameterPromptHint(
|
|
356
|
+
{ id: "n1", type: "camera-motion", data: { cameraMotion: "dolly-in", hintMode: "kinda-short" } },
|
|
357
|
+
ctx,
|
|
358
|
+
)
|
|
359
|
+
expect(out).toContain(optionFor("framing", "shotSize", "wide-shot").promptHint)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// helpers
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function registeredOptions(nodeType: string): readonly PickerOption[] {
|
|
368
|
+
const cat = PICKER_CATALOGS.find((c) => c.nodeType === nodeType)
|
|
369
|
+
if (!cat?.options) throw new Error(`no single-dim catalog for ${nodeType}`)
|
|
370
|
+
return cat.options
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function firstInjectingId(nodeType: string): string {
|
|
374
|
+
const opt = registeredOptions(nodeType).find((o) => o.promptHint.length > 0)
|
|
375
|
+
if (!opt) throw new Error(`no injecting option for ${nodeType}`)
|
|
376
|
+
return opt.id
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function optionFor(nodeType: string, field: string, id: string): PickerOption {
|
|
380
|
+
const cat = PICKER_CATALOGS.find((c) => c.nodeType === nodeType)
|
|
381
|
+
const options = cat?.dimensions?.find((d) => d.field === field)?.options ?? cat?.options
|
|
382
|
+
const opt = options?.find((o) => o.id === id)
|
|
383
|
+
if (!opt) throw new Error(`no option ${nodeType}.${field}=${id}`)
|
|
384
|
+
return opt
|
|
385
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
3
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
4
|
+
|
|
5
|
+
beforeEach(() => resetCatalogPacks())
|
|
6
|
+
|
|
7
|
+
// NOTE: getParameterPromptHint reads picker fields from node.data (see the
|
|
8
|
+
// existing parameter-prompt-hint.test.ts convention `{ id, type, data: {...} }`).
|
|
9
|
+
describe("pack-extend promptHint fallback (single-dim)", () => {
|
|
10
|
+
it("base ids resolve exactly as before", () => {
|
|
11
|
+
// 'forest' is a base 'setting' id; its hint is unchanged.
|
|
12
|
+
expect(getParameterPromptHint({ id: "n1", type: "setting", data: { setting: "forest" } })).toBeTypeOf("string")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("a pack-added single-dim id resolves to the pack option promptHint", () => {
|
|
16
|
+
registerCatalogPack({ id: "sai/setting", catalogId: "setting", mode: "extend",
|
|
17
|
+
options: [{ id: "shul-hall", label: "Shul Hall", promptHint: "in a synagogue hall", term: "shul hall" }] })
|
|
18
|
+
expect(getParameterPromptHint({ id: "n1", type: "setting", data: { setting: "shul-hall" } }))
|
|
19
|
+
.toBe("in a synagogue hall")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("an unknown id still yields empty string", () => {
|
|
23
|
+
expect(getParameterPromptHint({ id: "n1", type: "setting", data: { setting: "does-not-exist" } })).toBe("")
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { getPerson, getPersonPromptHint, buildPersonHints, getPickerCatalog } from "../index.js"
|
|
3
|
+
import {
|
|
4
|
+
registerPersonPack,
|
|
5
|
+
resetPersonPacks,
|
|
6
|
+
getRegisteredPersonDimensionOrder,
|
|
7
|
+
getRegisteredPeople,
|
|
8
|
+
} from "../person-packs.js"
|
|
9
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
10
|
+
import { PEOPLE } from "../person.js"
|
|
11
|
+
import { getParameterValue, resolveLabel } from "@nodaro/shared"
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetPersonPacks()
|
|
15
|
+
resetCatalogPacks()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const pack = {
|
|
19
|
+
id: "sai/person-sector",
|
|
20
|
+
dimensions: [{ dimension: "sector-attire", field: "sectorAttire", label: "Sector Attire" }],
|
|
21
|
+
entries: [
|
|
22
|
+
{
|
|
23
|
+
id: "attire-modest-suit",
|
|
24
|
+
label: "Modest Suit",
|
|
25
|
+
group: "Attire",
|
|
26
|
+
dimension: "sector-attire",
|
|
27
|
+
description: "a modest tailored suit",
|
|
28
|
+
promptHint: "wearing a modest tailored suit",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
sidecars: { he: { "attire-modest-suit": { label: "חליפה צנועה" } } },
|
|
32
|
+
exemptSidecarLocales: ["hi", "ja", "ko", "zh-CN", "ru", "ar", "es", "fr", "de", "pt-BR"] as const,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("registerPersonPack", () => {
|
|
36
|
+
it("makes the new entry resolvable via getPerson + getPersonPromptHint", () => {
|
|
37
|
+
expect(getPerson("attire-modest-suit")).toBeUndefined()
|
|
38
|
+
registerPersonPack(pack)
|
|
39
|
+
expect(getPerson("attire-modest-suit")?.label).toBe("Modest Suit")
|
|
40
|
+
expect(getPersonPromptHint("attire-modest-suit")).toBe("wearing a modest tailored suit")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("adds the new dimension to the registered dimension order + person picker catalog", () => {
|
|
44
|
+
registerPersonPack(pack)
|
|
45
|
+
expect(getRegisteredPersonDimensionOrder()).toContain("sector-attire")
|
|
46
|
+
const person = getPickerCatalog("person")!
|
|
47
|
+
expect(person.fields).toContain("sectorAttire")
|
|
48
|
+
expect(person.dimensions!.find((d) => d.field === "sectorAttire")!.options.map((o) => o.id)).toEqual([
|
|
49
|
+
"attire-modest-suit",
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("composes the pack dimension's hint into buildPersonHints output", () => {
|
|
54
|
+
registerPersonPack(pack)
|
|
55
|
+
const hints = buildPersonHints({ sectorAttire: "attire-modest-suit" } as never)
|
|
56
|
+
expect(hints).toContain("wearing a modest tailored suit")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("base person behaviour is unchanged with no pack", () => {
|
|
60
|
+
expect(getRegisteredPersonDimensionOrder()).not.toContain("sector-attire")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("getRegisteredPeople honors deny/replace CatalogPacks on person", () => {
|
|
65
|
+
it("deny pack removes a base person id from the registered set", () => {
|
|
66
|
+
// "man" is a base person entry (person.ts type dim).
|
|
67
|
+
expect(getRegisteredPeople().some((e) => e.id === "man")).toBe(true)
|
|
68
|
+
registerCatalogPack({ id: "sai/person-deny", catalogId: "person", mode: "deny", denyIds: ["man", "woman"] })
|
|
69
|
+
const ids = getRegisteredPeople().map((e) => e.id)
|
|
70
|
+
expect(ids).not.toContain("man")
|
|
71
|
+
expect(ids).not.toContain("woman")
|
|
72
|
+
// a non-denied base entry survives
|
|
73
|
+
expect(ids).toContain("not-defined")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("deny is applied AFTER extend so the pack's own added entries survive an unrelated deny", () => {
|
|
77
|
+
registerPersonPack(pack) // adds attire-modest-suit (sector-attire dim)
|
|
78
|
+
registerCatalogPack({ id: "sai/person-deny", catalogId: "person", mode: "deny", denyIds: ["woman"] })
|
|
79
|
+
const ids = getRegisteredPeople().map((e) => e.id)
|
|
80
|
+
expect(ids).toContain("attire-modest-suit")
|
|
81
|
+
expect(ids).not.toContain("woman")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("replace pack reconstructs the person set from the vendored catalog (removed base ids gone)", () => {
|
|
85
|
+
registerCatalogPack({
|
|
86
|
+
id: "sai/person-replace",
|
|
87
|
+
catalogId: "person",
|
|
88
|
+
mode: "replace",
|
|
89
|
+
catalog: {
|
|
90
|
+
nodeType: "person",
|
|
91
|
+
label: "Person",
|
|
92
|
+
catalogId: "person",
|
|
93
|
+
kind: "multi",
|
|
94
|
+
fields: ["type"],
|
|
95
|
+
dimensions: [
|
|
96
|
+
{ field: "type", label: "Type", options: [{ id: "man", label: "Man", promptHint: "a man", term: "man", category: "Realistic" }] },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
const ids = getRegisteredPeople().map((e) => e.id)
|
|
101
|
+
expect(ids).toEqual(["man"]) // only the vendored copy; every other base entry is gone
|
|
102
|
+
const reconstructed = getRegisteredPeople()[0]
|
|
103
|
+
expect(reconstructed.dimension).toBe("type") // field "type" reverse-maps to dimension "type"
|
|
104
|
+
expect(reconstructed.group).toBe("Realistic") // category -> group
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("no packs registered = base PEOPLE unchanged (mainline inert)", () => {
|
|
108
|
+
expect(getRegisteredPeople().map((e) => e.id)).toEqual(PEOPLE.map((e) => e.id))
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** Every id the composed "person" catalog exposes (what /v1/catalogs + MCP read). */
|
|
113
|
+
function composedPersonIds(): Set<string> {
|
|
114
|
+
const cat = getPickerCatalog("person")!
|
|
115
|
+
return new Set((cat.dimensions ?? []).flatMap((d) => d.options.map((o) => o.id)))
|
|
116
|
+
}
|
|
117
|
+
/** Every id the picker-ui grids read. */
|
|
118
|
+
function gridPersonIds(): Set<string> {
|
|
119
|
+
return new Set(getRegisteredPeople().map((e) => e.id))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("person grid and /v1/catalogs share one curation source (no drift)", () => {
|
|
123
|
+
it("a denied id is absent from BOTH consumers", () => {
|
|
124
|
+
// present in both before deny
|
|
125
|
+
expect(gridPersonIds().has("woman")).toBe(true)
|
|
126
|
+
expect(composedPersonIds().has("woman")).toBe(true)
|
|
127
|
+
registerCatalogPack({ id: "sai/deny", catalogId: "person", mode: "deny", denyIds: ["woman", "beautiful-woman"] })
|
|
128
|
+
for (const id of ["woman", "beautiful-woman"]) {
|
|
129
|
+
expect(gridPersonIds().has(id)).toBe(false)
|
|
130
|
+
expect(composedPersonIds().has(id)).toBe(false)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
it("an extended id is present in BOTH consumers", () => {
|
|
134
|
+
registerPersonPack(pack) // adds attire-modest-suit
|
|
135
|
+
expect(gridPersonIds().has("attire-modest-suit")).toBe(true)
|
|
136
|
+
expect(composedPersonIds().has("attire-modest-suit")).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
it("extend + deny together: pack entry present in both, denied base absent from both", () => {
|
|
139
|
+
registerPersonPack(pack)
|
|
140
|
+
registerCatalogPack({ id: "sai/deny", catalogId: "person", mode: "deny", denyIds: ["woman"] })
|
|
141
|
+
expect(gridPersonIds().has("attire-modest-suit")).toBe(true)
|
|
142
|
+
expect(composedPersonIds().has("attire-modest-suit")).toBe(true)
|
|
143
|
+
expect(gridPersonIds().has("woman")).toBe(false)
|
|
144
|
+
expect(composedPersonIds().has("woman")).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe("registerPersonPack wires getParameterValue (G4)", () => {
|
|
149
|
+
it("registerPersonPack makes its dimension resolve in getParameterValue (G4)", () => {
|
|
150
|
+
expect(getParameterValue({ sectorAttire: "attire-modest-suit" }, "person")).toBeUndefined()
|
|
151
|
+
registerPersonPack(pack)
|
|
152
|
+
expect(getParameterValue({ sectorAttire: "attire-modest-suit" }, "person")).toBe("attire-modest-suit")
|
|
153
|
+
resetPersonPacks()
|
|
154
|
+
expect(getParameterValue({ sectorAttire: "attire-modest-suit" }, "person")).toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe("registerPersonPack wires pack sidecars into the localizer (G10)", () => {
|
|
159
|
+
it("a registered person pack's Hebrew sidecar resolves in the localizer (G10)", () => {
|
|
160
|
+
// The fixture `pack` carries a `he` sidecar for attire-modest-suit; before
|
|
161
|
+
// registration the localizer has no entry and falls back to English.
|
|
162
|
+
expect(resolveLabel("person", "attire-modest-suit", "Modest Suit", "he")).toBe("Modest Suit")
|
|
163
|
+
registerPersonPack(pack)
|
|
164
|
+
expect(resolveLabel("person", "attire-modest-suit", "Modest Suit", "he")).toBe("חליפה צנועה")
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest"
|
|
2
|
+
import { projectAllCatalogs } from "../picker-catalogs.js"
|
|
3
|
+
import { registerCatalogPack, resetCatalogPacks } from "../catalog-packs.js"
|
|
4
|
+
|
|
5
|
+
beforeEach(() => resetCatalogPacks())
|
|
6
|
+
|
|
7
|
+
describe("projectAllCatalogs", () => {
|
|
8
|
+
it("projects every registered catalog, compact by default, with no promptHint/description", () => {
|
|
9
|
+
const all = projectAllCatalogs()
|
|
10
|
+
const setting = all.find((c) => c.catalogId === "setting")!
|
|
11
|
+
expect(setting.detail).toBe("compact")
|
|
12
|
+
expect(setting.options![0]).not.toHaveProperty("promptHint")
|
|
13
|
+
expect(setting.options![0]).not.toHaveProperty("description")
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("detail:full carries promptHint + description", () => {
|
|
17
|
+
const setting = projectAllCatalogs({ detail: "full" }).find((c) => c.catalogId === "setting")!
|
|
18
|
+
expect(setting.options![0]).toHaveProperty("promptHint")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("reflects a registered pack (server-driven curation)", () => {
|
|
22
|
+
registerCatalogPack({ id: "sai/deny", catalogId: "setting", mode: "deny", denyIds: ["forest"] })
|
|
23
|
+
const setting = projectAllCatalogs().find((c) => c.catalogId === "setting")!
|
|
24
|
+
expect(setting.options!.some((o) => o.id === "forest")).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
})
|