@nodaro/prompts 1.27.0 → 1.29.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 +915 -603
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +251 -17
- package/dist/index.d.ts +251 -17
- package/dist/index.js +896 -604
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/fixtures/blended-cut-approved.json +712 -0
- package/src/__tests__/fixtures/parameter-hint-golden.json +33 -1
- package/src/__tests__/hint-join.test.ts +64 -1
- package/src/__tests__/motion-hint.test.ts +20 -0
- package/src/__tests__/parameter-registry-sync.test.ts +1 -0
- package/src/__tests__/person-mixed-heritage.test.ts +43 -0
- package/src/__tests__/resolve-prompt.test.ts +13 -1
- package/src/__tests__/transition-options.test.ts +223 -0
- package/src/__tests__/transition-styles.test.ts +202 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +41 -0
- package/src/__tests__/transitions-blended-cut.test.ts +325 -0
- package/src/__tests__/transitions-instant.test.ts +9 -5
- package/src/__tests__/transitions-scope.test.ts +420 -4
- package/src/__tests__/video-own-motion.test.ts +26 -0
- package/src/hint-join.ts +33 -1
- package/src/index.ts +1 -0
- package/src/parameter-prompt-hint.ts +20 -3
- package/src/person.ts +24 -8
- package/src/picker-art/character-art-files.generated.ts +536 -536
- package/src/picker-catalogs.ts +83 -7
- package/src/prompt-builder.ts +2 -1
- package/src/prompt-hint-join.ts +8 -2
- package/src/prompt-style-section.ts +3 -5
- package/src/resolve-prompt.ts +22 -4
- package/src/transitions.ts +408 -52
- package/src/video-own-motion.ts +29 -0
|
@@ -1931,6 +1931,38 @@
|
|
|
1931
1931
|
"ctx": null,
|
|
1932
1932
|
"expected": "a Crusader knight — chainmail hauberk under a white surcoat with a red cross emblem, conical helm, longsword and kite shield, devout warrior bearing, in their early 20s, around 21 years old, of Norwegian descent, a Shibuya streetwear aesthetic — Tokyo street-fashion vibe, layered post-modern mood, full-figured, full bust, pear-shaped silhouette, a heart-shaped face with a wide forehead and pointed chin, a pointed chin with a narrow jawline, high, defined cheekbones, a lean face with low facial fat and subtle hollowing beneath the cheekbones, double-eyelid eyes with a clearly defined upper crease, droopy eyes with the upper lids sitting low for a soft, sleepy gaze, upturned eyes with the outer corners lifted, positive canthal tilt, close-set eyes set close together, eyes set close beneath the brow with little brow-to-eye distance, hunter eyes, a small button nose with a rounded tip, a slightly upturned nose tip, full plump lips, a wide mouth, with lips pursed, pressed and pushed forward, long straight hair, gray hair, bold defined dark full eyebrows, tan skin, with visible swimwear tan lines on the skin, rare violet eyes with a deep purple iris, gazing off-camera with a contemplative expression, a goatee, with a small face tattoo near the eye or cheekbone"
|
|
1933
1933
|
},
|
|
1934
|
+
{
|
|
1935
|
+
"key": "person/mixed-heritage",
|
|
1936
|
+
"note": "Two ethnicity picks → one mixed-heritage phrase from the entries' prompt text",
|
|
1937
|
+
"node": {
|
|
1938
|
+
"id": "n1",
|
|
1939
|
+
"type": "person",
|
|
1940
|
+
"data": {
|
|
1941
|
+
"ethnicity": [
|
|
1942
|
+
"slavic",
|
|
1943
|
+
"mediterranean"
|
|
1944
|
+
]
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
"ctx": null,
|
|
1948
|
+
"expected": "of mixed Slavic Eastern European and Mediterranean heritage"
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
"key": "person/mixed-heritage-abbrev",
|
|
1952
|
+
"note": "Two ethnicity picks whose short labels are abbreviations (Pacific Isl. / East (any))",
|
|
1953
|
+
"node": {
|
|
1954
|
+
"id": "n1",
|
|
1955
|
+
"type": "person",
|
|
1956
|
+
"data": {
|
|
1957
|
+
"ethnicity": [
|
|
1958
|
+
"pacific-islander",
|
|
1959
|
+
"east-asian"
|
|
1960
|
+
]
|
|
1961
|
+
}
|
|
1962
|
+
},
|
|
1963
|
+
"ctx": null,
|
|
1964
|
+
"expected": "of mixed Pacific Islander and East Asian heritage"
|
|
1965
|
+
},
|
|
1934
1966
|
{
|
|
1935
1967
|
"key": "person/one-dimension",
|
|
1936
1968
|
"note": "multi-dim: only \"type\" set",
|
|
@@ -2922,7 +2954,7 @@
|
|
|
2922
2954
|
}
|
|
2923
2955
|
},
|
|
2924
2956
|
"ctx": null,
|
|
2925
|
-
"expected": "double exposure (the first
|
|
2957
|
+
"expected": "double exposure (the first subject becomes a crisp silhouette filled solid with the second shot, while the rest of the first shot stays untouched around it. The camera stays where it is and the framing does not change. The silhouette holds for a clear beat, both pictures plainly seen at once, then the first shot around it gives way to the second shot. The shot ends on the second shot alone, fully resolved)"
|
|
2926
2958
|
},
|
|
2927
2959
|
{
|
|
2928
2960
|
"key": "transition/id:dream-ripple",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { joinHintFragments } from "../hint-join.js"
|
|
2
|
+
import { joinHintFragments, joinSentences, appendPromptHints } from "../hint-join.js"
|
|
3
|
+
import { joinPromptHints } from "../prompt-hint-join.js"
|
|
4
|
+
import { composeNegative, resolvePrompt } from "../resolve-prompt.js"
|
|
3
5
|
import { composeCameraMotionHintFromConnections, getCameraMotionPromptHint } from "../camera-motions.js"
|
|
4
6
|
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
5
7
|
|
|
@@ -56,3 +58,64 @@ describe("sentence-style camera-motion hints compose cleanly", () => {
|
|
|
56
58
|
)
|
|
57
59
|
})
|
|
58
60
|
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A prompt typed with a closing period used to come out doubled once a picker
|
|
64
|
+
* hint was appended: "No text, no logos, no watermark.. butterfly portrait
|
|
65
|
+
* lighting" (every picker example run in the docs rebuild).
|
|
66
|
+
*/
|
|
67
|
+
describe("joinSentences", () => {
|
|
68
|
+
it("joins with a period and a space", () => {
|
|
69
|
+
expect(joinSentences(["a red fox", "soft window light"])).toBe("a red fox. soft window light")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("never doubles a closing period, question or exclamation mark, or ellipsis", () => {
|
|
73
|
+
expect(joinSentences(["No text, no logos, no watermark.", "butterfly portrait lighting"])).toBe(
|
|
74
|
+
"No text, no logos, no watermark. butterfly portrait lighting",
|
|
75
|
+
)
|
|
76
|
+
expect(joinSentences(["Is it raining?", "wet asphalt"])).toBe("Is it raining? wet asphalt")
|
|
77
|
+
expect(joinSentences(["Go!", "motion blur"])).toBe("Go! motion blur")
|
|
78
|
+
expect(joinSentences(["and then…", "a door opens"])).toBe("and then… a door opens")
|
|
79
|
+
expect(joinSentences(["雨の街。", "neon"])).toBe("雨の街。 neon")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("does not carry trailing space into the join, and drops blank pieces", () => {
|
|
83
|
+
expect(joinSentences(["a cat. ", "noir"])).toBe("a cat. noir")
|
|
84
|
+
expect(joinSentences(["a cat \n", "noir"])).toBe("a cat. noir")
|
|
85
|
+
expect(joinSentences(["", " ", undefined, "noir"])).toBe("noir")
|
|
86
|
+
expect(joinSentences([])).toBe("")
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("appendPromptHints", () => {
|
|
91
|
+
it("joins the hints as clauses, then follows the prompt as a new sentence", () => {
|
|
92
|
+
expect(appendPromptHints("A studio portrait.", ["butterfly lighting", "85mm lens"])).toBe(
|
|
93
|
+
"A studio portrait. butterfly lighting, 85mm lens",
|
|
94
|
+
)
|
|
95
|
+
expect(appendPromptHints("A studio portrait", ["butterfly lighting"])).toBe("A studio portrait. butterfly lighting")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("gives the hints alone with no prompt, and the prompt back unchanged with no hints", () => {
|
|
99
|
+
expect(appendPromptHints(undefined, ["butterfly lighting"])).toBe("butterfly lighting")
|
|
100
|
+
expect(appendPromptHints("", ["butterfly lighting"])).toBe("butterfly lighting")
|
|
101
|
+
expect(appendPromptHints("A portrait. ", [])).toBe("A portrait. ")
|
|
102
|
+
expect(appendPromptHints("A portrait", ["", " "])).toBe("A portrait")
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("the other prompt joins are sentence-aware too", () => {
|
|
107
|
+
const M = new Map<string, string>()
|
|
108
|
+
it("folded direction hints (joinPromptHints)", () => {
|
|
109
|
+
expect(joinPromptHints("No watermark.", ["golden hour", "shallow depth of field"])).toBe(
|
|
110
|
+
"No watermark. golden hour. shallow depth of field",
|
|
111
|
+
)
|
|
112
|
+
expect(joinPromptHints(" untouched ", [])).toBe(" untouched ")
|
|
113
|
+
})
|
|
114
|
+
it("a connected prompt appended to the typed one (appendWired)", () => {
|
|
115
|
+
expect(resolvePrompt({ typed: ["A fox."], wired: "in the snow", refMap: M, appendWired: true })).toBe("A fox. in the snow")
|
|
116
|
+
})
|
|
117
|
+
it("a connected negative prompt (composeNegative)", () => {
|
|
118
|
+
expect(composeNegative("blurry.", "watermark")).toBe("blurry. watermark")
|
|
119
|
+
expect(composeNegative("blurry", "watermark")).toBe("blurry. watermark")
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { getParameterPromptHint } from "../parameter-prompt-hint.js"
|
|
3
|
+
|
|
4
|
+
describe("Motion's prompt clause", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["subtle", /subtle, gentle motion/],
|
|
7
|
+
["moderate", /moderate, natural motion/],
|
|
8
|
+
["dynamic", /dynamic, energetic motion/],
|
|
9
|
+
])("%s", (motion, clause) => {
|
|
10
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion } })).toMatch(clause)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("is the bare term in compact mode", () => {
|
|
14
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion: "dynamic", hintMode: "compact" } })).toBe("dynamic motion")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("is empty for an unknown step", () => {
|
|
18
|
+
expect(getParameterPromptHint({ id: "m", type: "motion", data: { motion: "wild" } })).toBe("")
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -152,6 +152,7 @@ const SAMPLE_DATA_BY_TYPE: Record<string, Record<string, unknown>> = {
|
|
|
152
152
|
"scene-count": { count: 5 },
|
|
153
153
|
"duration": { seconds: 8 },
|
|
154
154
|
"aspect-ratio": { ratio: "16:9" },
|
|
155
|
+
"provider": { category: "video", provider: "seedance-2" },
|
|
155
156
|
"music-genre": { genre: firstId(MUSIC_GENRES) },
|
|
156
157
|
"music-mood": { emotion: firstId(MUSIC_EMOTIONS) },
|
|
157
158
|
"instrumentation": { instruments: [firstId(INSTRUMENTS)] },
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two ethnicity picks read as one heritage phrase built from each entry's
|
|
3
|
+
* prompt text — never from its short label, which is picker-grid display copy
|
|
4
|
+
* ("Mediter.", "Pacific Isl.", "any", a bare "East"). Two picks used to put
|
|
5
|
+
* those abbreviations into the prompt: "of mixed Slavic and Mediter. heritage".
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from "vitest"
|
|
8
|
+
import { buildPersonHints, buildPersonTerms, PEOPLE } from "../person.js"
|
|
9
|
+
|
|
10
|
+
const hintsFor = (ethnicity: string[]) => buildPersonHints({ ethnicity })
|
|
11
|
+
const termsFor = (ethnicity: string[]) => buildPersonTerms({ ethnicity })
|
|
12
|
+
|
|
13
|
+
describe("mixed heritage (two ethnicity picks)", () => {
|
|
14
|
+
it("names both heritages in full", () => {
|
|
15
|
+
expect(hintsFor(["slavic", "mediterranean"])).toContain(
|
|
16
|
+
"of mixed Slavic Eastern European and Mediterranean heritage",
|
|
17
|
+
)
|
|
18
|
+
expect(hintsFor(["pacific-islander", "east-asian"])).toContain("of mixed Pacific Islander and East Asian heritage")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("never doubles the word heritage", () => {
|
|
22
|
+
expect(hintsFor(["brazilian", "west-african"])).toContain("of mixed Brazilian and West African heritage")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("reads the same in compact mode", () => {
|
|
26
|
+
expect(termsFor(["slavic", "mediterranean"])).toContain("of mixed Slavic Eastern European and Mediterranean heritage")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("treats 'mixed' paired with a specific pick as that pick", () => {
|
|
30
|
+
expect(hintsFor(["mixed", "italian"])).toEqual(hintsFor(["italian"]))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("puts no short-label abbreviation into any two-pick phrase", () => {
|
|
34
|
+
// The short labels' tell-tales: an abbreviation dot ("Mediter.", "Isl."),
|
|
35
|
+
// "(any)" / a bare "any", and a region with no noun ("West", "East").
|
|
36
|
+
const bad = /\.|\(any\)|\bany\b|of mixed (West|East|Central|Southern) and/
|
|
37
|
+
// Paired with French, so French itself is the one entry left out.
|
|
38
|
+
const leaks = PEOPLE.filter((p) => p.dimension === "ethnicity" && p.id !== "mixed" && p.id !== "french")
|
|
39
|
+
.map((p) => `${p.id}: ${hintsFor([p.id, "french"]).find((h) => h.startsWith("of mixed")) ?? "(no phrase)"}`)
|
|
40
|
+
.filter((line) => bad.test(line.slice(line.indexOf(":") + 1)) || line.endsWith("(no phrase)"))
|
|
41
|
+
expect(leaks).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { resolvePrompt, computeNodePrompt, computeLlmChatFields } from "../resolve-prompt.js"
|
|
2
|
+
import { resolvePrompt, computeNodePrompt, computeLlmChatFields, computeScriptTopic } from "../resolve-prompt.js"
|
|
3
3
|
const M = new Map<string, string>()
|
|
4
4
|
const R = new Map([["Hero", "a knight"]])
|
|
5
5
|
describe("resolvePrompt", () => {
|
|
@@ -42,3 +42,15 @@ describe("computeLlmChatFields", () => {
|
|
|
42
42
|
{ override: "o", wiredUserInput: "wu", wiredSystemPrompt: "ws", refMap: M }).userInput).toBe("o")
|
|
43
43
|
})
|
|
44
44
|
})
|
|
45
|
+
describe("computeScriptTopic (generate-script)", () => {
|
|
46
|
+
it("the wired prompt wins over the saved `prompt`, the reverse of computeNodePrompt", () =>
|
|
47
|
+
expect(computeScriptTopic({ prompt: "saved" }, { wired: "wire", refMap: M })).toBe("wire"))
|
|
48
|
+
it("a list fan-out item wins over both", () =>
|
|
49
|
+
expect(computeScriptTopic({ prompt: "saved" }, { override: "row", wired: "wire", refMap: M })).toBe("row"))
|
|
50
|
+
it("falls back to the saved `prompt` and resolves its {Label}", () =>
|
|
51
|
+
expect(computeScriptTopic({ prompt: "{Hero}" }, { wired: " ", refMap: R })).toBe("a knight"))
|
|
52
|
+
it("wraps the topic with the node's pre/post text", () =>
|
|
53
|
+
expect(computeScriptTopic({ promptPrefix: "A story:", promptSuffix: "The end." }, { wired: "a knight", refMap: M }))
|
|
54
|
+
.toBe("A story: a knight The end."))
|
|
55
|
+
it("nothing -> empty string", () => expect(computeScriptTopic({}, { refMap: M })).toBe(""))
|
|
56
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
TRANSITIONS,
|
|
4
|
+
TRANSITION_OPTION_FIELDS,
|
|
5
|
+
WIPE_DIRECTION,
|
|
6
|
+
composeTransitionHintFromConnections,
|
|
7
|
+
getParameterPromptHint,
|
|
8
|
+
getPickerCatalog,
|
|
9
|
+
getTransitionOptions,
|
|
10
|
+
getTransitionPromptHint,
|
|
11
|
+
projectPickerCatalog,
|
|
12
|
+
readTransitionOptionValues,
|
|
13
|
+
registerCatalogPack,
|
|
14
|
+
renderTransitionBases,
|
|
15
|
+
resetCatalogPacks,
|
|
16
|
+
} from "../index.js"
|
|
17
|
+
|
|
18
|
+
afterEach(() => resetCatalogPacks())
|
|
19
|
+
|
|
20
|
+
/** The rows with a Style option, in catalog order. */
|
|
21
|
+
const STYLED_ROWS = [
|
|
22
|
+
"smoke-puff", "sand-storm", "aurora-sweep", "sakura-petals", "garden-bloom", "debris-shower", "white-flash",
|
|
23
|
+
]
|
|
24
|
+
const OPTIONED_ROWS = ["wipe", ...STYLED_ROWS]
|
|
25
|
+
|
|
26
|
+
const BODY = (phrase: string) =>
|
|
27
|
+
`linear wipe (a clean ${phrase}, revealing the second shot behind it)`
|
|
28
|
+
|
|
29
|
+
/** Every wipe direction, as the video prompt reads it — the approved wording. */
|
|
30
|
+
const WIPE_BASES: Readonly<Record<string, string>> = {
|
|
31
|
+
auto: BODY("straight edge sweeps across the frame"),
|
|
32
|
+
"left-to-right": BODY("vertical edge sweeps across the frame from left to right"),
|
|
33
|
+
"right-to-left": BODY("vertical edge sweeps across the frame from right to left"),
|
|
34
|
+
"top-to-bottom": BODY("horizontal edge sweeps down the frame from top to bottom"),
|
|
35
|
+
"bottom-to-top": BODY("horizontal edge sweeps up the frame from bottom to top"),
|
|
36
|
+
"top-left-to-bottom-right": BODY(
|
|
37
|
+
"diagonal edge sweeps across the frame from the top-left corner to the bottom-right corner",
|
|
38
|
+
),
|
|
39
|
+
"top-right-to-bottom-left": BODY(
|
|
40
|
+
"diagonal edge sweeps across the frame from the top-right corner to the bottom-left corner",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("per-row transition options — the catalog", () => {
|
|
45
|
+
it("the wipe declares the direction; the styled rows share the style field", () => {
|
|
46
|
+
const withOptions = TRANSITIONS.filter((t) => t.options?.length)
|
|
47
|
+
expect(withOptions.map((t) => t.id)).toEqual(["wipe", ...STYLED_ROWS])
|
|
48
|
+
expect(getTransitionOptions("wipe")).toEqual([WIPE_DIRECTION])
|
|
49
|
+
for (const id of STYLED_ROWS) {
|
|
50
|
+
expect(getTransitionOptions(id).map((o) => o.field)).toEqual(["style"])
|
|
51
|
+
}
|
|
52
|
+
expect(TRANSITION_OPTION_FIELDS).toEqual(["wipeDirection", "style"])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("a field is declared at most once per row", () => {
|
|
56
|
+
for (const t of TRANSITIONS) {
|
|
57
|
+
const fields = (t.options ?? []).map((o) => o.field)
|
|
58
|
+
expect(new Set(fields).size).toBe(fields.length)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("the direction's choices: auto first, then the six approved directions", () => {
|
|
63
|
+
expect(WIPE_DIRECTION.choices.map((c) => c.id)).toEqual([
|
|
64
|
+
"auto",
|
|
65
|
+
"left-to-right",
|
|
66
|
+
"right-to-left",
|
|
67
|
+
"top-to-bottom",
|
|
68
|
+
"bottom-to-top",
|
|
69
|
+
"top-left-to-bottom-right",
|
|
70
|
+
"top-right-to-bottom-left",
|
|
71
|
+
])
|
|
72
|
+
expect(WIPE_DIRECTION.choices[0]!.term).toBe("")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("every template carries exactly one token per option and no other token", () => {
|
|
76
|
+
for (const t of TRANSITIONS) {
|
|
77
|
+
if (!t.promptTemplate) {
|
|
78
|
+
expect(t.options ?? []).toEqual([])
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const tokens = t.promptTemplate.match(/\{[^}]*\}/g) ?? []
|
|
82
|
+
expect(tokens.sort()).toEqual((t.options ?? []).map((o) => `{${o.field}}`).sort())
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("no promptHint carries a token — a plain reader always sees a sentence", () => {
|
|
87
|
+
for (const t of TRANSITIONS) expect(t.promptHint).not.toMatch(/[{}]/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("the wipe's promptHint IS its template on the auto choice", () => {
|
|
91
|
+
expect(getTransitionPromptHint("wipe")).toBe(
|
|
92
|
+
"linear wipe transition: a clean straight edge sweeps across the frame, revealing the second shot behind it",
|
|
93
|
+
)
|
|
94
|
+
expect(getTransitionPromptHint("wipe", { wipeDirection: "auto" })).toBe(
|
|
95
|
+
getTransitionPromptHint("wipe"),
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("per-row transition options — the rendered wipe", () => {
|
|
101
|
+
it.each(Object.entries(WIPE_BASES))("%s", (direction, base) => {
|
|
102
|
+
const values = direction === "auto" ? undefined : { wipeDirection: direction }
|
|
103
|
+
expect(renderTransitionBases(["wipe"], "full", values)).toEqual([base])
|
|
104
|
+
expect(
|
|
105
|
+
composeTransitionHintFromConnections("wipe", [], [], undefined, "full", { optionValues: values }),
|
|
106
|
+
).toBe(base)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("the direction slots in ahead of the timing levers, in both scopes", () => {
|
|
110
|
+
const timing = { position: "middle", duration: "short", intensity: "natural" } as const
|
|
111
|
+
expect(
|
|
112
|
+
composeTransitionHintFromConnections("wipe", [], [], timing, "full", {
|
|
113
|
+
optionValues: { wipeDirection: "top-to-bottom" },
|
|
114
|
+
}),
|
|
115
|
+
).toBe(
|
|
116
|
+
`${WIPE_BASES["top-to-bottom"]}, the transition occurs in the middle of the clip, ` +
|
|
117
|
+
"lasting approximately 1 second, with natural timing",
|
|
118
|
+
)
|
|
119
|
+
expect(
|
|
120
|
+
composeTransitionHintFromConnections("wipe", [], [], timing, "full", {
|
|
121
|
+
scope: "shot",
|
|
122
|
+
optionValues: { wipeDirection: "top-to-bottom" },
|
|
123
|
+
}),
|
|
124
|
+
).toBe(
|
|
125
|
+
`${WIPE_BASES["top-to-bottom"]}, the transition occurs in the middle of this shot, ` +
|
|
126
|
+
"lasting approximately 1 second, with natural timing",
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("an unknown direction reads as auto — never a hole in the sentence", () => {
|
|
131
|
+
expect(renderTransitionBases(["wipe"], "full", { wipeDirection: "sideways" })).toEqual([
|
|
132
|
+
WIPE_BASES.auto,
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("a row that declares no option ignores the value", () => {
|
|
137
|
+
for (const t of TRANSITIONS) {
|
|
138
|
+
if (t.options?.length) continue
|
|
139
|
+
expect(renderTransitionBases([t.id], "full", { wipeDirection: "left-to-right" })).toEqual(
|
|
140
|
+
renderTransitionBases([t.id]),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("a two-pick writes the direction into the wipe only", () => {
|
|
146
|
+
expect(
|
|
147
|
+
composeTransitionHintFromConnections(["cross-dissolve", "wipe"], [], [], undefined, "full", {
|
|
148
|
+
optionValues: { wipeDirection: "right-to-left" },
|
|
149
|
+
}),
|
|
150
|
+
).toBe(
|
|
151
|
+
`${renderTransitionBases(["cross-dissolve"])[0]}, and ${WIPE_BASES["right-to-left"]}`,
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("a catalog pack that REWRITES the wipe keeps its own words", () => {
|
|
156
|
+
const base = getPickerCatalog("transition")!
|
|
157
|
+
registerCatalogPack({
|
|
158
|
+
id: "t-wipe-rewrite",
|
|
159
|
+
catalogId: "transitions",
|
|
160
|
+
mode: "replace",
|
|
161
|
+
catalog: {
|
|
162
|
+
...base,
|
|
163
|
+
options: base.options!.map((o) =>
|
|
164
|
+
o.id === "wipe" ? { ...o, promptHint: "curated wipe text" } : o,
|
|
165
|
+
),
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
expect(getTransitionPromptHint("wipe", { wipeDirection: "left-to-right" })).toBe(
|
|
169
|
+
"curated wipe text",
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe("per-row transition options — the node data and the wire catalog", () => {
|
|
175
|
+
it("readTransitionOptionValues keeps declared, non-empty string fields only", () => {
|
|
176
|
+
expect(readTransitionOptionValues(undefined)).toBeUndefined()
|
|
177
|
+
expect(readTransitionOptionValues({ transition: "wipe" })).toBeUndefined()
|
|
178
|
+
expect(readTransitionOptionValues({ wipeDirection: "" })).toBeUndefined()
|
|
179
|
+
expect(
|
|
180
|
+
readTransitionOptionValues({ wipeDirection: "left-to-right", direction: "asc", other: 3 }),
|
|
181
|
+
).toEqual({ wipeDirection: "left-to-right" })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("a canvas transition node writes its wipeDirection", () => {
|
|
185
|
+
const node = (data: Record<string, unknown>) => ({ id: "t", type: "transition", data })
|
|
186
|
+
expect(getParameterPromptHint(node({ transition: "wipe", wipeDirection: "bottom-to-top" }))).toBe(
|
|
187
|
+
WIPE_BASES["bottom-to-top"],
|
|
188
|
+
)
|
|
189
|
+
expect(getParameterPromptHint(node({ transition: "wipe" }))).toBe(WIPE_BASES.auto)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("the transition catalog publishes the direction as the wipe option's params", () => {
|
|
193
|
+
const options = getPickerCatalog("transition")!.options!
|
|
194
|
+
const wipe = options.find((o) => o.id === "wipe")!
|
|
195
|
+
expect(wipe.params).toEqual([
|
|
196
|
+
{
|
|
197
|
+
field: "wipeDirection",
|
|
198
|
+
label: "Direction",
|
|
199
|
+
options: WIPE_DIRECTION.choices.map((c) => ({
|
|
200
|
+
id: c.id,
|
|
201
|
+
label: c.label,
|
|
202
|
+
description: c.description,
|
|
203
|
+
promptHint: c.phrase,
|
|
204
|
+
term: c.term,
|
|
205
|
+
})),
|
|
206
|
+
},
|
|
207
|
+
])
|
|
208
|
+
expect(options.filter((o) => o.params).map((o) => o.id)).toEqual(OPTIONED_ROWS)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("per-row transition options — the wire projection", () => {
|
|
213
|
+
it.each(["compact", "full"] as const)("the %s projection carries the wipe's params", (detail) => {
|
|
214
|
+
const projected = projectPickerCatalog(getPickerCatalog("transition")!, { detail })
|
|
215
|
+
const wipe = projected.options!.find((o) => o.id === "wipe")!
|
|
216
|
+
expect(wipe.params?.map((d) => d.field)).toEqual(["wipeDirection"])
|
|
217
|
+
expect(wipe.params![0]!.options.map((o) => o.id)).toEqual(WIPE_DIRECTION.choices.map((c) => c.id))
|
|
218
|
+
expect(wipe.params![0]!.options[1]!.promptHint).toBe(
|
|
219
|
+
detail === "full" ? "vertical edge sweeps across the frame from left to right" : undefined,
|
|
220
|
+
)
|
|
221
|
+
expect(projected.options!.filter((o) => o.params).map((o) => o.id)).toEqual(OPTIONED_ROWS)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
AURORA_SWEEP_STYLE,
|
|
4
|
+
DEBRIS_SHOWER_STYLE,
|
|
5
|
+
GARDEN_BLOOM_STYLE,
|
|
6
|
+
SAND_STORM_STYLE,
|
|
7
|
+
SAKURA_PETALS_STYLE,
|
|
8
|
+
SMOKE_PUFF_STYLE,
|
|
9
|
+
TRANSITIONS,
|
|
10
|
+
TRANSITION_STYLE_FIELD,
|
|
11
|
+
WHITE_FLASH_STYLE,
|
|
12
|
+
composeTransitionHintFromConnections,
|
|
13
|
+
getParameterPromptHint,
|
|
14
|
+
getTransitionOptions,
|
|
15
|
+
getTransitionPromptHint,
|
|
16
|
+
renderTransitionBases,
|
|
17
|
+
} from "../index.js"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* THE STYLE OPTION — a row's alternative looks, each the text a tested take
|
|
21
|
+
* was generated from. `landed` is the transition clause EXACTLY as it sat in
|
|
22
|
+
* that take's prompt (the f1ab / f2ab / f3ab A/B rounds and the sandsweep2 redraft, tile-default levers,
|
|
23
|
+
* `scope: "shot"`); the catalog must render it byte for byte. The one planned
|
|
24
|
+
* difference is smoke-puff's Full cover: it was tested as an untidied draft
|
|
25
|
+
* (capital first letter, final full stop), and ships with the approved tidy.
|
|
26
|
+
*/
|
|
27
|
+
const TESTED: ReadonlyArray<{ row: string; style: string; take: string; landed: string }> = [
|
|
28
|
+
{ row: "debris-shower", style: "auto", take: "f2ab 06-B 64f5537b",
|
|
29
|
+
landed: "debris shower (a dense shower of loose leaves, paper scraps and dust whips across the frame from one side, close to the lens, thick enough to hide the whole picture. The camera stays where it is and the framing does not change. As the last of the debris blows past the far edge, the second shot is revealed behind it. The shot ends on the second shot, clear and fully resolved, with no debris left. The debris passes in front of the picture in one direction, and the second shot appears only once it has passed)" },
|
|
30
|
+
{ row: "debris-shower", style: "debris-shower-light-sweep", take: "f2ab 06-A 399ee116",
|
|
31
|
+
landed: "debris shower (a shower of debris — leaves, papers, dust — sweeps across the frame in front of the camera, and once the debris clears the scene behind has changed)" },
|
|
32
|
+
{ row: "garden-bloom", style: "auto", take: "f2ab 04-A 583c84ec",
|
|
33
|
+
landed: "garden bloom (lush flowers and vines rapidly grow and bloom outward from the edges of the frame, the foliage spreads to overtake the entire image, then parts open like curtains to reveal the new scene behind)" },
|
|
34
|
+
{ row: "garden-bloom", style: "garden-bloom-hedge-doors", take: "f2ab 04-B ebe96b89",
|
|
35
|
+
landed: "garden bloom (vines and flowers grow rapidly inward from the edges of the frame, blooming as they spread, until leaves and blossoms cover the whole picture. The camera stays where it is and the framing does not change. The foliage then splits down the middle and draws apart toward the side edges, opening onto the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no leaves or flowers left. The plants grow over the front of the picture, and the first shot stays unchanged until they cover it completely)" },
|
|
36
|
+
{ row: "sakura-petals", style: "auto", take: "f2ab 03-A 7e31da0d",
|
|
37
|
+
landed: "cherry blossom petal storm (a dense storm of cherry blossom petals swirls in from one side and fills the frame in soft pink motion, the petals cluster to fully veil the image, then drift past to reveal the new scene)" },
|
|
38
|
+
{ row: "sakura-petals", style: "sakura-petals-side-sweep", take: "f2ab 03-B ae385f2f",
|
|
39
|
+
landed: "cherry blossom petal storm (a dense storm of pink cherry blossom petals swirls in from one side of the frame, close to the lens, and thickens until the petals veil the whole picture. The camera stays where it is and the framing does not change. The petals keep drifting the same way and thin out, revealing the second shot behind them. The shot ends on the second shot, clear and fully resolved, with no petals left. The petals fly across the front of the picture in one direction, and the first shot stays unchanged until they hide it completely)" },
|
|
40
|
+
{ row: "aurora-sweep", style: "auto", take: "f2ab 07-B ab3dd61e",
|
|
41
|
+
landed: "aurora sweep (a luminous curtain of green and violet aurora light ripples across the whole frame, and its bright bands veil the first shot. The camera stays where it is and the framing does not change. As the bands fade, the second shot is revealed behind them. The shot ends on the second shot, clear and fully resolved, with no aurora light left. The aurora glows over the front of the picture, and the second shot appears only as it fades)" },
|
|
42
|
+
{ row: "aurora-sweep", style: "aurora-sweep-veil", take: "f2ab 07-A 794058f2",
|
|
43
|
+
landed: "aurora sweep (a luminous green and violet aurora curtain ripples across the entire frame, the bright bands obscure the first scene, and as the aurora dissipates the second scene resolves in the clear sky)" },
|
|
44
|
+
{ row: "smoke-puff", style: "auto", take: "f1ab 04-A 739d8c22",
|
|
45
|
+
landed: "smoke puff (the subject vanishes in a soft puff of smoke that billows outward and fills the frame, the smoke then clears to reveal the new subject in the new scene)" },
|
|
46
|
+
{ row: "smoke-puff", style: "smoke-puff-full-cover", take: "f1ab 04-B 3a80fdfa",
|
|
47
|
+
landed: "smoke puff (The first subject vanishes in a sudden soft puff of smoke that billows outward from where it stood until the smoke fills the frame. The camera stays where it is and the framing does not change. The smoke thins and clears from the centre outward, revealing the second shot with the second subject at the same place in the frame. The shot ends on the second shot, clear and fully resolved, with no smoke left. The smoke comes only from where the first subject was.)" },
|
|
48
|
+
{ row: "sand-storm", style: "auto", take: "f2ab 01-B bc5bf08d",
|
|
49
|
+
landed: "sand storm (a wall of opaque, swirling ochre sand sweeps in from one side of the frame and surges across it until the whole picture is hidden in dust. The camera stays where it is and the framing does not change. The dust then thins and sinks away toward the lower edge of the frame, revealing the second shot behind it. The shot ends on the second shot, clear and fully resolved, with no dust left. The first shot stays as it is until the sand covers it completely, and the second shot appears only as the dust clears)" },
|
|
50
|
+
{ row: "sand-storm", style: "sand-storm-light-sweep", take: "sandsweep2 R1 64574737",
|
|
51
|
+
landed: "sand storm (a narrow streak of blown sand races across the frame close to the lens in one direction. The first shot stays clear ahead of the streak and the second shot is already clear behind it. The camera stays where it is and the framing does not change. The shot ends on the second shot, fully resolved, with no sand left)" },
|
|
52
|
+
{ row: "white-flash", style: "auto", take: "f3ab 07-B 4e42fbe4",
|
|
53
|
+
landed: "white flash (a bright camera-flash bloom fills the whole frame with pure white. The camera stays where it is and the framing does not change. The white holds for a clear beat, then fades down to reveal the second shot. The shot ends on the second shot, fully resolved, with no white haze left. The first shot is gone once the frame is white, and the second shot appears only out of the white)" },
|
|
54
|
+
{ row: "white-flash", style: "white-flash-overexposure", take: "f3ab 07-A f22e5913",
|
|
55
|
+
landed: "white flash (a bright camera-flash bloom fills the frame with pure white, holds for a fraction of a second, then resolves into the new scene)" },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/** The approved tidy: first letter lower-cased, a final full stop dropped. */
|
|
59
|
+
const tidy = (clause: string): string =>
|
|
60
|
+
clause.replace(/\((.)/, (_, c: string) => `(${c.toLowerCase()}`).replace(/\.\)$/, ")")
|
|
61
|
+
|
|
62
|
+
const values = (style: string) => (style === "auto" ? undefined : { style })
|
|
63
|
+
|
|
64
|
+
const STYLES = {
|
|
65
|
+
"smoke-puff": SMOKE_PUFF_STYLE,
|
|
66
|
+
"sand-storm": SAND_STORM_STYLE,
|
|
67
|
+
"aurora-sweep": AURORA_SWEEP_STYLE,
|
|
68
|
+
"sakura-petals": SAKURA_PETALS_STYLE,
|
|
69
|
+
"garden-bloom": GARDEN_BLOOM_STYLE,
|
|
70
|
+
"debris-shower": DEBRIS_SHOWER_STYLE,
|
|
71
|
+
"white-flash": WHITE_FLASH_STYLE,
|
|
72
|
+
} as const
|
|
73
|
+
|
|
74
|
+
describe("transition Style — the rows and their choices", () => {
|
|
75
|
+
it("seven rows declare a Style, all on the one shared field", () => {
|
|
76
|
+
const styled = TRANSITIONS.filter((t) => t.options?.some((o) => o.field === TRANSITION_STYLE_FIELD))
|
|
77
|
+
expect(styled.map((t) => t.id)).toEqual(Object.keys(STYLES))
|
|
78
|
+
for (const [id, option] of Object.entries(STYLES)) {
|
|
79
|
+
expect(getTransitionOptions(id)).toEqual([option])
|
|
80
|
+
expect(TRANSITIONS.find((t) => t.id === id)!.promptTemplate).toBe("{style}")
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("the default look is `auto`, labelled by its own name — never \"Auto\"", () => {
|
|
85
|
+
const labels = Object.fromEntries(
|
|
86
|
+
Object.entries(STYLES).map(([id, o]) => [id, o.choices.map((c) => `${c.id}=${c.label}`)]),
|
|
87
|
+
)
|
|
88
|
+
expect(labels).toEqual({
|
|
89
|
+
"smoke-puff": ["auto=Engulf (default)", "smoke-puff-full-cover=Full cover"],
|
|
90
|
+
"sand-storm": ["auto=Full cover (default)", "sand-storm-light-sweep=Light sweep"],
|
|
91
|
+
"aurora-sweep": ["auto=Sky glow (default)", "aurora-sweep-veil=Veil"],
|
|
92
|
+
"sakura-petals": ["auto=Swirling veil (default)", "sakura-petals-side-sweep=Side sweep"],
|
|
93
|
+
"garden-bloom": ["auto=Grow & part (default)", "garden-bloom-hedge-doors=Hedge doors"],
|
|
94
|
+
"debris-shower": ["auto=Full cover (default)", "debris-shower-light-sweep=Light sweep"],
|
|
95
|
+
"white-flash": ["auto=Flash (default)", "white-flash-overexposure=Overexposure"],
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("every choice but auto is prefixed with its row's id, so no id means anything on two rows", () => {
|
|
100
|
+
const seen = new Set<string>()
|
|
101
|
+
for (const [id, option] of Object.entries(STYLES)) {
|
|
102
|
+
for (const c of option.choices.slice(1)) {
|
|
103
|
+
expect(c.id.startsWith(`${id}-`)).toBe(true)
|
|
104
|
+
expect(seen.has(c.id)).toBe(false)
|
|
105
|
+
seen.add(c.id)
|
|
106
|
+
}
|
|
107
|
+
for (const c of option.choices) expect(c.term).toBe("")
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("every body is ship-tidy: lower-case first letter, no final full stop, no token", () => {
|
|
112
|
+
for (const option of Object.values(STYLES)) {
|
|
113
|
+
for (const c of option.choices) {
|
|
114
|
+
expect(c.phrase[0]).toBe(c.phrase[0]!.toLowerCase())
|
|
115
|
+
expect(c.phrase).not.toMatch(/[.\s]$/)
|
|
116
|
+
expect(c.phrase).not.toMatch(/[{}]/)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("a styled row's plain promptHint IS its default look", () => {
|
|
122
|
+
for (const [id, option] of Object.entries(STYLES)) {
|
|
123
|
+
expect(getTransitionPromptHint(id)).toBe(option.choices[0]!.phrase)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe("transition Style — the rendered text is the tested text", () => {
|
|
129
|
+
it("every row × style has a tested take", () => {
|
|
130
|
+
const covered = TESTED.map((t) => `${t.row}|${t.style}`).sort()
|
|
131
|
+
const declared = Object.entries(STYLES)
|
|
132
|
+
.flatMap(([id, o]) => o.choices.map((c) => `${id}|${c.id}`))
|
|
133
|
+
.sort()
|
|
134
|
+
expect(covered).toEqual(declared)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it.each(TESTED.map((t) => [`${t.row} · ${t.style} (${t.take})`, t] as const))("%s", (_, t) => {
|
|
138
|
+
const rendered = composeTransitionHintFromConnections(t.row, [], [], {}, "full", {
|
|
139
|
+
scope: "shot",
|
|
140
|
+
optionValues: values(t.style),
|
|
141
|
+
})
|
|
142
|
+
const planned = t.row === "smoke-puff" && t.style !== "auto" ? tidy(t.landed) : t.landed
|
|
143
|
+
expect(rendered).toBe(planned)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("smoke-puff's Full cover differs from its take only by the approved tidy", () => {
|
|
147
|
+
const t = TESTED.find((x) => x.row === "smoke-puff" && x.style !== "auto")!
|
|
148
|
+
expect(t.landed).not.toBe(tidy(t.landed))
|
|
149
|
+
expect(t.landed.replace(/\.\)$/, ")").toLowerCase()).toBe(tidy(t.landed).toLowerCase())
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("the levers follow the style's body unchanged", () => {
|
|
153
|
+
for (const t of TESTED) {
|
|
154
|
+
const base = renderTransitionBases([t.row], "full", values(t.style))[0]!
|
|
155
|
+
expect(
|
|
156
|
+
composeTransitionHintFromConnections(
|
|
157
|
+
t.row, [], [], { position: "middle", duration: "short", intensity: "natural" }, "full",
|
|
158
|
+
{ optionValues: values(t.style) },
|
|
159
|
+
),
|
|
160
|
+
).toBe(
|
|
161
|
+
`${base}, the transition occurs in the middle of the clip, lasting approximately 1 second, with natural timing`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("compact renders the same `term (body)` — the row's term leads every style", () => {
|
|
167
|
+
for (const t of TESTED) {
|
|
168
|
+
expect(
|
|
169
|
+
composeTransitionHintFromConnections(t.row, [], [], {}, "compact", { optionValues: values(t.style) }),
|
|
170
|
+
).toBe(renderTransitionBases([t.row], "full", values(t.style))[0])
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("another row's style — or none, or auto — reads as the row's default look", () => {
|
|
175
|
+
const light = { style: "debris-shower-light-sweep" }
|
|
176
|
+
for (const id of ["garden-bloom", "smoke-puff", "aurora-sweep", "sakura-petals"]) {
|
|
177
|
+
expect(renderTransitionBases([id], "full", light)).toEqual(renderTransitionBases([id]))
|
|
178
|
+
}
|
|
179
|
+
expect(renderTransitionBases(["debris-shower"], "full", { style: "auto" })).toEqual(
|
|
180
|
+
renderTransitionBases(["debris-shower"]),
|
|
181
|
+
)
|
|
182
|
+
expect(renderTransitionBases(["debris-shower"], "full", { style: "light-sweep" })).toEqual(
|
|
183
|
+
renderTransitionBases(["debris-shower"]),
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("a canvas transition node writes its style", () => {
|
|
188
|
+
const node = (data: Record<string, unknown>) => ({ id: "t", type: "transition", data })
|
|
189
|
+
const light = TESTED.find((t) => t.style === "debris-shower-light-sweep")!
|
|
190
|
+
expect(
|
|
191
|
+
getParameterPromptHint(node({ transition: "debris-shower", style: "debris-shower-light-sweep" })),
|
|
192
|
+
).toBe(light.landed)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("a two-pick writes the style into its own row only", () => {
|
|
196
|
+
const hedge = { style: "garden-bloom-hedge-doors" }
|
|
197
|
+
expect(renderTransitionBases(["debris-shower", "garden-bloom"], "full", hedge)).toEqual([
|
|
198
|
+
renderTransitionBases(["debris-shower"])[0],
|
|
199
|
+
renderTransitionBases(["garden-bloom"], "full", hedge)[0],
|
|
200
|
+
])
|
|
201
|
+
})
|
|
202
|
+
})
|