@nodaro/prompts 1.0.1
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/LICENSE +105 -0
- package/README.md +27 -0
- package/dist/index.cjs +21240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3599 -0
- package/dist/index.d.ts +3599 -0
- package/dist/index.js +20854 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/__tests__/__snapshots__/prompt-builder-segments.test.ts.snap +65 -0
- package/src/__tests__/action-fx.test.ts +155 -0
- package/src/__tests__/apply-picker-json.test.ts +50 -0
- package/src/__tests__/assemble-image-input.test.ts +248 -0
- package/src/__tests__/assemble-suno-input.test.ts +283 -0
- package/src/__tests__/brand-tokens.test.ts +81 -0
- package/src/__tests__/build-image-prompt-element-injection.test.ts +118 -0
- package/src/__tests__/build-image-prompt-hybrid-format.test.ts +131 -0
- package/src/__tests__/build-image-prompt-mentions.test.ts +809 -0
- package/src/__tests__/build-image-prompt-reference-cap.test.ts +57 -0
- package/src/__tests__/build-image-prompt-reference-numbering.test.ts +237 -0
- package/src/__tests__/build-image-prompt-reference-order.test.ts +313 -0
- package/src/__tests__/camera-motions-from-connections.test.ts +71 -0
- package/src/__tests__/catalog-gapfill.test.ts +53 -0
- package/src/__tests__/character-convergence-image.test.ts +217 -0
- package/src/__tests__/character-default-role-image.test.ts +167 -0
- package/src/__tests__/character-default-role-video.test.ts +168 -0
- package/src/__tests__/character-default-role.test.ts +70 -0
- package/src/__tests__/character-fx.test.ts +200 -0
- package/src/__tests__/entity-prompts-location.test.ts +97 -0
- package/src/__tests__/entity-prompts.test.ts +240 -0
- package/src/__tests__/expand-extra-refs-role.test.ts +50 -0
- package/src/__tests__/factory-presets.test.ts +1045 -0
- package/src/__tests__/factory-snippets.test.ts +68 -0
- package/src/__tests__/framing-multi.test.ts +71 -0
- package/src/__tests__/framing-vantage.test.ts +39 -0
- package/src/__tests__/i18n-entry-completeness.test.ts +269 -0
- package/src/__tests__/identity-lock.test.ts +46 -0
- package/src/__tests__/instrumentation.test.ts +68 -0
- package/src/__tests__/lighting-multi.test.ts +68 -0
- package/src/__tests__/location-convergence-image.test.ts +124 -0
- package/src/__tests__/mention-lock-flag.test.ts +499 -0
- package/src/__tests__/multi-picker-spec.test.ts +71 -0
- package/src/__tests__/music-genre.test.ts +103 -0
- package/src/__tests__/music-mood.test.ts +87 -0
- package/src/__tests__/object-creature-convergence-image.test.ts +105 -0
- package/src/__tests__/parameter-prompt-hint.test.ts +270 -0
- package/src/__tests__/parameter-registry-sync.test.ts +243 -0
- package/src/__tests__/person-age.test.ts +80 -0
- package/src/__tests__/person-analyzer-invariants.test.ts +47 -0
- package/src/__tests__/person-body-axes.test.ts +67 -0
- package/src/__tests__/person-facial-geometry.test.ts +156 -0
- package/src/__tests__/person-regional-aesthetic.test.ts +161 -0
- package/src/__tests__/person-sections.test.ts +18 -0
- package/src/__tests__/picker-analyzer-registry.test.ts +108 -0
- package/src/__tests__/picker-catalogs-project.test.ts +85 -0
- package/src/__tests__/picker-catalogs.test.ts +53 -0
- package/src/__tests__/picker-limits.test.ts +20 -0
- package/src/__tests__/prompt-builder-segments.test.ts +183 -0
- package/src/__tests__/prompt-builder-structured-fields.test.ts +40 -0
- package/src/__tests__/prompt-builder.test.ts +1773 -0
- package/src/__tests__/prompt-wizard-categories.test.ts +31 -0
- package/src/__tests__/provider-prompt-doctrine.test.ts +49 -0
- package/src/__tests__/resolve-prompt-append.test.ts +58 -0
- package/src/__tests__/resolve-prompt.test.ts +44 -0
- package/src/__tests__/role-picker-shared.test.ts +208 -0
- package/src/__tests__/seedance-2-inputs.test.ts +173 -0
- package/src/__tests__/seedance-extend.test.ts +44 -0
- package/src/__tests__/sound-aggregator.test.ts +350 -0
- package/src/__tests__/style-presets.test.ts +34 -0
- package/src/__tests__/temporal-multi.test.ts +74 -0
- package/src/__tests__/transitions.test.ts +213 -0
- package/src/__tests__/video-reference-features.test.ts +41 -0
- package/src/__tests__/video-reference-leading-refs.test.ts +90 -0
- package/src/__tests__/video-reference-resolver.test.ts +233 -0
- package/src/__tests__/video-reference-roles.test.ts +96 -0
- package/src/__tests__/voice-character.test.ts +48 -0
- package/src/__tests__/voice-delivery.test.ts +41 -0
- package/src/__tests__/wardrobe.test.ts +25 -0
- package/src/action-fx.ts +255 -0
- package/src/aesthetic.ts +435 -0
- package/src/assemble-image-input.ts +236 -0
- package/src/assemble-suno-input.ts +147 -0
- package/src/atmosphere.ts +104 -0
- package/src/backdrop.ts +131 -0
- package/src/brand-tokens.ts +154 -0
- package/src/camera-format.ts +76 -0
- package/src/camera-motions.ts +615 -0
- package/src/character-fx.ts +274 -0
- package/src/color-look.ts +103 -0
- package/src/composition-effects.ts +67 -0
- package/src/entity-prompts.ts +231 -0
- package/src/era.ts +292 -0
- package/src/exposure-settings.ts +142 -0
- package/src/factory-presets/generate-image.ts +1644 -0
- package/src/factory-presets/generate-video.ts +1116 -0
- package/src/factory-presets/index.ts +46 -0
- package/src/factory-presets/lottie-overlay.ts +166 -0
- package/src/factory-presets/motion-graphics.ts +350 -0
- package/src/factory-presets/music.ts +734 -0
- package/src/factory-presets/sfx.ts +136 -0
- package/src/factory-presets/shared-image.ts +207 -0
- package/src/factory-presets/switchx.ts +65 -0
- package/src/factory-presets/text.ts +292 -0
- package/src/factory-presets/types.ts +51 -0
- package/src/factory-presets/video-edit.ts +136 -0
- package/src/factory-presets/voice.ts +172 -0
- package/src/factory-presets.ts +2 -0
- package/src/factory-snippets/catalog.ts +105 -0
- package/src/factory-snippets/index.ts +16 -0
- package/src/factory-snippets/types.ts +33 -0
- package/src/framing.ts +634 -0
- package/src/held-prop.ts +187 -0
- package/src/identity-lock.ts +213 -0
- package/src/index.ts +66 -0
- package/src/instrumentation.ts +337 -0
- package/src/lens.ts +59 -0
- package/src/lighting.ts +229 -0
- package/src/loop-subject.ts +276 -0
- package/src/materials.ts +184 -0
- package/src/mood.ts +186 -0
- package/src/music-genre.ts +662 -0
- package/src/music-mood.ts +137 -0
- package/src/object-asset-presets.ts +81 -0
- package/src/parameter-prompt-hint.ts +281 -0
- package/src/person.ts +1368 -0
- package/src/photo-genre.ts +151 -0
- package/src/photographer.ts +612 -0
- package/src/picker-analyzer-registry.ts +374 -0
- package/src/picker-catalogs.ts +858 -0
- package/src/pose.ts +246 -0
- package/src/post-process-effects.ts +94 -0
- package/src/prompt-builder-structured-fields.ts +116 -0
- package/src/prompt-builder.ts +2964 -0
- package/src/prompt-templates.ts +50 -0
- package/src/prompt-wizard-categories.ts +334 -0
- package/src/provider-prompt-doctrine.ts +85 -0
- package/src/render-quality.ts +89 -0
- package/src/resolve-prompt.ts +120 -0
- package/src/seedance-2-inputs.ts +100 -0
- package/src/setting.ts +130 -0
- package/src/sound-aggregator.ts +241 -0
- package/src/style-presets.ts +162 -0
- package/src/style.ts +99 -0
- package/src/styling.ts +585 -0
- package/src/temporal.ts +151 -0
- package/src/transitions.ts +333 -0
- package/src/video-reference-resolver.ts +823 -0
- package/src/voice-character.ts +237 -0
- package/src/voice-delivery.ts +142 -0
- package/src/wardrobe.ts +179 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
FACTORY_SNIPPETS,
|
|
4
|
+
getFactorySnippets,
|
|
5
|
+
SNIPPET_MEDIA_VALUES,
|
|
6
|
+
} from "../factory-snippets/index.js"
|
|
7
|
+
|
|
8
|
+
describe("FACTORY_SNIPPETS catalog invariants", () => {
|
|
9
|
+
it("has unique ids and unique names", () => {
|
|
10
|
+
const ids = FACTORY_SNIPPETS.map((s) => s.id)
|
|
11
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
12
|
+
const names = FACTORY_SNIPPETS.map((s) => s.name.toLowerCase())
|
|
13
|
+
expect(new Set(names).size).toBe(names.length)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("every snippet has valid shape", () => {
|
|
17
|
+
for (const s of FACTORY_SNIPPETS) {
|
|
18
|
+
expect(s.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
|
19
|
+
expect(s.name.trim().length).toBeGreaterThan(0)
|
|
20
|
+
expect(s.name.length).toBeLessThanOrEqual(80)
|
|
21
|
+
expect(s.text.trim()).toBe(s.text) // no leading/trailing whitespace
|
|
22
|
+
expect(s.text.length).toBeGreaterThan(0)
|
|
23
|
+
expect(s.text.length).toBeLessThanOrEqual(600)
|
|
24
|
+
expect(["prompt", "negative"]).toContain(s.target)
|
|
25
|
+
expect(s.media.length).toBeGreaterThan(0)
|
|
26
|
+
for (const m of s.media) expect(SNIPPET_MEDIA_VALUES).toContain(m)
|
|
27
|
+
expect(s.category.trim().length).toBeGreaterThan(0)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("snippet text never collides with mention/variable token parsing", () => {
|
|
32
|
+
// `{`/`}` would form {Label} variable tokens; `@` would form @slug mentions;
|
|
33
|
+
// newlines would break the per-line pill matcher (collectTokens is per-line).
|
|
34
|
+
for (const s of FACTORY_SNIPPETS) {
|
|
35
|
+
expect(s.text).not.toMatch(/[{}@\n]/)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("negative-target snippets are bare comma lists (never 'no X' phrasing)", () => {
|
|
40
|
+
for (const s of FACTORY_SNIPPETS.filter((x) => x.target === "negative")) {
|
|
41
|
+
expect(s.text.toLowerCase().startsWith("no ")).toBe(false)
|
|
42
|
+
expect(s.text.toLowerCase()).not.toContain(" don't ")
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("getFactorySnippets filters by target + media", () => {
|
|
47
|
+
const imgPrompt = getFactorySnippets("prompt", "image")
|
|
48
|
+
expect(imgPrompt.length).toBeGreaterThan(20)
|
|
49
|
+
expect(imgPrompt.every((s) => s.target === "prompt" && s.media.includes("image"))).toBe(true)
|
|
50
|
+
const vidNeg = getFactorySnippets("negative", "video")
|
|
51
|
+
expect(vidNeg.length).toBeGreaterThanOrEqual(4)
|
|
52
|
+
expect(vidNeg.every((s) => s.target === "negative" && s.media.includes("video"))).toBe(true)
|
|
53
|
+
// Audio/text catalogs are deliberately empty in v1 — menu shows user snippets only.
|
|
54
|
+
expect(getFactorySnippets("prompt", "audio")).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("no snippet text is a substring of another (keeps the pill matcher unambiguous)", () => {
|
|
58
|
+
for (const a of FACTORY_SNIPPETS) {
|
|
59
|
+
for (const b of FACTORY_SNIPPETS) {
|
|
60
|
+
if (a.id === b.id) continue
|
|
61
|
+
expect(
|
|
62
|
+
b.text.includes(a.text),
|
|
63
|
+
`"${a.id}" text is contained in "${b.id}" text`,
|
|
64
|
+
).toBe(false)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildFramingHints, FRAMING_FIELD_BY_CATEGORY, FRAMING_CATEGORY_ORDER } from "../framing.js"
|
|
3
|
+
|
|
4
|
+
describe("buildFramingHints", () => {
|
|
5
|
+
it("returns empty when nothing is set", () => {
|
|
6
|
+
const hints = buildFramingHints({})
|
|
7
|
+
expect(hints).toEqual([])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("emits one hint per set per-category field, in canonical category order", () => {
|
|
11
|
+
const hints = buildFramingHints({
|
|
12
|
+
shotSize: "wide-shot",
|
|
13
|
+
angle: "low-angle",
|
|
14
|
+
coverage: "single",
|
|
15
|
+
composition: "rule-of-thirds",
|
|
16
|
+
vantage: "front-on",
|
|
17
|
+
})
|
|
18
|
+
expect(hints.length).toBe(5)
|
|
19
|
+
// Canonical order: shot-size, angle, coverage, composition, vantage
|
|
20
|
+
expect(hints[0]).toMatch(/wide shot/i)
|
|
21
|
+
expect(hints[1]).toMatch(/low angle/i)
|
|
22
|
+
expect(hints[2]).toMatch(/single shot/i)
|
|
23
|
+
expect(hints[3]).toMatch(/rule of thirds/i)
|
|
24
|
+
expect(hints[4]).toMatch(/front-on/i)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("ignores the legacy framing field — multi-category is the sole shape", () => {
|
|
28
|
+
// Legacy compat layer was removed: the legacy `framing` field is no
|
|
29
|
+
// longer read; only per-category fields are considered.
|
|
30
|
+
const hints = buildFramingHints({ framing: "medium-shot" } as Record<string, unknown>)
|
|
31
|
+
expect(hints).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("skipVantage suppresses ONLY the vantage hint, keeps other categories", () => {
|
|
35
|
+
const hints = buildFramingHints(
|
|
36
|
+
{
|
|
37
|
+
shotSize: "close-up",
|
|
38
|
+
angle: "high-angle",
|
|
39
|
+
vantage: "profile-left",
|
|
40
|
+
},
|
|
41
|
+
true,
|
|
42
|
+
)
|
|
43
|
+
expect(hints.length).toBe(2)
|
|
44
|
+
expect(hints[0]).toMatch(/close-up/i)
|
|
45
|
+
expect(hints[1]).toMatch(/high angle/i)
|
|
46
|
+
expect(hints.some((h) => /profile/i.test(h))).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("ignores empty-string and non-string values", () => {
|
|
50
|
+
const hints = buildFramingHints({
|
|
51
|
+
shotSize: "",
|
|
52
|
+
angle: undefined,
|
|
53
|
+
coverage: null as unknown as string,
|
|
54
|
+
composition: 42 as unknown as string,
|
|
55
|
+
vantage: "front-on",
|
|
56
|
+
})
|
|
57
|
+
expect(hints.length).toBe(1)
|
|
58
|
+
expect(hints[0]).toMatch(/front-on/i)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("FRAMING_FIELD_BY_CATEGORY covers all categories in order", () => {
|
|
62
|
+
for (const cat of FRAMING_CATEGORY_ORDER) {
|
|
63
|
+
expect(FRAMING_FIELD_BY_CATEGORY[cat]).toBeDefined()
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("returns empty when an unknown framing id is given (no hint string)", () => {
|
|
68
|
+
const hints = buildFramingHints({ shotSize: "this-is-not-a-real-id" })
|
|
69
|
+
expect(hints).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
FRAMINGS,
|
|
4
|
+
FRAMING_CATEGORY_ORDER,
|
|
5
|
+
FRAMING_CATEGORY_LABELS,
|
|
6
|
+
getFraming,
|
|
7
|
+
isVantageFraming,
|
|
8
|
+
} from "../framing.js"
|
|
9
|
+
|
|
10
|
+
describe("Framing vantage category", () => {
|
|
11
|
+
it("includes vantage in the category order", () => {
|
|
12
|
+
expect(FRAMING_CATEGORY_ORDER).toContain("vantage")
|
|
13
|
+
})
|
|
14
|
+
it("labels vantage correctly", () => {
|
|
15
|
+
expect(FRAMING_CATEGORY_LABELS.vantage).toBe("Vantage")
|
|
16
|
+
})
|
|
17
|
+
it("contains all 6 vantage entries", () => {
|
|
18
|
+
const ids = ["front-on", "three-quarter-front", "profile-left", "profile-right", "three-quarter-back", "behind"]
|
|
19
|
+
for (const id of ids) {
|
|
20
|
+
const entry = getFraming(id)
|
|
21
|
+
expect(entry).toBeDefined()
|
|
22
|
+
expect(entry?.category).toBe("vantage")
|
|
23
|
+
expect(entry?.promptHint.length).toBeGreaterThan(10)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
it("isVantageFraming correctly identifies vantage entries", () => {
|
|
27
|
+
expect(isVantageFraming("front-on")).toBe(true)
|
|
28
|
+
expect(isVantageFraming("profile-left")).toBe(true)
|
|
29
|
+
expect(isVantageFraming("medium-shot")).toBe(false) // shot-size
|
|
30
|
+
expect(isVantageFraming("high-angle")).toBe(false) // angle
|
|
31
|
+
expect(isVantageFraming("rule-of-thirds")).toBe(false) // composition
|
|
32
|
+
expect(isVantageFraming(undefined)).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
it("has at least the core framing entries (vantage + foundational categories)", () => {
|
|
35
|
+
// Initial set was 35 (24 original + 6 vantage + 4 reference-parity + 1 later);
|
|
36
|
+
// catalog has since grown. Use a lower-bound check so this doesn't break on every catalog expansion.
|
|
37
|
+
expect(FRAMINGS.length).toBeGreaterThanOrEqual(35)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1#6b — i18n per-ENTRY × locale completeness.
|
|
3
|
+
*
|
|
4
|
+
* The sibling file `i18n-locale-completeness.test.ts` only asserts that the
|
|
5
|
+
* sidecar FILES exist (`<catalog>.<locale>.ts`). It does NOT look inside them.
|
|
6
|
+
* That is how ~660 person / camera-format / render-quality translation strings
|
|
7
|
+
* silently shipped missing: the files were all present, but a batch of catalog
|
|
8
|
+
* entries (e.g. the facial-geometry person dimensions, the ARRI Alexa 65 /
|
|
9
|
+
* Sony Venice camera bodies, the Arnold / V-Ray render engines) had no entry at
|
|
10
|
+
* all in any non-English sidecar — so those pickers fell back to English in 11
|
|
11
|
+
* locales with zero signal.
|
|
12
|
+
*
|
|
13
|
+
* This guard walks every catalog ENTRY (not just every file) and asserts each
|
|
14
|
+
* id is actually translated in every non-English locale. It enumerates from
|
|
15
|
+
* `PICKER_CATALOGS` (`picker-catalogs.ts`) — the pure-data, single-source-of-
|
|
16
|
+
* truth mirror of the frontend picker registry — so the moment a new catalog
|
|
17
|
+
* entry is added (anywhere, by anyone) it is automatically in-scope here; there
|
|
18
|
+
* is no second list to remember to update.
|
|
19
|
+
*
|
|
20
|
+
* ---------------------------------------------------------------------------
|
|
21
|
+
* What "translated" means (derived from the established sidecar convention,
|
|
22
|
+
* documented in `i18n/types.ts`):
|
|
23
|
+
*
|
|
24
|
+
* - `description` is translated FREELY for every entry. Empirically every
|
|
25
|
+
* description-carrying catalog translates the description of EVERY one of
|
|
26
|
+
* its ids in EVERY locale. So:
|
|
27
|
+
* → DESCRIPTION IS REQUIRED for an entry when (a) the canonical English
|
|
28
|
+
* entry has a non-empty description AND (b) the catalog conventionally
|
|
29
|
+
* translates descriptions in that locale (detected per (catalog,locale)
|
|
30
|
+
* as: at least one id in that sidecar carries a description). Condition
|
|
31
|
+
* (b) cleanly exempts the handful of label-only catalogs (loop-subject,
|
|
32
|
+
* seasons) whose sidecars translate the short subject label only and
|
|
33
|
+
* never carry a description — translating their description is simply
|
|
34
|
+
* not part of that catalog's contract.
|
|
35
|
+
*
|
|
36
|
+
* - `label` is translated when it is a common noun/adjective, but LEFT in its
|
|
37
|
+
* canonical English form when it is a brand name, model number, proper noun,
|
|
38
|
+
* technical unit, microtrend tag, loanword, or Latin/Italian/Japanese
|
|
39
|
+
* cinematography jargon (ARRI Alexa 65, V-Ray, Sony A7III, 8K UHD,
|
|
40
|
+
* anamorphic, djembe, goblincore, katana, …). Translators apply this
|
|
41
|
+
* per-ENTRY and per-LOCALE: empirically the label-skip is pervasive and
|
|
42
|
+
* irreducible across the WHOLE corpus — e.g. `djembe`, `chiffon`,
|
|
43
|
+
* `baroque`, `super-8`, `femme-fatale`, the `*-core` aesthetics are each
|
|
44
|
+
* translated in some locales and intentionally left English in others.
|
|
45
|
+
* There is therefore NO clean dataset-wide "label required" invariant; any
|
|
46
|
+
* such rule (even "required where a majority translate it" or "required
|
|
47
|
+
* where all-but-one translate it") false-fails on 1500+ cells of shipped,
|
|
48
|
+
* deliberate data. So this guard does NOT impose a blanket label
|
|
49
|
+
* requirement. The real, universal failure mode the original gap exhibited
|
|
50
|
+
* was a MISSING ENTRY (the id key absent in every sidecar) — Test 1 below
|
|
51
|
+
* catches that for every id in every locale, and a present entry always
|
|
52
|
+
* carries the canonical-English label fallback at runtime. The specific
|
|
53
|
+
* common-noun label work this guard was added for (the 26 facial-geometry
|
|
54
|
+
* person dimensions) is pinned explicitly in Test 3, which also documents
|
|
55
|
+
* the deliberate brand-label-English decision for the camera / render
|
|
56
|
+
* bodies & engines.
|
|
57
|
+
*
|
|
58
|
+
* Net: Test 1 (entry-exists) + Test 2 (description) are TRUE on the entire
|
|
59
|
+
* current dataset (so the suite is green) yet FAIL the instant a new catalog
|
|
60
|
+
* entry ships without an entry or without the description its catalog
|
|
61
|
+
* conventionally carries — exactly the regression that let the original gap
|
|
62
|
+
* through. Test 3 additionally pins the in-scope label coverage.
|
|
63
|
+
* ---------------------------------------------------------------------------
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { describe, it, expect } from "vitest"
|
|
67
|
+
import { PICKER_CATALOGS, type PickerOption } from "../picker-catalogs.js"
|
|
68
|
+
import { NON_EN_LOCALE_IDS, type LocaleId, type LocaleCatalogMap } from "@nodaro/shared"
|
|
69
|
+
|
|
70
|
+
const LOCALES = NON_EN_LOCALE_IDS
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Flatten PICKER_CATALOGS → { catalogId → PickerOption[] }.
|
|
74
|
+
// single: top-level `options`. multi: every `dimensions[].options`.
|
|
75
|
+
// De-dupe by id (a few multi catalogs can repeat an id across dimensions; the
|
|
76
|
+
// person catalog does not, but be defensive).
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
interface CatalogEntries {
|
|
80
|
+
readonly catalogId: string
|
|
81
|
+
readonly options: readonly PickerOption[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const CATALOGS: readonly CatalogEntries[] = PICKER_CATALOGS.map((c) => {
|
|
85
|
+
const collected: PickerOption[] = []
|
|
86
|
+
const seen = new Set<string>()
|
|
87
|
+
const push = (opts: readonly PickerOption[] | undefined) => {
|
|
88
|
+
for (const o of opts ?? []) {
|
|
89
|
+
if (seen.has(o.id)) continue
|
|
90
|
+
seen.add(o.id)
|
|
91
|
+
collected.push(o)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (c.kind === "single") {
|
|
95
|
+
push(c.options)
|
|
96
|
+
} else {
|
|
97
|
+
for (const d of c.dimensions ?? []) push(d.options)
|
|
98
|
+
}
|
|
99
|
+
return { catalogId: c.catalogId, options: collected }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Eagerly load every sidecar map for every (catalog, locale) so the per-entry
|
|
104
|
+
// `it.each` cases are synchronous. Done once at module init.
|
|
105
|
+
//
|
|
106
|
+
// Loader mirrors `catalog-gapfill.test.ts` (dynamic import of the source .ts).
|
|
107
|
+
// A missing sidecar file is the sibling test's job; here we treat it as an
|
|
108
|
+
// empty map so this test reports per-entry coverage rather than a load crash.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
type LoadedMaps = Map<string, LocaleCatalogMap> // key = `${catalogId}:${locale}`
|
|
112
|
+
|
|
113
|
+
async function loadAllMaps(): Promise<LoadedMaps> {
|
|
114
|
+
const maps: LoadedMaps = new Map()
|
|
115
|
+
await Promise.all(
|
|
116
|
+
CATALOGS.flatMap((c) =>
|
|
117
|
+
LOCALES.map(async (locale) => {
|
|
118
|
+
try {
|
|
119
|
+
const mod = (// i18n sidecars live in @nodaro/shared source (test-only cross-package path)
|
|
120
|
+
await import(`../../../shared/src/i18n/${c.catalogId}.${locale}.ts`)) as {
|
|
121
|
+
default?: LocaleCatalogMap
|
|
122
|
+
}
|
|
123
|
+
maps.set(`${c.catalogId}:${locale}`, mod.default ?? {})
|
|
124
|
+
} catch {
|
|
125
|
+
maps.set(`${c.catalogId}:${locale}`, {})
|
|
126
|
+
}
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
return maps
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const MAPS = await loadAllMaps()
|
|
134
|
+
|
|
135
|
+
function mapFor(catalogId: string, locale: LocaleId): LocaleCatalogMap {
|
|
136
|
+
return MAPS.get(`${catalogId}:${locale}`) ?? {}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const has = (s: string | undefined): boolean => typeof s === "string" && s.trim().length > 0
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Per (catalog, locale): does this sidecar conventionally translate
|
|
143
|
+
* descriptions at all? True if any entry carries a non-empty description.
|
|
144
|
+
* Label-only catalogs (loop-subject, seasons) return false → their entries are
|
|
145
|
+
* not required to carry a translated description.
|
|
146
|
+
*/
|
|
147
|
+
function localeTranslatesDescriptions(catalogId: string, locale: LocaleId): boolean {
|
|
148
|
+
const map = mapFor(catalogId, locale)
|
|
149
|
+
for (const v of Object.values(map)) if (has(v?.description)) return true
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Sanity: the enumeration source is non-trivial and the locale list is the 11
|
|
155
|
+
// non-English locales.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("i18n per-entry guard — setup sanity", () => {
|
|
159
|
+
it("enumerates a non-trivial number of catalogs and entries", () => {
|
|
160
|
+
expect(CATALOGS.length).toBeGreaterThanOrEqual(30)
|
|
161
|
+
const totalEntries = CATALOGS.reduce((n, c) => n + c.options.length, 0)
|
|
162
|
+
expect(totalEntries).toBeGreaterThan(2000)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("covers exactly the 11 non-English locales", () => {
|
|
166
|
+
expect([...LOCALES].sort()).toEqual(
|
|
167
|
+
["ar", "de", "es", "fr", "he", "hi", "ja", "ko", "pt-BR", "ru", "zh-CN"].sort(),
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Test 1 — every catalog entry EXISTS in every non-English sidecar.
|
|
174
|
+
// (The worst failure mode: the id key is entirely absent → picker silently
|
|
175
|
+
// English in that locale.)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe("every catalog entry has a sidecar translation entry", () => {
|
|
179
|
+
const cases: Array<[string, LocaleId, string]> = []
|
|
180
|
+
for (const c of CATALOGS) {
|
|
181
|
+
for (const locale of LOCALES) {
|
|
182
|
+
for (const o of c.options) cases.push([c.catalogId, locale, o.id])
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
it.each(cases)("%s [%s] entry exists: %s", (catalogId, locale, id) => {
|
|
187
|
+
const entry = mapFor(catalogId, locale)[id]
|
|
188
|
+
expect(
|
|
189
|
+
entry,
|
|
190
|
+
`Missing i18n entry "${id}" in packages/shared/src/i18n/${catalogId}.${locale}.ts. ` +
|
|
191
|
+
`The "${catalogId}" picker silently falls back to English for this option in the "${locale}" locale. ` +
|
|
192
|
+
`Add an entry mirroring its siblings (e.g. ${catalogId}.fr.ts).`,
|
|
193
|
+
).toBeTruthy()
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Test 2 — DESCRIPTION is translated wherever the catalog convention carries it.
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
describe("description is translated for every entry (where the catalog carries descriptions)", () => {
|
|
202
|
+
const cases: Array<[string, LocaleId, string, boolean]> = []
|
|
203
|
+
for (const c of CATALOGS) {
|
|
204
|
+
for (const locale of LOCALES) {
|
|
205
|
+
// Gate per (catalog, locale): only catalogs whose convention translates
|
|
206
|
+
// descriptions are subject to the requirement.
|
|
207
|
+
const required = localeTranslatesDescriptions(c.catalogId, locale)
|
|
208
|
+
for (const o of c.options) {
|
|
209
|
+
// English entry must itself have a description to require a translation.
|
|
210
|
+
if (!has(o.description)) continue
|
|
211
|
+
cases.push([c.catalogId, locale, o.id, required])
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it.each(cases)("%s [%s] description: %s", (catalogId, locale, id, required) => {
|
|
217
|
+
if (!required) return // label-only catalog (e.g. loop-subject, seasons)
|
|
218
|
+
const entry = mapFor(catalogId, locale)[id]
|
|
219
|
+
expect(
|
|
220
|
+
has(entry?.description),
|
|
221
|
+
`Missing translated description for "${id}" in packages/shared/src/i18n/${catalogId}.${locale}.ts. ` +
|
|
222
|
+
`This catalog translates descriptions for its other entries in "${locale}", so this one must too ` +
|
|
223
|
+
`(otherwise the picker tooltip falls back to English).`,
|
|
224
|
+
).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Test 3 — focused, explicit coverage of the exact gap this guard was added
|
|
230
|
+
// for. Belt-and-suspenders: even if the data-driven gates above ever loosen,
|
|
231
|
+
// these specific entries must carry the specific fields. (Doubles as live
|
|
232
|
+
// documentation of the brand-label decision for camera/render.)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
const PERSON_FACIAL_GEOMETRY_IDS = [
|
|
236
|
+
"cheekbones-average", "cheekbones-low", "cheekbones-high-defined", "cheekbones-sculpted", "cheekbones-wide",
|
|
237
|
+
"facial-fullness-average", "facial-fullness-gaunt", "facial-fullness-lean", "facial-fullness-full", "facial-fullness-round",
|
|
238
|
+
"eye-wide", "eye-narrow", "eyelid-standard", "canthal-neutral", "eye-spacing-average",
|
|
239
|
+
"eyeset-average", "eyeset-low", "eyeset-high",
|
|
240
|
+
"nose-tip-natural", "nose-tip-refined", "nose-tip-upturned", "nose-tip-rounded", "nose-tip-drooping",
|
|
241
|
+
"lips-full-lower", "lips-natural", "lips-heart",
|
|
242
|
+
] as const
|
|
243
|
+
|
|
244
|
+
// Brand-named camera bodies / render engines: label stays English (matches the
|
|
245
|
+
// sibling convention for arri-alexa, canon-r5, octane-render, redshift, …).
|
|
246
|
+
// Only the description is translated.
|
|
247
|
+
const CAMERA_FORMAT_BRAND_IDS = ["alexa-65", "sony-venice", "blackmagic-pocket-6k", "red-komodo"] as const
|
|
248
|
+
const RENDER_QUALITY_BRAND_IDS = ["arnold-render", "corona-renderer", "vray", "aces"] as const
|
|
249
|
+
|
|
250
|
+
describe("targeted gap coverage: facial-geometry + brand camera/render entries", () => {
|
|
251
|
+
it.each(LOCALES)("person facial-geometry ids carry label + description [%s]", (locale) => {
|
|
252
|
+
const map = mapFor("person", locale)
|
|
253
|
+
for (const id of PERSON_FACIAL_GEOMETRY_IDS) {
|
|
254
|
+
expect(has(map[id]?.label), `person.${locale}: ${id} label`).toBe(true)
|
|
255
|
+
expect(has(map[id]?.description), `person.${locale}: ${id} description`).toBe(true)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it.each(LOCALES)("brand camera/render ids carry description, label left English [%s]", (locale) => {
|
|
260
|
+
const cam = mapFor("camera-format", locale)
|
|
261
|
+
for (const id of CAMERA_FORMAT_BRAND_IDS) {
|
|
262
|
+
expect(has(cam[id]?.description), `camera-format.${locale}: ${id} description`).toBe(true)
|
|
263
|
+
}
|
|
264
|
+
const ren = mapFor("render-quality", locale)
|
|
265
|
+
for (const id of RENDER_QUALITY_BRAND_IDS) {
|
|
266
|
+
expect(has(ren[id]?.description), `render-quality.${locale}: ${id} description`).toBe(true)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildIdentityLockLine } from "../identity-lock.js"
|
|
3
|
+
import type { ConnectedReference } from "@nodaro/shared"
|
|
4
|
+
|
|
5
|
+
const charRef = (over: Partial<ConnectedReference> = {}): ConnectedReference => ({
|
|
6
|
+
id: "c", defaultName: "Victoria Hayes", source: "wired-character", url: "u", ...over,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe("buildIdentityLockLine", () => {
|
|
10
|
+
it("default-OFF for a character with no explicit flag → null", () => {
|
|
11
|
+
expect(buildIdentityLockLine(charRef(), "reference image A")).toBeNull()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("OFF when identityLock.enabled === false → null", () => {
|
|
15
|
+
expect(buildIdentityLockLine(charRef({ identityLock: { enabled: false } }), "reference image A")).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("custom text overrides the built-in, with the binding interpolated", () => {
|
|
19
|
+
const ref = charRef({ identityLock: { enabled: true, text: "Keep {ref} pixel-perfect." } })
|
|
20
|
+
expect(buildIdentityLockLine(ref, "reference image A")).toBe("Keep reference image A pixel-perfect.")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("enabled:true with no text → built-in wording per source (character / face / creature)", () => {
|
|
24
|
+
expect(buildIdentityLockLine(charRef({ identityLock: { enabled: true } }), "reference image A"))
|
|
25
|
+
.toBe("Lock the exact identity of the person in reference image A — face, bone structure, skin tone, and all unique features.")
|
|
26
|
+
expect(buildIdentityLockLine(
|
|
27
|
+
{ id: "f", defaultName: "Face", source: "wired-face", url: "u", identityLock: { enabled: true } },
|
|
28
|
+
"reference image A",
|
|
29
|
+
)).toBe("Lock the exact facial identity in reference image A — bone structure, features, and skin texture.")
|
|
30
|
+
expect(buildIdentityLockLine(
|
|
31
|
+
{ id: "r", defaultName: "Rex", source: "wired-creature", url: "u", identityLock: { enabled: true } },
|
|
32
|
+
"@image_2",
|
|
33
|
+
)).toBe("Lock the exact identity of the creature in @image_2 — anatomy, markings, and all unique features.")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("default-OFF for object/location/image → null", () => {
|
|
37
|
+
expect(buildIdentityLockLine({ id: "o", defaultName: "Lamp", source: "wired-object", url: "u" }, "reference image A")).toBeNull()
|
|
38
|
+
expect(buildIdentityLockLine({ id: "l", defaultName: "Library", source: "wired-location", url: "u" }, "reference image A")).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("creature with enabled:true uses the creature wording", () => {
|
|
42
|
+
const ref: ConnectedReference = { id: "r", defaultName: "Rex", source: "wired-creature", url: "u", identityLock: { enabled: true } }
|
|
43
|
+
expect(buildIdentityLockLine(ref, "@image_2"))
|
|
44
|
+
.toBe("Lock the exact identity of the creature in @image_2 — anatomy, markings, and all unique features.")
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
INSTRUMENTS,
|
|
4
|
+
PRODUCTION_STYLES,
|
|
5
|
+
VOCAL_PRESENCE,
|
|
6
|
+
getInstrument,
|
|
7
|
+
getProductionStyle,
|
|
8
|
+
getVocalPresence,
|
|
9
|
+
buildInstrumentationHints,
|
|
10
|
+
INSTRUMENTATION_DEFAULT_DATA,
|
|
11
|
+
} from "../instrumentation.js"
|
|
12
|
+
|
|
13
|
+
describe("instrumentation catalogs", () => {
|
|
14
|
+
it("INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE are non-empty with unique ids", () => {
|
|
15
|
+
for (const list of [INSTRUMENTS, PRODUCTION_STYLES, VOCAL_PRESENCE]) {
|
|
16
|
+
expect(list.length).toBeGreaterThan(0)
|
|
17
|
+
const ids = new Set(list.map((x) => x.id))
|
|
18
|
+
expect(ids.size).toBe(list.length)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("VOCAL_PRESENCE includes 'instrumental' so it can flip the boolean for MiniMax", () => {
|
|
23
|
+
expect(VOCAL_PRESENCE.find((x) => x.id === "instrumental")).toBeDefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("INSTRUMENTS includes darbuka in percussion", () => {
|
|
27
|
+
const entry = INSTRUMENTS.find((x) => x.id === "darbuka")
|
|
28
|
+
expect(entry).toBeDefined()
|
|
29
|
+
expect(entry?.category).toBe("percussion")
|
|
30
|
+
expect(entry?.promptHint).toBe("darbuka")
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("buildInstrumentationHints", () => {
|
|
35
|
+
it("returns empty for empty data", () => {
|
|
36
|
+
expect(buildInstrumentationHints({ instruments: [] })).toBe("")
|
|
37
|
+
expect(buildInstrumentationHints({})).toBe("")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("composes single instrument", () => {
|
|
41
|
+
const i = INSTRUMENTS[0]
|
|
42
|
+
expect(buildInstrumentationHints({ instruments: [i.id] })).toBe(i.promptHint)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("joins multiple instruments with commas", () => {
|
|
46
|
+
const a = INSTRUMENTS[0], b = INSTRUMENTS[1]
|
|
47
|
+
const out = buildInstrumentationHints({ instruments: [a.id, b.id] })
|
|
48
|
+
expect(out).toContain(a.promptHint)
|
|
49
|
+
expect(out).toContain(b.promptHint)
|
|
50
|
+
expect(out).toContain(",")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("composes [production] [instruments] with [vocalPresence]", () => {
|
|
54
|
+
const p = PRODUCTION_STYLES[0]
|
|
55
|
+
const i = INSTRUMENTS[0]
|
|
56
|
+
const v = VOCAL_PRESENCE.find((x) => x.id === "female-lead")!
|
|
57
|
+
const out = buildInstrumentationHints({
|
|
58
|
+
production: p.id, instruments: [i.id], vocalPresence: v.id,
|
|
59
|
+
})
|
|
60
|
+
expect(out.indexOf(p.promptHint)).toBeLessThan(out.indexOf(i.promptHint))
|
|
61
|
+
expect(out).toContain(v.promptHint)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("filters unknown instrument ids silently", () => {
|
|
65
|
+
const i = INSTRUMENTS[0]
|
|
66
|
+
expect(buildInstrumentationHints({ instruments: [i.id, "not-real"] })).toBe(i.promptHint)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { buildLightingHints, LIGHTING_FIELD_BY_CATEGORY, LIGHTING_CATEGORY_ORDER } from "../lighting.js"
|
|
3
|
+
|
|
4
|
+
describe("buildLightingHints", () => {
|
|
5
|
+
it("returns empty when nothing is set", () => {
|
|
6
|
+
const hints = buildLightingHints({})
|
|
7
|
+
expect(hints).toEqual([])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("emits one hint per set per-category field, in canonical category order", () => {
|
|
11
|
+
const hints = buildLightingHints({
|
|
12
|
+
timeOfDay: "golden-hour",
|
|
13
|
+
lightingStyle: "rembrandt",
|
|
14
|
+
lightingDirection: "side",
|
|
15
|
+
})
|
|
16
|
+
expect(hints.length).toBe(3)
|
|
17
|
+
// Canonical order: time-of-day, style, direction
|
|
18
|
+
expect(hints[0]).toMatch(/golden hour/i)
|
|
19
|
+
expect(hints[1]).toMatch(/rembrandt/i)
|
|
20
|
+
expect(hints[2]).toMatch(/side lighting/i)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("ignores the legacy lighting field — multi-category is the sole shape", () => {
|
|
24
|
+
// Legacy compat layer was removed: the legacy `lighting` field is no
|
|
25
|
+
// longer read; only per-category fields are considered.
|
|
26
|
+
const hints = buildLightingHints({ lighting: "noon" } as Record<string, unknown>)
|
|
27
|
+
expect(hints).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("ignores empty-string and non-string values", () => {
|
|
31
|
+
const hints = buildLightingHints({
|
|
32
|
+
timeOfDay: "",
|
|
33
|
+
lightingStyle: undefined,
|
|
34
|
+
lightingDirection: "front",
|
|
35
|
+
})
|
|
36
|
+
expect(hints.length).toBe(1)
|
|
37
|
+
expect(hints[0]).toMatch(/front lighting/i)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("LIGHTING_FIELD_BY_CATEGORY covers all categories in order", () => {
|
|
41
|
+
for (const cat of LIGHTING_CATEGORY_ORDER) {
|
|
42
|
+
expect(LIGHTING_FIELD_BY_CATEGORY[cat]).toBeDefined()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("LIGHTING_FIELD_BY_CATEGORY maps each category to the expected field name", () => {
|
|
47
|
+
expect(LIGHTING_FIELD_BY_CATEGORY["time-of-day"]).toBe("timeOfDay")
|
|
48
|
+
expect(LIGHTING_FIELD_BY_CATEGORY.style).toBe("lightingStyle")
|
|
49
|
+
expect(LIGHTING_FIELD_BY_CATEGORY.direction).toBe("lightingDirection")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("returns empty when an unknown lighting id is given (no hint string)", () => {
|
|
53
|
+
const hints = buildLightingHints({ timeOfDay: "this-is-not-a-real-id" })
|
|
54
|
+
expect(hints).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("aggregates all 3 categories with the correct prompt-hint substrings", () => {
|
|
58
|
+
const hints = buildLightingHints({
|
|
59
|
+
timeOfDay: "neon-night",
|
|
60
|
+
lightingStyle: "low-key",
|
|
61
|
+
lightingDirection: "back-rim",
|
|
62
|
+
})
|
|
63
|
+
expect(hints.length).toBe(3)
|
|
64
|
+
expect(hints[0]).toMatch(/neon night/i)
|
|
65
|
+
expect(hints[1]).toMatch(/low-key/i)
|
|
66
|
+
expect(hints[2]).toMatch(/back-light|rim-light/i)
|
|
67
|
+
})
|
|
68
|
+
})
|