@nodaro/shared 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/index.ts +206 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
isValidTimezone,
|
|
4
|
+
legacyScheduleToRules,
|
|
5
|
+
localMinuteKey,
|
|
6
|
+
localTimeIn,
|
|
7
|
+
nextScheduleRuns,
|
|
8
|
+
normalizeScheduleRule,
|
|
9
|
+
normalizeScheduleRules,
|
|
10
|
+
previewHorizonMs,
|
|
11
|
+
ruleMatches,
|
|
12
|
+
scheduleMatchesAt,
|
|
13
|
+
scheduleOccurrences,
|
|
14
|
+
timezoneOffsetMinutes,
|
|
15
|
+
type ScheduleRule,
|
|
16
|
+
} from "../schedule-rules.js"
|
|
17
|
+
|
|
18
|
+
const at = (iso: string) => new Date(iso)
|
|
19
|
+
|
|
20
|
+
describe("normalizeScheduleRule", () => {
|
|
21
|
+
it("clamps to the kind's range instead of refusing, and fills the defaults", () => {
|
|
22
|
+
// 0 is "not typed yet": the kind's default, never every minute.
|
|
23
|
+
expect(normalizeScheduleRule({ id: "a", kind: "minutes", every: 0 })).toEqual({ id: "a", kind: "minutes", every: 5 })
|
|
24
|
+
expect(normalizeScheduleRule({ id: "a", kind: "minutes", every: "120" })).toEqual({ id: "a", kind: "minutes", every: 59 })
|
|
25
|
+
expect(normalizeScheduleRule({ id: "a", kind: "hours", every: 2, minute: 75 })).toEqual({ id: "a", kind: "hours", every: 2, minute: 59 })
|
|
26
|
+
expect(normalizeScheduleRule({ kind: "days" }, "rule-7")).toEqual({ id: "rule-7", kind: "days", every: 1, hour: 0, minute: 0 })
|
|
27
|
+
expect(normalizeScheduleRule({ id: "m", kind: "months", every: 3, dayOfMonth: 40, hour: 9 })).toEqual({ id: "m", kind: "months", every: 3, hour: 9, minute: 0, dayOfMonth: 31 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("weeks keeps only valid weekdays, deduplicated and sorted; none means no rule", () => {
|
|
31
|
+
expect(normalizeScheduleRule({ id: "w", kind: "weeks", weekdays: [5, 1, 1, 9, -1, "3"], hour: 9 })).toEqual({ id: "w", kind: "weeks", every: 1, hour: 9, minute: 0, weekdays: [1, 3, 5] })
|
|
32
|
+
expect(normalizeScheduleRule({ id: "w", kind: "weeks", weekdays: [] })).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("a cron rule needs a 5-field expression; unknown kinds are not rules", () => {
|
|
36
|
+
expect(normalizeScheduleRule({ id: "c", kind: "cron", cron: " 0 9 * * 1-5 " })).toEqual({ id: "c", kind: "cron", cron: "0 9 * * 1-5" })
|
|
37
|
+
expect(normalizeScheduleRule({ id: "c", kind: "cron", cron: "0 9 * *" })).toBeNull()
|
|
38
|
+
expect(normalizeScheduleRule({ id: "s", kind: "seconds", every: 30 })).toBeNull()
|
|
39
|
+
expect(normalizeScheduleRules([{ kind: "minutes", every: 5 }, { kind: "nope" }, null])).toHaveLength(1)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("legacyScheduleToRules — the old node fields as rules", () => {
|
|
44
|
+
it("interval strings", () => {
|
|
45
|
+
expect(legacyScheduleToRules({ interval: "5m" })).toEqual([{ id: "rule-1", kind: "minutes", every: 5 }])
|
|
46
|
+
expect(legacyScheduleToRules({ interval: "90m" })).toEqual([{ id: "rule-1", kind: "hours", every: 2, minute: 0 }])
|
|
47
|
+
expect(legacyScheduleToRules({ interval: "1h" })).toEqual([{ id: "rule-1", kind: "hours", every: 1, minute: 0 }])
|
|
48
|
+
expect(legacyScheduleToRules({ interval: "2d" })).toEqual([{ id: "rule-1", kind: "days", every: 2, hour: 0, minute: 0 }])
|
|
49
|
+
// Seconds cannot be scheduled: the cron ticks once a minute.
|
|
50
|
+
expect(legacyScheduleToRules({ interval: "30s" })).toEqual([{ id: "rule-1", kind: "minutes", every: 1 }])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("the editor's cron presets become words, a custom cron stays a cron rule", () => {
|
|
54
|
+
expect(legacyScheduleToRules({ interval: "*/15 * * * *" })).toEqual([{ id: "rule-1", kind: "minutes", every: 15 }])
|
|
55
|
+
expect(legacyScheduleToRules({ interval: "0 * * * *" })).toEqual([{ id: "rule-1", kind: "hours", every: 1, minute: 0 }])
|
|
56
|
+
expect(legacyScheduleToRules({ interval: "0 0 * * *" })).toEqual([{ id: "rule-1", kind: "days", every: 1, hour: 0, minute: 0 }])
|
|
57
|
+
expect(legacyScheduleToRules({ interval: "custom", cron: "30 9 * * 1-5" })).toEqual([{ id: "rule-1", kind: "weeks", every: 1, hour: 9, minute: 30, weekdays: [1, 2, 3, 4, 5] }])
|
|
58
|
+
expect(legacyScheduleToRules({ interval: "custom", cronExpression: "0 6 1 * *" })).toEqual([{ id: "rule-1", kind: "months", every: 1, hour: 6, minute: 0, dayOfMonth: 1 }])
|
|
59
|
+
expect(legacyScheduleToRules({ interval: "custom", cron: "0 6 * 3 *" })).toEqual([{ id: "rule-1", kind: "cron", cron: "0 6 * 3 *" }])
|
|
60
|
+
expect(legacyScheduleToRules({})).toEqual([])
|
|
61
|
+
expect(legacyScheduleToRules({ interval: "custom", cron: "not cron" })).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("localTimeIn / timezoneOffsetMinutes", () => {
|
|
66
|
+
it("reads the wall clock in the schedule's timezone; unknown zones mean UTC", () => {
|
|
67
|
+
const summer = at("2026-07-01T06:30:00Z")
|
|
68
|
+
expect(localTimeIn(summer, "Asia/Jerusalem")).toMatchObject({ year: 2026, month: 7, day: 1, hour: 9, minute: 30, weekday: 3 })
|
|
69
|
+
expect(localTimeIn(summer, "America/New_York")).toMatchObject({ hour: 2, minute: 30 })
|
|
70
|
+
expect(localTimeIn(summer, "Not/AZone")).toMatchObject({ hour: 6, minute: 30 })
|
|
71
|
+
expect(localTimeIn(summer)).toMatchObject({ hour: 6, minute: 30 })
|
|
72
|
+
expect(timezoneOffsetMinutes(summer, "Asia/Jerusalem")).toBe(180)
|
|
73
|
+
expect(timezoneOffsetMinutes(at("2026-01-15T12:00:00Z"), "Asia/Jerusalem")).toBe(120)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("isValidTimezone knows the zones this runtime can read", () => {
|
|
77
|
+
expect(isValidTimezone("Asia/Jerusalem")).toBe(true)
|
|
78
|
+
expect(isValidTimezone("UTC")).toBe(true)
|
|
79
|
+
expect(isValidTimezone("Not/AZone")).toBe(false)
|
|
80
|
+
expect(isValidTimezone("")).toBe(false)
|
|
81
|
+
expect(isValidTimezone(undefined)).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("localMinuteKey names one wall-clock minute", () => {
|
|
85
|
+
expect(localMinuteKey(localTimeIn(at("2026-09-22T10:05:30Z")))).toBe(localMinuteKey(localTimeIn(at("2026-09-22T10:05:59Z"))))
|
|
86
|
+
expect(localMinuteKey(localTimeIn(at("2026-09-22T10:05:00Z")))).not.toBe(localMinuteKey(localTimeIn(at("2026-09-22T10:06:00Z"))))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("epochDay and weekday agree with the calendar", () => {
|
|
90
|
+
expect(localTimeIn(at("1970-01-01T12:00:00Z"))).toMatchObject({ epochDay: 0, weekday: 4 })
|
|
91
|
+
expect(localTimeIn(at("2026-09-21T12:00:00Z"))).toMatchObject({ weekday: 1 }) // a Monday
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("ruleMatches — one minute against one rule", () => {
|
|
96
|
+
const local = (iso: string, tz?: string) => localTimeIn(at(iso), tz)
|
|
97
|
+
|
|
98
|
+
it("minutes: at 0, N, 2N… of every hour", () => {
|
|
99
|
+
const rule: ScheduleRule = { id: "r", kind: "minutes", every: 20 }
|
|
100
|
+
expect(ruleMatches(rule, local("2026-09-22T10:00:00Z"))).toBe(true)
|
|
101
|
+
expect(ruleMatches(rule, local("2026-09-22T10:20:00Z"))).toBe(true)
|
|
102
|
+
expect(ruleMatches(rule, local("2026-09-22T10:21:00Z"))).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("hours: every Nth hour at the given minute", () => {
|
|
106
|
+
const rule: ScheduleRule = { id: "r", kind: "hours", every: 6, minute: 15 }
|
|
107
|
+
expect(ruleMatches(rule, local("2026-09-22T06:15:00Z"))).toBe(true)
|
|
108
|
+
expect(ruleMatches(rule, local("2026-09-22T07:15:00Z"))).toBe(false)
|
|
109
|
+
expect(ruleMatches(rule, local("2026-09-22T06:16:00Z"))).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("days: at hour:minute in the schedule's timezone, every Nth day from a fixed origin", () => {
|
|
113
|
+
const daily: ScheduleRule = { id: "r", kind: "days", every: 1, hour: 9, minute: 0 }
|
|
114
|
+
expect(ruleMatches(daily, local("2026-07-01T06:00:00Z", "Asia/Jerusalem"))).toBe(true) // 09:00 in Jerusalem (summer)
|
|
115
|
+
expect(ruleMatches(daily, local("2026-07-01T09:00:00Z", "Asia/Jerusalem"))).toBe(false)
|
|
116
|
+
const everyThird: ScheduleRule = { id: "r", kind: "days", every: 3, hour: 0, minute: 0 }
|
|
117
|
+
const hits = [0, 1, 2, 3, 4, 5, 6, 7, 8].filter((d) => ruleMatches(everyThird, local(`2026-09-${String(10 + d).padStart(2, "0")}T00:00:00Z`)))
|
|
118
|
+
expect(hits).toHaveLength(3) // any nine consecutive days hold exactly three "every third day" hits
|
|
119
|
+
expect(hits[1] - hits[0]).toBe(3)
|
|
120
|
+
expect(hits[2] - hits[1]).toBe(3)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("weeks: on the chosen weekdays; every Nth Monday-start week", () => {
|
|
124
|
+
const weekdays9: ScheduleRule = { id: "r", kind: "weeks", every: 1, hour: 9, minute: 0, weekdays: [1, 2, 3, 4, 5] }
|
|
125
|
+
expect(ruleMatches(weekdays9, local("2026-09-21T09:00:00Z"))).toBe(true) // Monday
|
|
126
|
+
expect(ruleMatches(weekdays9, local("2026-09-20T09:00:00Z"))).toBe(false) // Sunday
|
|
127
|
+
const biweekly: ScheduleRule = { id: "r", kind: "weeks", every: 2, hour: 9, minute: 0, weekdays: [1] }
|
|
128
|
+
const mondays = ["2026-09-07", "2026-09-14", "2026-09-21", "2026-09-28"].map((d) => ruleMatches(biweekly, local(`${d}T09:00:00Z`)))
|
|
129
|
+
expect(mondays.filter(Boolean)).toHaveLength(2)
|
|
130
|
+
expect(mondays[0]).toBe(mondays[2]) // alternating, phase stable
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("months: on the day of month (clamped to the month's length) at hour:minute", () => {
|
|
134
|
+
const the31st: ScheduleRule = { id: "r", kind: "months", every: 1, hour: 8, minute: 0, dayOfMonth: 31 }
|
|
135
|
+
expect(ruleMatches(the31st, local("2026-01-31T08:00:00Z"))).toBe(true)
|
|
136
|
+
expect(ruleMatches(the31st, local("2026-02-28T08:00:00Z"))).toBe(true) // February has no 31st
|
|
137
|
+
expect(ruleMatches(the31st, local("2026-02-27T08:00:00Z"))).toBe(false)
|
|
138
|
+
const quarterly: ScheduleRule = { id: "r", kind: "months", every: 3, hour: 0, minute: 0, dayOfMonth: 1 }
|
|
139
|
+
const firsts = [1, 2, 3, 4, 5, 6].map((m) => ruleMatches(quarterly, local(`2026-${String(m).padStart(2, "0")}-01T00:00:00Z`)))
|
|
140
|
+
expect(firsts.filter(Boolean)).toHaveLength(2)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("cron: a 5-field expression, weekday 0 = Sunday", () => {
|
|
144
|
+
const rule: ScheduleRule = { id: "r", kind: "cron", cron: "30 6 * * 1-5" }
|
|
145
|
+
expect(ruleMatches(rule, local("2026-09-21T06:30:00Z"))).toBe(true)
|
|
146
|
+
expect(ruleMatches(rule, local("2026-09-20T06:30:00Z"))).toBe(false)
|
|
147
|
+
expect(ruleMatches({ id: "r", kind: "cron", cron: "*/15 * * * *" }, local("2026-09-21T06:45:00Z"))).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("scheduleMatchesAt / scheduleOccurrences / nextScheduleRuns", () => {
|
|
152
|
+
it("any rule matching this minute fires the schedule", () => {
|
|
153
|
+
const spec = { rules: [{ id: "a", kind: "hours" as const, every: 1, minute: 0 }, { id: "b", kind: "minutes" as const, every: 20 }] }
|
|
154
|
+
expect(scheduleMatchesAt(spec, at("2026-09-22T10:40:00Z"))).toBe(true)
|
|
155
|
+
expect(scheduleMatchesAt(spec, at("2026-09-22T10:41:00Z"))).toBe(false)
|
|
156
|
+
expect(scheduleMatchesAt({ rules: [] }, at("2026-09-22T10:00:00Z"))).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("every 20 minutes is 72 runs a day; several rules merge into one chronological list", () => {
|
|
160
|
+
const day = { from: at("2026-09-22T00:00:00Z"), until: at("2026-09-22T23:59:00Z") }
|
|
161
|
+
expect(scheduleOccurrences({ rules: [{ id: "a", kind: "minutes", every: 20 }] }, day.from, day.until, 5000)).toHaveLength(72)
|
|
162
|
+
const two = scheduleOccurrences(
|
|
163
|
+
{ rules: [{ id: "a", kind: "hours", every: 12, minute: 0 }, { id: "b", kind: "days", every: 1, hour: 6, minute: 0 }] },
|
|
164
|
+
day.from, day.until, 10,
|
|
165
|
+
)
|
|
166
|
+
expect(two.map((d) => d.toISOString())).toEqual(["2026-09-22T00:00:00.000Z", "2026-09-22T06:00:00.000Z", "2026-09-22T12:00:00.000Z"])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("the next runs respect the timezone across a DST change", () => {
|
|
170
|
+
// New York springs forward on 2026-03-08 at 02:00. A daily 09:00 rule stays at 09:00 local: 14:00Z before, 13:00Z after.
|
|
171
|
+
const spec = { rules: [{ id: "a", kind: "days" as const, every: 1, hour: 9, minute: 0 }], timezone: "America/New_York" }
|
|
172
|
+
const runs = nextScheduleRuns(spec, at("2026-03-07T00:00:00Z"), 2)
|
|
173
|
+
expect(runs.map((d) => d.toISOString())).toEqual(["2026-03-07T14:00:00.000Z", "2026-03-08T13:00:00.000Z"])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("a zone whose clocks change mid-UTC-hour keeps its wall-clock time across the change", () => {
|
|
177
|
+
// Adelaide springs forward on 2026-10-04 at 02:00 (+9:30 → +10:30), which is 16:30Z.
|
|
178
|
+
// A daily 03:15 rule: 17:45Z before the change, 16:45Z from the day of the change.
|
|
179
|
+
const spec = { rules: [{ id: "a", kind: "days" as const, every: 1, hour: 3, minute: 15 }], timezone: "Australia/Adelaide" }
|
|
180
|
+
expect(nextScheduleRuns(spec, at("2026-10-02T00:00:00Z"), 3).map((d) => d.toISOString())).toEqual([
|
|
181
|
+
"2026-10-02T17:45:00.000Z",
|
|
182
|
+
"2026-10-03T16:45:00.000Z",
|
|
183
|
+
"2026-10-04T16:45:00.000Z",
|
|
184
|
+
])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("a wall-clock minute the clocks fall back onto runs once; one they jump over is skipped that day", () => {
|
|
188
|
+
// New York falls back on 2026-11-01 at 02:00 EDT → 01:00 EST: 01:30 local happens at 05:30Z and again at 06:30Z.
|
|
189
|
+
const fallBack = { rules: [{ id: "a", kind: "days" as const, every: 1, hour: 1, minute: 30 }], timezone: "America/New_York" }
|
|
190
|
+
expect(scheduleOccurrences(fallBack, at("2026-11-01T00:00:00Z"), at("2026-11-01T12:00:00Z"), 5).map((d) => d.toISOString()))
|
|
191
|
+
.toEqual(["2026-11-01T05:30:00.000Z"])
|
|
192
|
+
// Springs forward on 2026-03-08 at 02:00 → 03:00: 02:30 local does not exist that day.
|
|
193
|
+
const springForward = { rules: [{ id: "a", kind: "days" as const, every: 1, hour: 2, minute: 30 }], timezone: "America/New_York" }
|
|
194
|
+
expect(scheduleOccurrences(springForward, at("2026-03-08T00:00:00Z"), at("2026-03-08T23:59:00Z"), 5)).toEqual([])
|
|
195
|
+
expect(scheduleOccurrences(springForward, at("2026-03-09T00:00:00Z"), at("2026-03-09T23:59:00Z"), 5).map((d) => d.toISOString()))
|
|
196
|
+
.toEqual(["2026-03-09T06:30:00.000Z"])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("the next run of a weekly rule is the next chosen weekday", () => {
|
|
200
|
+
const spec = { rules: [{ id: "a", kind: "weeks" as const, every: 1, hour: 9, minute: 0, weekdays: [1] }] }
|
|
201
|
+
expect(nextScheduleRuns(spec, at("2026-09-22T12:00:00Z"), 1).map((d) => d.toISOString())).toEqual(["2026-09-28T09:00:00.000Z"])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe("the edges a review found", () => {
|
|
206
|
+
const iso = (d: Date) => d.toISOString()
|
|
207
|
+
|
|
208
|
+
it("weeks are Monday-start: a Sunday and the Monday after it fall in different weeks", () => {
|
|
209
|
+
const rule: ScheduleRule = { id: "r", kind: "weeks", every: 2, hour: 9, minute: 0, weekdays: [0, 1] }
|
|
210
|
+
const sunday = ruleMatches(rule, localTimeIn(at("2026-09-20T09:00:00Z")))
|
|
211
|
+
const monday = ruleMatches(rule, localTimeIn(at("2026-09-21T09:00:00Z")))
|
|
212
|
+
expect(sunday).not.toBe(monday)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("a quarterly rule lands on January, April, July and October — by value, not by count", () => {
|
|
216
|
+
const quarterly: ScheduleRule = { id: "r", kind: "months", every: 3, hour: 0, minute: 0, dayOfMonth: 1 }
|
|
217
|
+
const months = Array.from({ length: 12 }, (_, i) => i + 1).filter((m) =>
|
|
218
|
+
ruleMatches(quarterly, localTimeIn(at(`2026-${String(m).padStart(2, "0")}-01T00:00:00Z`))),
|
|
219
|
+
)
|
|
220
|
+
expect(months).toEqual([1, 4, 7, 10])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("a legacy cron nobody could say in words never becomes 'every minute', and an empty weekday set is no rule", () => {
|
|
224
|
+
// "*/0" never matched before (a zero step); it stays a cron rule that never matches.
|
|
225
|
+
const zeroStep = legacyScheduleToRules({ interval: "custom", cron: "*/0 * * * *" })
|
|
226
|
+
expect(zeroStep).toEqual([{ id: "rule-1", kind: "cron", cron: "*/0 * * * *" }])
|
|
227
|
+
expect(scheduleMatchesAt({ rules: zeroStep }, at("2026-09-22T10:00:00Z"))).toBe(false)
|
|
228
|
+
expect(legacyScheduleToRules({ interval: "custom", cron: "0 9 * * 5-0" })).toEqual([])
|
|
229
|
+
// "0m" parsed to 0 ms and never fired; it is not a schedule now either.
|
|
230
|
+
expect(legacyScheduleToRules({ interval: "0m" })).toEqual([])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("every: 0 is 'not typed yet' — the kind's default, never every minute", () => {
|
|
234
|
+
expect(normalizeScheduleRule({ id: "a", kind: "minutes", every: 0 })).toEqual({ id: "a", kind: "minutes", every: 5 })
|
|
235
|
+
expect(normalizeScheduleRule({ id: "a", kind: "hours", every: -2 })).toEqual({ id: "a", kind: "hours", every: 1, minute: 0 })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("the next run is the very next minute, even when asked mid-minute", () => {
|
|
239
|
+
expect(nextScheduleRuns({ rules: [{ id: "a", kind: "minutes", every: 1 }] }, at("2026-09-22T12:00:30Z"), 2).map(iso))
|
|
240
|
+
.toEqual(["2026-09-22T12:01:00.000Z", "2026-09-22T12:02:00.000Z"])
|
|
241
|
+
expect(nextScheduleRuns({ rules: [{ id: "a", kind: "days", every: 1, hour: 12, minute: 1 }] }, at("2026-09-22T12:00:30Z"), 1).map(iso))
|
|
242
|
+
.toEqual(["2026-09-22T12:01:00.000Z"])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("a quarterly or yearly rule still gets its next run — the horizon follows the rules", () => {
|
|
246
|
+
const yearly = { rules: [{ id: "a", kind: "months" as const, every: 12, hour: 9, minute: 0, dayOfMonth: 1 }] }
|
|
247
|
+
expect(nextScheduleRuns(yearly, at("2026-02-02T00:00:00Z"), 1).map(iso)).toEqual(["2027-01-01T09:00:00.000Z"])
|
|
248
|
+
const quarterly = { rules: [{ id: "a", kind: "months" as const, every: 3, hour: 9, minute: 0, dayOfMonth: 1 }] }
|
|
249
|
+
expect(nextScheduleRuns(quarterly, at("2026-01-02T00:00:00Z"), 1).map(iso)).toEqual(["2026-04-01T09:00:00.000Z"])
|
|
250
|
+
expect(previewHorizonMs([{ id: "a", kind: "minutes", every: 5 }])).toBe(62 * 86400000)
|
|
251
|
+
expect(previewHorizonMs(yearly.rules)).toBeGreaterThan(365 * 86400000)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("Sunday answers to 7 in a cron rule, as in every crontab", () => {
|
|
255
|
+
const sundayAsSeven: ScheduleRule = { id: "r", kind: "cron", cron: "0 9 * * 7" }
|
|
256
|
+
expect(ruleMatches(sundayAsSeven, localTimeIn(at("2026-09-20T09:00:00Z")))).toBe(true) // a Sunday
|
|
257
|
+
expect(ruleMatches(sundayAsSeven, localTimeIn(at("2026-09-21T09:00:00Z")))).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("a timezone is read the same however it is spelled, and cached under its canonical name", () => {
|
|
261
|
+
expect(isValidTimezone("aSiA/jErUsAlEm")).toBe(true)
|
|
262
|
+
expect(localTimeIn(at("2026-07-01T06:30:00Z"), "aSiA/jErUsAlEm").hour).toBe(9)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { COMBINE_TRANSITIONS } from "../combine-transitions.js"
|
|
4
|
+
import { edlDurationMs, type Edl } from "../edl.js"
|
|
5
|
+
import {
|
|
6
|
+
EDL_TARGET_ASPECTS,
|
|
7
|
+
SPEAKER_LAYOUTS,
|
|
8
|
+
SPEAKER_LAYOUT_IDS,
|
|
9
|
+
getSpeakerLayout,
|
|
10
|
+
speakerLayoutAllows,
|
|
11
|
+
speakerSwitchOverlaps,
|
|
12
|
+
SPEAKER_SWITCHES,
|
|
13
|
+
SPEAKER_SWITCH_IDS,
|
|
14
|
+
getSpeakerSwitch,
|
|
15
|
+
SPEAKER_EMPHASIS_STYLES,
|
|
16
|
+
parseSpeakerEmphasisStyle,
|
|
17
|
+
isKnownSpeakerEmphasisStyle,
|
|
18
|
+
isEdlTargetAspect,
|
|
19
|
+
speakerPresentationWarnings,
|
|
20
|
+
} from "../speaker-layouts.js"
|
|
21
|
+
|
|
22
|
+
describe("SPEAKER_LAYOUTS registry", () => {
|
|
23
|
+
it("has unique ids, and SPEAKER_LAYOUT_IDS is derived from it", () => {
|
|
24
|
+
expect(new Set(SPEAKER_LAYOUT_IDS).size).toBe(SPEAKER_LAYOUT_IDS.length)
|
|
25
|
+
expect(SPEAKER_LAYOUT_IDS).toEqual(SPEAKER_LAYOUTS.map((l) => l.id))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("every sheet has 1 ≤ minSlots ≤ maxSlots", () => {
|
|
29
|
+
for (const l of SPEAKER_LAYOUTS) {
|
|
30
|
+
expect(l.minSlots, l.id).toBeGreaterThanOrEqual(1)
|
|
31
|
+
expect(l.minSlots, l.id).toBeLessThanOrEqual(l.maxSlots)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("every sheet's aspects are non-empty, unique, and ⊆ EDL_TARGET_ASPECTS", () => {
|
|
36
|
+
for (const l of SPEAKER_LAYOUTS) {
|
|
37
|
+
expect(l.aspects.length, l.id).toBeGreaterThan(0)
|
|
38
|
+
expect(new Set(l.aspects).size, l.id).toBe(l.aspects.length)
|
|
39
|
+
for (const a of l.aspects) expect(EDL_TARGET_ASPECTS as readonly string[], l.id).toContain(a)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("pins the v1 layout set and the NARROW honest aspect list (aspects a renderer is drawn for; widen additively only)", () => {
|
|
44
|
+
const pinned = Object.fromEntries(
|
|
45
|
+
SPEAKER_LAYOUTS.map((l) => [l.id, { minSlots: l.minSlots, maxSlots: l.maxSlots, aspects: [...l.aspects] }]),
|
|
46
|
+
)
|
|
47
|
+
const all = ["16:9", "9:16", "1:1", "4:5"]
|
|
48
|
+
expect(pinned).toEqual({
|
|
49
|
+
single: { minSlots: 1, maxSlots: 1, aspects: all },
|
|
50
|
+
"side-by-side": { minSlots: 2, maxSlots: 2, aspects: ["16:9", "1:1"] },
|
|
51
|
+
stacked: { minSlots: 2, maxSlots: 2, aspects: ["9:16", "4:5", "1:1"] },
|
|
52
|
+
grid: { minSlots: 2, maxSlots: 6, aspects: all },
|
|
53
|
+
pip: { minSlots: 2, maxSlots: 2, aspects: all },
|
|
54
|
+
})
|
|
55
|
+
expect([...EDL_TARGET_ASPECTS]).toEqual(all)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("getSpeakerLayout looks up by id and returns undefined for an unknown id", () => {
|
|
59
|
+
expect(getSpeakerLayout("grid")?.maxSlots).toBe(6)
|
|
60
|
+
expect(getSpeakerLayout("carousel")).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("speakerLayoutAllows", () => {
|
|
65
|
+
const sbs = getSpeakerLayout("side-by-side")!
|
|
66
|
+
const grid = getSpeakerLayout("grid")!
|
|
67
|
+
|
|
68
|
+
it("checks the aspect against the sheet's drawn aspects", () => {
|
|
69
|
+
expect(speakerLayoutAllows(sbs, { aspect: "16:9" })).toBe(true)
|
|
70
|
+
expect(speakerLayoutAllows(sbs, { aspect: "9:16" })).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("checks the slot count against the inclusive [minSlots, maxSlots] range", () => {
|
|
74
|
+
expect(speakerLayoutAllows(grid, { slotCount: 2 })).toBe(true)
|
|
75
|
+
expect(speakerLayoutAllows(grid, { slotCount: 6 })).toBe(true)
|
|
76
|
+
expect(speakerLayoutAllows(grid, { slotCount: 1 })).toBe(false)
|
|
77
|
+
expect(speakerLayoutAllows(grid, { slotCount: 7 })).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("treats omitted query fields as unconstrained, and needs BOTH to pass when both are given", () => {
|
|
81
|
+
expect(speakerLayoutAllows(sbs, {})).toBe(true)
|
|
82
|
+
expect(speakerLayoutAllows(sbs, { aspect: "1:1", slotCount: 2 })).toBe(true)
|
|
83
|
+
expect(speakerLayoutAllows(sbs, { aspect: "1:1", slotCount: 3 })).toBe(false)
|
|
84
|
+
expect(speakerLayoutAllows(sbs, { aspect: "4:5", slotCount: 2 })).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("SPEAKER_SWITCHES registry", () => {
|
|
89
|
+
const xfadeFamily = COMBINE_TRANSITIONS.filter((t) => t.xfade !== null)
|
|
90
|
+
|
|
91
|
+
it("has unique ids, and SPEAKER_SWITCH_IDS is derived from it", () => {
|
|
92
|
+
expect(new Set(SPEAKER_SWITCH_IDS).size).toBe(SPEAKER_SWITCH_IDS.length)
|
|
93
|
+
expect(SPEAKER_SWITCH_IDS).toEqual(SPEAKER_SWITCHES.map((s) => s.id))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("totality: every combine transition with an xfade appears as xfade:<id>, xfade:cut never does, count = cut/pan/zoom + that family", () => {
|
|
97
|
+
for (const t of xfadeFamily) expect(SPEAKER_SWITCH_IDS).toContain(`xfade:${t.id}`)
|
|
98
|
+
expect(SPEAKER_SWITCH_IDS).not.toContain("xfade:cut")
|
|
99
|
+
expect(getSpeakerSwitch("xfade:cut")).toBeUndefined()
|
|
100
|
+
expect(SPEAKER_SWITCH_IDS.slice(0, 3)).toEqual(["cut", "pan", "zoom"])
|
|
101
|
+
expect(SPEAKER_SWITCHES.length).toBe(3 + xfadeFamily.length)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("the source file hand-lists no xfade:<id> literal (the family is DERIVED) and states the prefix exactly once", () => {
|
|
105
|
+
const text = readFileSync(new URL("../speaker-layouts.ts", import.meta.url), "utf8")
|
|
106
|
+
// A quoted/templated string that starts with the prefix and continues with an
|
|
107
|
+
// id character is a hand-listed switch id.
|
|
108
|
+
expect(text.match(/["'`]xfade:[a-z0-9]/gi)).toBeNull()
|
|
109
|
+
// The overlap rule's prefix is the ONE bare literal.
|
|
110
|
+
expect(text.match(/"xfade:"/g)).toHaveLength(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("every sheet's overlaps === speakerSwitchOverlaps(id): the xfade family overlaps, cut/pan/zoom do not", () => {
|
|
114
|
+
for (const s of SPEAKER_SWITCHES) expect(s.overlaps, s.id).toBe(speakerSwitchOverlaps(s.id))
|
|
115
|
+
for (const id of ["cut", "pan", "zoom"]) expect(getSpeakerSwitch(id)?.overlaps, id).toBe(false)
|
|
116
|
+
for (const t of xfadeFamily) expect(getSpeakerSwitch(`xfade:${t.id}`)?.overlaps, t.id).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("requiresSameSource is true only for pan", () => {
|
|
120
|
+
expect(SPEAKER_SWITCHES.filter((s) => s.requiresSameSource).map((s) => s.id)).toEqual(["pan"])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("an UNKNOWN xfade:bogus still overlaps under the D17 rule (edlDurationMs subtracts it) though getSpeakerSwitch returns undefined — an executor refuses it, the clock never guesses", () => {
|
|
124
|
+
expect(getSpeakerSwitch("xfade:bogus")).toBeUndefined()
|
|
125
|
+
expect(speakerSwitchOverlaps("xfade:bogus")).toBe(true)
|
|
126
|
+
const edl: Edl = {
|
|
127
|
+
version: 1,
|
|
128
|
+
clock: "master",
|
|
129
|
+
sources: [{ id: "a", url: "u", kind: "video", role: "master-audio" }],
|
|
130
|
+
segments: [
|
|
131
|
+
{ id: "s0", inMs: 0, outMs: 4000, video: "a" },
|
|
132
|
+
{ id: "s1", inMs: 4000, outMs: 8000, video: "a", layout: { mode: "single", transition: { type: "xfade:bogus", durationMs: 500 } } },
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
expect(edlDurationMs(edl)).toBe(7500)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("getSpeakerSwitch is a plain lookup that never throws on an unknown id", () => {
|
|
139
|
+
expect(getSpeakerSwitch("pan")?.requiresSameSource).toBe(true)
|
|
140
|
+
expect(() => getSpeakerSwitch("wipe")).not.toThrow()
|
|
141
|
+
expect(getSpeakerSwitch("wipe")).toBeUndefined()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("emphasis styles", () => {
|
|
146
|
+
it("are atomic ids (no '+' inside an id)", () => {
|
|
147
|
+
expect([...SPEAKER_EMPHASIS_STYLES]).toEqual(["none", "scale", "border", "dim"])
|
|
148
|
+
for (const s of SPEAKER_EMPHASIS_STYLES) expect(s).not.toContain("+")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("parseSpeakerEmphasisStyle splits on '+', trims, drops empties", () => {
|
|
152
|
+
expect(parseSpeakerEmphasisStyle("scale+border")).toEqual(["scale", "border"])
|
|
153
|
+
expect(parseSpeakerEmphasisStyle(" scale + dim ")).toEqual(["scale", "dim"])
|
|
154
|
+
expect(parseSpeakerEmphasisStyle("scale++border+")).toEqual(["scale", "border"])
|
|
155
|
+
expect(parseSpeakerEmphasisStyle("")).toEqual([])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("isKnownSpeakerEmphasisStyle needs ≥1 atom and every atom known", () => {
|
|
159
|
+
expect(isKnownSpeakerEmphasisStyle("scale+border")).toBe(true)
|
|
160
|
+
expect(isKnownSpeakerEmphasisStyle(" scale + dim ")).toBe(true)
|
|
161
|
+
expect(isKnownSpeakerEmphasisStyle("none")).toBe(true)
|
|
162
|
+
expect(isKnownSpeakerEmphasisStyle("scale+glow")).toBe(false)
|
|
163
|
+
expect(isKnownSpeakerEmphasisStyle("")).toBe(false)
|
|
164
|
+
expect(isKnownSpeakerEmphasisStyle(" + ")).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("isEdlTargetAspect narrows an open targetAspect to a known aspect", () => {
|
|
168
|
+
expect(isEdlTargetAspect("9:16")).toBe(true)
|
|
169
|
+
expect(isEdlTargetAspect("21:9")).toBe(false)
|
|
170
|
+
expect(isEdlTargetAspect(null)).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("isKnownSpeakerEmphasisStyle: `none` stands alone and no atom repeats", () => {
|
|
174
|
+
expect(isKnownSpeakerEmphasisStyle("none+scale")).toBe(false)
|
|
175
|
+
expect(isKnownSpeakerEmphasisStyle("scale+scale")).toBe(false)
|
|
176
|
+
expect(isKnownSpeakerEmphasisStyle("scale+border+dim")).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe("speakerPresentationWarnings — pure, never throws", () => {
|
|
181
|
+
it("returns [] for an EDL with no layouts and no target aspect", () => {
|
|
182
|
+
const edl: Edl = {
|
|
183
|
+
version: 1,
|
|
184
|
+
clock: "master",
|
|
185
|
+
sources: [{ id: "a", url: "u", kind: "video" }],
|
|
186
|
+
segments: [{ id: "s0", inMs: 0, outMs: 1000, video: "a" }],
|
|
187
|
+
}
|
|
188
|
+
expect(speakerPresentationWarnings(edl)).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("guards a raw object that skipped normalizeEdl (non-array sources/segments, garbage layout fields)", () => {
|
|
192
|
+
const garbage = [
|
|
193
|
+
{},
|
|
194
|
+
{ segments: "nope", sources: 7 },
|
|
195
|
+
{ segments: [null, { id: "s1", layout: { mode: 3, transition: { type: 9 }, emphasis: { style: null } } }], sources: null },
|
|
196
|
+
{ meta: "x", segments: [{ id: "s0", layout: { mode: "pan", slots: "x" } }] },
|
|
197
|
+
]
|
|
198
|
+
for (const g of garbage) {
|
|
199
|
+
expect(() => speakerPresentationWarnings(g as unknown as Edl)).not.toThrow()
|
|
200
|
+
}
|
|
201
|
+
expect(speakerPresentationWarnings(null as unknown as Edl)).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
TRANSCRIBE_PROVIDERS,
|
|
4
|
+
TRANSCRIBE_LANES,
|
|
5
|
+
TRANSCRIBE_PROVIDER_CAPABILITIES,
|
|
6
|
+
transcribeProvidersWithWordTimestamps,
|
|
7
|
+
transcribeLaneSupportsWordTimestamps,
|
|
8
|
+
DEFAULT_TRANSCRIBE_PROVIDER,
|
|
9
|
+
DEFAULT_TRANSCRIBE_NODE_PROVIDER,
|
|
10
|
+
} from "../model-constants.js"
|
|
11
|
+
|
|
12
|
+
describe("transcribe lane capabilities", () => {
|
|
13
|
+
it("every lane has a capability entry", () => {
|
|
14
|
+
const missing = TRANSCRIBE_LANES.filter((p) => TRANSCRIBE_PROVIDER_CAPABILITIES[p] === undefined)
|
|
15
|
+
expect(missing, `lanes without a capability row: ${missing.join(", ")}`).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("every TRANSCRIBE_PROVIDERS member is a lane with a capability entry", () => {
|
|
19
|
+
// The user-facing enum is a SUBSET of the lanes. A provider a caller can
|
|
20
|
+
// name but whose capabilities are unknown is the bug this pins.
|
|
21
|
+
const lanes = new Set<string>(TRANSCRIBE_LANES)
|
|
22
|
+
const orphans = TRANSCRIBE_PROVIDERS.filter((p) => !lanes.has(p))
|
|
23
|
+
expect(orphans, `TRANSCRIBE_PROVIDERS members missing from TRANSCRIBE_LANES: ${orphans.join(", ")}`).toEqual([])
|
|
24
|
+
for (const p of TRANSCRIBE_PROVIDERS) {
|
|
25
|
+
expect(TRANSCRIBE_PROVIDER_CAPABILITIES[p]).toBeDefined()
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("the capability map has no entry that is not a lane", () => {
|
|
30
|
+
const lanes = new Set<string>(TRANSCRIBE_LANES)
|
|
31
|
+
const extra = Object.keys(TRANSCRIBE_PROVIDER_CAPABILITIES).filter((k) => !lanes.has(k))
|
|
32
|
+
expect(extra, `capability rows for unknown lanes: ${extra.join(", ")}`).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("openai/whisper cannot do word timestamps; the other two can", () => {
|
|
36
|
+
// Not a taste call: openai/whisper on Replicate has no `word_timestamps`
|
|
37
|
+
// input in any published version, so the key is silently dropped.
|
|
38
|
+
expect(TRANSCRIBE_PROVIDER_CAPABILITIES.whisper.wordTimestamps).toBe(false)
|
|
39
|
+
expect(TRANSCRIBE_PROVIDER_CAPABILITIES["incredibly-fast-whisper"].wordTimestamps).toBe(true)
|
|
40
|
+
expect(TRANSCRIBE_PROVIDER_CAPABILITIES["elevenlabs-stt"].wordTimestamps).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("transcribeProvidersWithWordTimestamps derives from the capability map", () => {
|
|
44
|
+
expect(transcribeProvidersWithWordTimestamps()).toEqual(["incredibly-fast-whisper", "elevenlabs-stt"])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("the default lane is itself a lane", () => {
|
|
48
|
+
expect(TRANSCRIBE_LANES).toContain(DEFAULT_TRANSCRIBE_PROVIDER)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("the transcribe NODE default is a lane, is ENABLED, and is word-capable", () => {
|
|
52
|
+
// The canvas node and the DAG both resolve an absent provider to this one,
|
|
53
|
+
// so it has to be a lane the route still accepts AND one that can answer the
|
|
54
|
+
// word-timings request a wired `json` handle infers.
|
|
55
|
+
expect(TRANSCRIBE_LANES).toContain(DEFAULT_TRANSCRIBE_NODE_PROVIDER)
|
|
56
|
+
expect(TRANSCRIBE_PROVIDERS as readonly string[]).toContain(DEFAULT_TRANSCRIBE_NODE_PROVIDER)
|
|
57
|
+
expect(TRANSCRIBE_PROVIDER_CAPABILITIES[DEFAULT_TRANSCRIBE_NODE_PROVIDER].wordTimestamps).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("transcribeLaneSupportsWordTimestamps — untrusted lane ids", () => {
|
|
62
|
+
it("answers the capability table for every known lane", () => {
|
|
63
|
+
for (const lane of TRANSCRIBE_LANES) {
|
|
64
|
+
expect(transcribeLaneSupportsWordTimestamps(lane)).toBe(
|
|
65
|
+
TRANSCRIBE_PROVIDER_CAPABILITIES[lane].wordTimestamps,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("answers false for a lane it has never heard of", () => {
|
|
71
|
+
// Node data is untrusted: an imported workflow can carry any string. The
|
|
72
|
+
// caller must not index the capability table directly (TypeError) nor
|
|
73
|
+
// assume a capability it cannot verify.
|
|
74
|
+
expect(transcribeLaneSupportsWordTimestamps("deepgram")).toBe(false)
|
|
75
|
+
expect(transcribeLaneSupportsWordTimestamps("")).toBe(false)
|
|
76
|
+
expect(transcribeLaneSupportsWordTimestamps(undefined)).toBe(false)
|
|
77
|
+
expect(transcribeLaneSupportsWordTimestamps(null)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// The catalog is what /v1/models and MCP list_models publish. Every engine a
|
|
82
|
+
// caller may name must be listed there, and its "word-timestamps" feature must
|
|
83
|
+
// agree with the capability table — the table is the authority, the catalog a
|
|
84
|
+
// published copy that this test keeps honest.
|
|
85
|
+
describe("MODEL_CATALOG ↔ transcribe capabilities", () => {
|
|
86
|
+
it("lists every nameable transcribe engine as an stt model", async () => {
|
|
87
|
+
const { MODEL_CATALOG } = await import("../model-catalog.js")
|
|
88
|
+
const { TRANSCRIBE_PROVIDERS } = await import("../model-constants.js")
|
|
89
|
+
for (const id of TRANSCRIBE_PROVIDERS) {
|
|
90
|
+
const entry = (MODEL_CATALOG as Record<string, { modes?: readonly string[] } | undefined>)[id]
|
|
91
|
+
expect(entry, `${id} missing from MODEL_CATALOG`).toBeDefined()
|
|
92
|
+
expect(entry!.modes, id).toContain("stt")
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('the catalog "word-timestamps" feature matches TRANSCRIBE_PROVIDER_CAPABILITIES', async () => {
|
|
97
|
+
const { MODEL_CATALOG } = await import("../model-catalog.js")
|
|
98
|
+
const { TRANSCRIBE_LANES, TRANSCRIBE_PROVIDER_CAPABILITIES } = await import("../model-constants.js")
|
|
99
|
+
for (const id of TRANSCRIBE_LANES) {
|
|
100
|
+
const features = (MODEL_CATALOG as Record<string, { features?: readonly string[] } | undefined>)[id]?.features ?? []
|
|
101
|
+
expect(features.includes("word-timestamps"), id).toBe(TRANSCRIBE_PROVIDER_CAPABILITIES[id].wordTimestamps)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { findWordlessTranscriptFeeds, transcribeWordTimestampsRefusal } from "../transcribe-preflight.js"
|
|
3
|
+
|
|
4
|
+
const n = (id: string, type: string, data: Record<string, unknown> = {}) => ({ id, type, data })
|
|
5
|
+
const e = (source: string, sourceHandle: string, target: string, targetHandle: string) => ({ source, sourceHandle, target, targetHandle })
|
|
6
|
+
|
|
7
|
+
describe("transcribeWordTimestampsRefusal", () => {
|
|
8
|
+
it("null for the word-capable lanes and for an absent provider (node default = elevenlabs-stt)", () => {
|
|
9
|
+
expect(transcribeWordTimestampsRefusal("elevenlabs-stt")).toBeNull()
|
|
10
|
+
expect(transcribeWordTimestampsRefusal("incredibly-fast-whisper")).toBeNull()
|
|
11
|
+
expect(transcribeWordTimestampsRefusal(undefined)).toBeNull()
|
|
12
|
+
expect(transcribeWordTimestampsRefusal("")).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
it("names the lane and the capable alternatives for whisper / an unknown lane", () => {
|
|
15
|
+
const msg = transcribeWordTimestampsRefusal("whisper")!
|
|
16
|
+
expect(msg).toContain('"whisper"')
|
|
17
|
+
expect(msg).toContain("elevenlabs-stt")
|
|
18
|
+
expect(msg).toContain("incredibly-fast-whisper")
|
|
19
|
+
expect(transcribeWordTimestampsRefusal("made-up-lane")).toContain('"made-up-lane"')
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("findWordlessTranscriptFeeds", () => {
|
|
24
|
+
it("flags whisper.json → add-captions.transcript", () => {
|
|
25
|
+
const hits = findWordlessTranscriptFeeds(
|
|
26
|
+
[n("t", "transcribe", { provider: "whisper" }), n("c", "add-captions")],
|
|
27
|
+
[e("t", "json", "c", "transcript")],
|
|
28
|
+
)
|
|
29
|
+
expect(hits).toHaveLength(1)
|
|
30
|
+
expect(hits[0]).toMatchObject({ transcribeNodeId: "t", consumerNodeId: "c", provider: "whisper" })
|
|
31
|
+
expect(hits[0]!.message).toContain("word timings")
|
|
32
|
+
})
|
|
33
|
+
it("follows the transcript THROUGH apply-edl (transcript in → json out)", () => {
|
|
34
|
+
const hits = findWordlessTranscriptFeeds(
|
|
35
|
+
[n("t", "transcribe", { provider: "whisper" }), n("x", "apply-edl"), n("c", "add-captions")],
|
|
36
|
+
[e("t", "json", "x", "transcript"), e("x", "json", "c", "transcript")],
|
|
37
|
+
)
|
|
38
|
+
expect(hits.map((h) => h.consumerNodeId)).toEqual(["c"])
|
|
39
|
+
})
|
|
40
|
+
it("does NOT flag a word-capable lane, the text handle, or a non-caption consumer", () => {
|
|
41
|
+
const nodes = [n("ok", "transcribe", { provider: "elevenlabs-stt" }), n("w", "transcribe", { provider: "whisper" }), n("c", "add-captions"), n("p", "edit-plan")]
|
|
42
|
+
expect(findWordlessTranscriptFeeds(nodes, [e("ok", "json", "c", "transcript")])).toEqual([])
|
|
43
|
+
expect(findWordlessTranscriptFeeds(nodes, [e("w", "text", "c", "in")])).toEqual([])
|
|
44
|
+
expect(findWordlessTranscriptFeeds(nodes, [e("w", "json", "p", "transcript")])).toEqual([]) // edit-plan works off segments
|
|
45
|
+
})
|
|
46
|
+
it("ignores skipped nodes on either end, and survives an apply-edl cycle", () => {
|
|
47
|
+
expect(findWordlessTranscriptFeeds(
|
|
48
|
+
[n("t", "transcribe", { provider: "whisper", skipped: true }), n("c", "add-captions")],
|
|
49
|
+
[e("t", "json", "c", "transcript")],
|
|
50
|
+
)).toEqual([])
|
|
51
|
+
expect(findWordlessTranscriptFeeds(
|
|
52
|
+
[n("t", "transcribe", { provider: "whisper" }), n("c", "add-captions", { skipped: true })],
|
|
53
|
+
[e("t", "json", "c", "transcript")],
|
|
54
|
+
)).toEqual([])
|
|
55
|
+
expect(findWordlessTranscriptFeeds(
|
|
56
|
+
[n("t", "transcribe", { provider: "whisper" }), n("a", "apply-edl"), n("b", "apply-edl")],
|
|
57
|
+
[e("t", "json", "a", "transcript"), e("a", "json", "b", "transcript"), e("b", "json", "a", "transcript")],
|
|
58
|
+
)).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
})
|