@xiboplayer/schedule 0.6.0 → 0.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/schedule",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  "./overlays": "./src/overlays.js"
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/utils": "0.6.0"
15
+ "@xiboplayer/utils": "0.6.2"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
package/src/index.d.ts CHANGED
@@ -15,5 +15,5 @@ export class ScheduleManager {
15
15
  export const scheduleManager: ScheduleManager;
16
16
 
17
17
  export function calculateTimeline(layouts: any[], durations: Map<string, number>, options?: any): any[];
18
- export function parseLayoutDuration(xlf: string): number;
18
+ export function parseLayoutDuration(xlf: string, videoDurations?: Map<string, number> | null): { duration: number; isDynamic: boolean };
19
19
  export function buildScheduleQueue(schedule: any, durations: Map<string, number>): any[];
package/src/timeline.js CHANGED
@@ -10,15 +10,22 @@
10
10
  * Parse layout duration from XLF XML string.
11
11
  * Lightweight parser — uses DOMParser, no rendering.
12
12
  *
13
+ * Single source of truth for XLF-based duration calculation.
14
+ * Supports a 3-phase progressive refinement pipeline:
15
+ * Phase 1 (ESTIMATE): parseLayoutDuration(xlf) — static duration from XLF
16
+ * Phase 2 (PROBE): parseLayoutDuration(xlf, videoDurations) — refined with real video lengths
17
+ * Phase 3 (LIVE UPDATE): renderer's updateLayoutDuration() — corrections from DURATION comments
18
+ *
13
19
  * Duration resolution order:
14
20
  * 1. Explicit <layout duration="60"> attribute
15
21
  * 2. Sum of widget <media duration="X"> per region (max across regions)
16
22
  * 3. Fallback: 60s
17
23
  *
18
24
  * @param {string} xlfXml - Raw XLF XML string
25
+ * @param {Map<string, number>|null} [videoDurations=null] - Optional map of fileId → probed duration in seconds
19
26
  * @returns {{ duration: number, isDynamic: boolean }} Duration in seconds and whether any widget has useDuration=0
20
27
  */
21
- export function parseLayoutDuration(xlfXml) {
28
+ export function parseLayoutDuration(xlfXml, videoDurations = null) {
22
29
  const doc = new DOMParser().parseFromString(xlfXml, 'text/xml');
23
30
  const layoutEl = doc.querySelector('layout');
24
31
  if (!layoutEl) return { duration: 60, isDynamic: false };
@@ -31,12 +38,18 @@ export function parseLayoutDuration(xlfXml) {
31
38
  let maxDuration = 0;
32
39
  let isDynamic = false;
33
40
  for (const regionEl of layoutEl.querySelectorAll('region')) {
41
+ if (regionEl.getAttribute('type') === 'drawer') continue; // Drawers are action-triggered, not timed
34
42
  let regionDuration = 0;
35
43
  for (const mediaEl of regionEl.querySelectorAll('media')) {
36
44
  const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
37
45
  const useDuration = parseInt(mediaEl.getAttribute('useDuration') || '1', 10);
38
- if (dur > 0 && useDuration !== 0) {
39
- regionDuration += dur;
46
+ const fileId = mediaEl.getAttribute('fileId') || '';
47
+ const probed = videoDurations?.get(fileId);
48
+
49
+ if (probed !== undefined) {
50
+ regionDuration += probed; // Phase 2: probed video duration
51
+ } else if (dur > 0 && useDuration !== 0) {
52
+ regionDuration += dur; // Explicit CMS duration
40
53
  } else {
41
54
  // Video with useDuration=0 means "play to end" — estimate 60s,
42
55
  // corrected later via recordLayoutDuration() when video metadata loads
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Timeline Calculator Tests
3
+ *
4
+ * Tests for calculateTimeline() — the pure simulation function that produces
5
+ * deterministic playback predictions from schedule + durations.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { calculateTimeline, parseLayoutDuration } from './timeline.js';
10
+
11
+ // ── Helpers ──────────────────────────────────────────────────────
12
+
13
+ /** Create a mock schedule with getAllLayoutsAtTime() support */
14
+ function createMockSchedule({ layouts = [], defaultLayout = null, playHistory = null } = {}) {
15
+ return {
16
+ schedule: { default: defaultLayout },
17
+ playHistory: playHistory || new Map(),
18
+ getAllLayoutsAtTime(time) {
19
+ const t = time.getTime();
20
+ return layouts.filter(l => {
21
+ const from = new Date(l.fromdt).getTime();
22
+ const to = new Date(l.todt).getTime();
23
+ return t >= from && t < to;
24
+ });
25
+ },
26
+ getLayoutsAtTime(time) {
27
+ return this.getAllLayoutsAtTime(time).map(l => l.file);
28
+ },
29
+ };
30
+ }
31
+
32
+ function hoursFromNow(h) {
33
+ return new Date(Date.now() + h * 3600000).toISOString();
34
+ }
35
+
36
+ // Fixed "now" for deterministic tests
37
+ const NOW = new Date('2026-03-03T10:00:00Z');
38
+
39
+ function fixedDate(isoTime) {
40
+ return new Date(isoTime);
41
+ }
42
+
43
+ // ── Tests ────────────────────────────────────────────────────────
44
+
45
+ describe('calculateTimeline', () => {
46
+ describe('Basic scheduling', () => {
47
+ it('should produce entries for a single scheduled layout', () => {
48
+ const schedule = createMockSchedule({
49
+ layouts: [{
50
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
51
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
52
+ }],
53
+ });
54
+ const durations = new Map([['100.xlf', 30]]);
55
+
56
+ const timeline = calculateTimeline(schedule, durations, {
57
+ from: NOW, hours: 1,
58
+ });
59
+
60
+ expect(timeline.length).toBeGreaterThan(0);
61
+ expect(timeline[0].layoutFile).toBe('100.xlf');
62
+ expect(timeline[0].duration).toBe(30);
63
+ expect(timeline[0].isDefault).toBe(false);
64
+ });
65
+
66
+ it('should use default layout when no scheduled layouts exist', () => {
67
+ const schedule = createMockSchedule({
68
+ layouts: [],
69
+ defaultLayout: 'default.xlf',
70
+ });
71
+ const durations = new Map([['default.xlf', 45]]);
72
+
73
+ const timeline = calculateTimeline(schedule, durations, {
74
+ from: NOW, hours: 0.1,
75
+ });
76
+
77
+ expect(timeline.length).toBeGreaterThan(0);
78
+ expect(timeline[0].layoutFile).toBe('default.xlf');
79
+ expect(timeline[0].isDefault).toBe(true);
80
+ expect(timeline[0].duration).toBe(45);
81
+ });
82
+
83
+ it('should use fallback duration when layout not in durations map', () => {
84
+ const schedule = createMockSchedule({
85
+ layouts: [{
86
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
87
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
88
+ }],
89
+ });
90
+ const durations = new Map(); // No durations known
91
+
92
+ const timeline = calculateTimeline(schedule, durations, {
93
+ from: NOW, hours: 0.1, defaultDuration: 42,
94
+ });
95
+
96
+ expect(timeline[0].duration).toBe(42);
97
+ });
98
+
99
+ it('should round-robin multiple layouts at same priority', () => {
100
+ const schedule = createMockSchedule({
101
+ layouts: [
102
+ { file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
103
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
104
+ { file: '200.xlf', priority: 10, maxPlaysPerHour: 0,
105
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
106
+ ],
107
+ });
108
+ const durations = new Map([['100.xlf', 30], ['200.xlf', 30]]);
109
+
110
+ const timeline = calculateTimeline(schedule, durations, {
111
+ from: NOW, hours: 0.1,
112
+ });
113
+
114
+ // Both layouts should appear in the timeline
115
+ const files = timeline.map(e => e.layoutFile);
116
+ expect(files).toContain('100.xlf');
117
+ expect(files).toContain('200.xlf');
118
+ });
119
+ });
120
+
121
+ describe('Priority handling', () => {
122
+ it('should play higher priority layout over lower priority', () => {
123
+ const schedule = createMockSchedule({
124
+ layouts: [
125
+ { file: 'low.xlf', priority: 5, maxPlaysPerHour: 0,
126
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
127
+ { file: 'high.xlf', priority: 10, maxPlaysPerHour: 0,
128
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
129
+ ],
130
+ });
131
+ const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
132
+
133
+ const timeline = calculateTimeline(schedule, durations, {
134
+ from: NOW, hours: 0.5,
135
+ });
136
+
137
+ // All entries should be high priority
138
+ const uniqueFiles = [...new Set(timeline.map(e => e.layoutFile))];
139
+ expect(uniqueFiles).toEqual(['high.xlf']);
140
+ });
141
+
142
+ it('should annotate hidden (overshadowed) layouts', () => {
143
+ const schedule = createMockSchedule({
144
+ layouts: [
145
+ { file: 'low.xlf', priority: 5, maxPlaysPerHour: 0,
146
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
147
+ { file: 'high.xlf', priority: 10, maxPlaysPerHour: 0,
148
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
149
+ ],
150
+ });
151
+ const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
152
+
153
+ const timeline = calculateTimeline(schedule, durations, {
154
+ from: NOW, hours: 0.1,
155
+ });
156
+
157
+ // First entry should have hidden layouts
158
+ expect(timeline[0].hidden).toBeDefined();
159
+ expect(timeline[0].hidden).toEqual(
160
+ expect.arrayContaining([expect.objectContaining({ file: 'low.xlf' })])
161
+ );
162
+ });
163
+ });
164
+
165
+ describe('Rate limiting (maxPlaysPerHour)', () => {
166
+ it('should respect maxPlaysPerHour by falling back to lower priority', () => {
167
+ const schedule = createMockSchedule({
168
+ layouts: [
169
+ { file: 'limited.xlf', priority: 10, maxPlaysPerHour: 2,
170
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
171
+ { file: 'filler.xlf', priority: 5, maxPlaysPerHour: 0,
172
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
173
+ ],
174
+ });
175
+ const durations = new Map([['limited.xlf', 30], ['filler.xlf', 30]]);
176
+
177
+ const timeline = calculateTimeline(schedule, durations, {
178
+ from: NOW, hours: 1,
179
+ });
180
+
181
+ // limited.xlf should appear at most 2 times in the hour
182
+ const limitedPlays = timeline.filter(e => e.layoutFile === 'limited.xlf');
183
+ expect(limitedPlays.length).toBeLessThanOrEqual(2);
184
+
185
+ // filler.xlf should fill the gaps
186
+ const fillerPlays = timeline.filter(e => e.layoutFile === 'filler.xlf');
187
+ expect(fillerPlays.length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('should fall back to default when all layouts are rate-limited', () => {
191
+ const schedule = createMockSchedule({
192
+ layouts: [
193
+ { file: 'limited.xlf', priority: 10, maxPlaysPerHour: 1,
194
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
195
+ ],
196
+ defaultLayout: 'default.xlf',
197
+ });
198
+ const durations = new Map([['limited.xlf', 30], ['default.xlf', 30]]);
199
+
200
+ const timeline = calculateTimeline(schedule, durations, {
201
+ from: NOW, hours: 1,
202
+ });
203
+
204
+ // Should have both limited and default layouts
205
+ const files = [...new Set(timeline.map(e => e.layoutFile))];
206
+ expect(files).toContain('limited.xlf');
207
+ expect(files).toContain('default.xlf');
208
+
209
+ // limited.xlf at most once per hour
210
+ const limitedPlays = timeline.filter(e => e.layoutFile === 'limited.xlf');
211
+ expect(limitedPlays.length).toBeLessThanOrEqual(1);
212
+ });
213
+ });
214
+
215
+ describe('Time boundaries', () => {
216
+ it('should stop at schedule end time', () => {
217
+ const schedule = createMockSchedule({
218
+ layouts: [{
219
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
220
+ fromdt: '2026-03-03T10:00:00Z', todt: '2026-03-03T10:30:00Z',
221
+ }],
222
+ defaultLayout: 'default.xlf',
223
+ });
224
+ const durations = new Map([['100.xlf', 60], ['default.xlf', 60]]);
225
+
226
+ const timeline = calculateTimeline(schedule, durations, {
227
+ from: NOW, hours: 1,
228
+ });
229
+
230
+ // After 10:30, should switch to default
231
+ const afterEnd = timeline.filter(e =>
232
+ e.startTime >= new Date('2026-03-03T10:30:00Z')
233
+ );
234
+ for (const entry of afterEnd) {
235
+ expect(entry.layoutFile).toBe('default.xlf');
236
+ }
237
+ });
238
+
239
+ it('should not produce entries beyond the simulation window', () => {
240
+ const schedule = createMockSchedule({
241
+ layouts: [{
242
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
243
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T14:00:00Z',
244
+ }],
245
+ });
246
+ const durations = new Map([['100.xlf', 30]]);
247
+
248
+ const timeline = calculateTimeline(schedule, durations, {
249
+ from: NOW, hours: 1,
250
+ });
251
+
252
+ const endOfWindow = new Date(NOW.getTime() + 3600000);
253
+ for (const entry of timeline) {
254
+ expect(entry.startTime.getTime()).toBeLessThan(endOfWindow.getTime());
255
+ }
256
+ });
257
+ });
258
+
259
+ describe('currentLayoutStartedAt (remaining time adjustment)', () => {
260
+ it('should adjust first entry duration to remaining time', () => {
261
+ const schedule = createMockSchedule({
262
+ layouts: [{
263
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
264
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
265
+ }],
266
+ });
267
+ const durations = new Map([['100.xlf', 60]]);
268
+
269
+ // Layout started 20 seconds ago → 40 seconds remaining
270
+ const startedAt = new Date(NOW.getTime() - 20000);
271
+
272
+ const timeline = calculateTimeline(schedule, durations, {
273
+ from: NOW, hours: 0.5,
274
+ currentLayoutStartedAt: startedAt,
275
+ });
276
+
277
+ expect(timeline[0].duration).toBe(40); // 60 - 20 = 40
278
+ });
279
+
280
+ it('should clamp remaining time to at least 1 second', () => {
281
+ const schedule = createMockSchedule({
282
+ layouts: [{
283
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
284
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
285
+ }],
286
+ });
287
+ const durations = new Map([['100.xlf', 30]]);
288
+
289
+ // Layout started 60 seconds ago but duration is only 30 → already overdue
290
+ const startedAt = new Date(NOW.getTime() - 60000);
291
+
292
+ const timeline = calculateTimeline(schedule, durations, {
293
+ from: NOW, hours: 0.1,
294
+ currentLayoutStartedAt: startedAt,
295
+ });
296
+
297
+ expect(timeline[0].duration).toBeGreaterThanOrEqual(1);
298
+ });
299
+ });
300
+
301
+ describe('Determinism', () => {
302
+ it('should produce identical output for identical inputs', () => {
303
+ const schedule = createMockSchedule({
304
+ layouts: [
305
+ { file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
306
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
307
+ { file: '200.xlf', priority: 10, maxPlaysPerHour: 0,
308
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
309
+ ],
310
+ });
311
+ const durations = new Map([['100.xlf', 30], ['200.xlf', 45]]);
312
+
313
+ const t1 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
314
+ const t2 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
315
+
316
+ expect(t1.length).toBe(t2.length);
317
+ for (let i = 0; i < t1.length; i++) {
318
+ expect(t1[i].layoutFile).toBe(t2[i].layoutFile);
319
+ expect(t1[i].startTime.getTime()).toBe(t2[i].startTime.getTime());
320
+ expect(t1[i].endTime.getTime()).toBe(t2[i].endTime.getTime());
321
+ expect(t1[i].duration).toBe(t2[i].duration);
322
+ }
323
+ });
324
+
325
+ it('should produce DIFFERENT output when "from" time changes', () => {
326
+ const schedule = createMockSchedule({
327
+ layouts: [{
328
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
329
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
330
+ }],
331
+ });
332
+ const durations = new Map([['100.xlf', 30]]);
333
+
334
+ const t1 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
335
+ const laterNow = new Date(NOW.getTime() + 300000); // 5 min later
336
+ const t2 = calculateTimeline(schedule, durations, { from: laterNow, hours: 1 });
337
+
338
+ // Start times must differ because the anchor moved
339
+ expect(t1[0].startTime.getTime()).not.toBe(t2[0].startTime.getTime());
340
+ });
341
+ });
342
+
343
+ describe('Edge cases', () => {
344
+ it('should return empty array when no layouts and no default', () => {
345
+ const schedule = createMockSchedule({ layouts: [], defaultLayout: null });
346
+ const durations = new Map();
347
+
348
+ const timeline = calculateTimeline(schedule, durations, {
349
+ from: NOW, hours: 1,
350
+ });
351
+
352
+ expect(timeline).toEqual([]);
353
+ });
354
+
355
+ it('should handle a large number of entries without exceeding 500 cap', () => {
356
+ const schedule = createMockSchedule({
357
+ layouts: [{
358
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
359
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T20:00:00Z',
360
+ }],
361
+ });
362
+ const durations = new Map([['100.xlf', 5]]); // 5s = many entries
363
+
364
+ const timeline = calculateTimeline(schedule, durations, {
365
+ from: NOW, hours: 2,
366
+ });
367
+
368
+ expect(timeline.length).toBeLessThanOrEqual(500);
369
+ });
370
+
371
+ it('should produce continuous timeline (no gaps between entries)', () => {
372
+ const schedule = createMockSchedule({
373
+ layouts: [{
374
+ file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
375
+ fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
376
+ }],
377
+ });
378
+ const durations = new Map([['100.xlf', 30]]);
379
+
380
+ const timeline = calculateTimeline(schedule, durations, {
381
+ from: NOW, hours: 0.5,
382
+ });
383
+
384
+ for (let i = 1; i < timeline.length; i++) {
385
+ expect(timeline[i].startTime.getTime()).toBe(timeline[i - 1].endTime.getTime());
386
+ }
387
+ });
388
+ });
389
+ });
390
+
391
+ // ── parseLayoutDuration Tests ───────────────────────────────────
392
+
393
+ /** Helper to build minimal XLF strings */
394
+ function xlf({ duration = 0, regions = [] } = {}) {
395
+ const regionXml = regions.map(r => {
396
+ const type = r.type ? ` type="${r.type}"` : '';
397
+ const widgets = (r.widgets || []).map(w => {
398
+ const attrs = Object.entries(w).map(([k, v]) => `${k}="${v}"`).join(' ');
399
+ return `<media ${attrs}/>`;
400
+ }).join('');
401
+ return `<region${type}>${widgets}</region>`;
402
+ }).join('');
403
+ return `<layout duration="${duration}">${regionXml}</layout>`;
404
+ }
405
+
406
+ describe('parseLayoutDuration', () => {
407
+ describe('Basic duration parsing', () => {
408
+ it('should return explicit layout duration when set', () => {
409
+ const result = parseLayoutDuration(xlf({ duration: 120 }));
410
+ expect(result).toEqual({ duration: 120, isDynamic: false });
411
+ });
412
+
413
+ it('should fallback to 60s when no layout element exists', () => {
414
+ const result = parseLayoutDuration('<invalid/>');
415
+ expect(result).toEqual({ duration: 60, isDynamic: false });
416
+ });
417
+
418
+ it('should calculate max region duration from widgets', () => {
419
+ const result = parseLayoutDuration(xlf({
420
+ regions: [
421
+ { widgets: [
422
+ { duration: 30, useDuration: 1 },
423
+ { duration: 20, useDuration: 1 },
424
+ ]},
425
+ { widgets: [
426
+ { duration: 10, useDuration: 1 },
427
+ ]},
428
+ ],
429
+ }));
430
+ // Region 1: 30+20=50, Region 2: 10 → max=50
431
+ expect(result).toEqual({ duration: 50, isDynamic: false });
432
+ });
433
+
434
+ it('should estimate 60s for widgets with useDuration=0 and mark as dynamic', () => {
435
+ const result = parseLayoutDuration(xlf({
436
+ regions: [
437
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'v1' }] },
438
+ ],
439
+ }));
440
+ expect(result).toEqual({ duration: 60, isDynamic: true });
441
+ });
442
+
443
+ it('should fallback to 60s when all regions are empty', () => {
444
+ const result = parseLayoutDuration(xlf({ regions: [{ widgets: [] }] }));
445
+ expect(result).toEqual({ duration: 60, isDynamic: false });
446
+ });
447
+ });
448
+
449
+ describe('Drawer region skip', () => {
450
+ it('should skip drawer regions entirely', () => {
451
+ const result = parseLayoutDuration(xlf({
452
+ regions: [
453
+ { widgets: [{ duration: 30, useDuration: 1 }] },
454
+ { type: 'drawer', widgets: [{ duration: 300, useDuration: 1 }] },
455
+ ],
456
+ }));
457
+ // Only the non-drawer region counts: 30s
458
+ expect(result).toEqual({ duration: 30, isDynamic: false });
459
+ });
460
+
461
+ it('should fallback to 60s when only drawer regions exist', () => {
462
+ const result = parseLayoutDuration(xlf({
463
+ regions: [
464
+ { type: 'drawer', widgets: [{ duration: 120, useDuration: 1 }] },
465
+ ],
466
+ }));
467
+ expect(result).toEqual({ duration: 60, isDynamic: false });
468
+ });
469
+ });
470
+
471
+ describe('videoDurations (Phase 2 probing)', () => {
472
+ it('should use probed duration when fileId matches', () => {
473
+ const videoDurations = new Map([['vid1', 45]]);
474
+ const result = parseLayoutDuration(xlf({
475
+ regions: [
476
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'vid1' }] },
477
+ ],
478
+ }), videoDurations);
479
+ expect(result).toEqual({ duration: 45, isDynamic: false });
480
+ });
481
+
482
+ it('should still estimate 60s for unmatched fileIds', () => {
483
+ const videoDurations = new Map([['other', 45]]);
484
+ const result = parseLayoutDuration(xlf({
485
+ regions: [
486
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'vid1' }] },
487
+ ],
488
+ }), videoDurations);
489
+ expect(result).toEqual({ duration: 60, isDynamic: true });
490
+ });
491
+
492
+ it('should return same result when videoDurations has no matching fileIds', () => {
493
+ const without = parseLayoutDuration(xlf({
494
+ regions: [
495
+ { widgets: [
496
+ { duration: 30, useDuration: 1 },
497
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
498
+ ]},
499
+ ],
500
+ }));
501
+ const videoDurations = new Map([['no-match', 99]]);
502
+ const withMap = parseLayoutDuration(xlf({
503
+ regions: [
504
+ { widgets: [
505
+ { duration: 30, useDuration: 1 },
506
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
507
+ ]},
508
+ ],
509
+ }), videoDurations);
510
+ expect(withMap).toEqual(without);
511
+ });
512
+
513
+ it('should handle mixed: some probed, some static, some estimated', () => {
514
+ const videoDurations = new Map([['vid1', 90]]);
515
+ const result = parseLayoutDuration(xlf({
516
+ regions: [
517
+ { widgets: [
518
+ { duration: 30, useDuration: 1 }, // Static: 30s
519
+ { duration: 0, useDuration: 0, fileId: 'vid1' }, // Probed: 90s
520
+ { duration: 0, useDuration: 0, fileId: 'vid2' }, // Estimated: 60s
521
+ ]},
522
+ ],
523
+ }), videoDurations);
524
+ // 30 + 90 + 60 = 180, isDynamic=true because vid2 is unprobed
525
+ expect(result).toEqual({ duration: 180, isDynamic: true });
526
+ });
527
+
528
+ it('should not mark as dynamic when all videos are probed', () => {
529
+ const videoDurations = new Map([['vid1', 45], ['vid2', 30]]);
530
+ const result = parseLayoutDuration(xlf({
531
+ regions: [
532
+ { widgets: [
533
+ { duration: 10, useDuration: 1 },
534
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
535
+ { duration: 0, useDuration: 0, fileId: 'vid2' },
536
+ ]},
537
+ ],
538
+ }), videoDurations);
539
+ // 10 + 45 + 30 = 85, all resolved
540
+ expect(result).toEqual({ duration: 85, isDynamic: false });
541
+ });
542
+ });
543
+ });