@xiboplayer/schedule 0.6.1 → 0.6.3

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.1",
3
+ "version": "0.6.3",
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.1"
15
+ "@xiboplayer/utils": "0.6.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
package/src/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export class ScheduleManager {
14
14
 
15
15
  export const scheduleManager: ScheduleManager;
16
16
 
17
- export function calculateTimeline(layouts: any[], durations: Map<string, number>, options?: any): any[];
18
- export function parseLayoutDuration(xlf: string): number;
17
+ export function calculateTimeline(queue: Array<{layoutId: string, duration: number}>, queuePosition: number, options?: any): any[];
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[];
@@ -35,13 +35,8 @@ function getDifferentDay() {
35
35
 
36
36
  // Helper to mock Date at specific time
37
37
  function mockTimeAt(targetDate) {
38
- const RealDate = Date;
39
- vi.spyOn(global, 'Date').mockImplementation((...args) => {
40
- if (args.length === 0) {
41
- return new RealDate(targetDate);
42
- }
43
- return new RealDate(...args);
44
- });
38
+ vi.useFakeTimers({ shouldAdvanceTime: false });
39
+ vi.setSystemTime(new Date(targetDate));
45
40
  }
46
41
 
47
42
  describe('ScheduleManager - Dayparting', () => {
@@ -50,14 +45,10 @@ describe('ScheduleManager - Dayparting', () => {
50
45
 
51
46
  beforeEach(() => {
52
47
  manager = new ScheduleManager();
53
- originalDate = global.Date;
54
48
  });
55
49
 
56
50
  afterEach(() => {
57
- // Restore Date
58
- if (vi.isMockFunction(global.Date)) {
59
- global.Date = originalDate;
60
- }
51
+ vi.useRealTimers();
61
52
  });
62
53
 
63
54
  describe('Weekday Schedules', () => {
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,18 +38,34 @@ export function parseLayoutDuration(xlfXml) {
31
38
  let maxDuration = 0;
32
39
  let isDynamic = false;
33
40
  for (const regionEl of layoutEl.querySelectorAll('region')) {
41
+ const regionType = regionEl.getAttribute('type');
42
+ if (regionType === 'drawer') continue; // Drawers are action-triggered, not timed
43
+ const isCanvas = regionType === 'canvas';
34
44
  let regionDuration = 0;
35
45
  for (const mediaEl of regionEl.querySelectorAll('media')) {
36
46
  const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
37
47
  const useDuration = parseInt(mediaEl.getAttribute('useDuration') || '1', 10);
38
- if (dur > 0 && useDuration !== 0) {
39
- regionDuration += dur;
48
+ const fileId = mediaEl.getAttribute('fileId') || '';
49
+ const probed = videoDurations?.get(fileId);
50
+
51
+ let widgetDuration;
52
+ if (probed !== undefined) {
53
+ widgetDuration = probed; // Phase 2: probed video duration
54
+ } else if (dur > 0 && useDuration !== 0) {
55
+ widgetDuration = dur; // Explicit CMS duration
40
56
  } else {
41
57
  // Video with useDuration=0 means "play to end" — estimate 60s,
42
58
  // corrected later via recordLayoutDuration() when video metadata loads
43
- regionDuration += 60;
59
+ widgetDuration = 60;
44
60
  isDynamic = true;
45
61
  }
62
+
63
+ if (isCanvas) {
64
+ // Canvas regions play all widgets simultaneously — duration is max, not sum
65
+ regionDuration = Math.max(regionDuration, widgetDuration);
66
+ } else {
67
+ regionDuration += widgetDuration;
68
+ }
46
69
  }
47
70
  maxDuration = Math.max(maxDuration, regionDuration);
48
71
  }
@@ -97,23 +120,6 @@ function canSimulatedPlay(history, maxPlaysPerHour, timeMs) {
97
120
  return true;
98
121
  }
99
122
 
100
- /**
101
- * Seed simulated play history from real play history.
102
- * Maps layoutId-based history to layoutFile-based history.
103
- * @param {Map<string, number[]>} realHistory - schedule.playHistory (layoutId → [timestamps])
104
- * @returns {Map<string, number[]>} layoutFile → [timestamps]
105
- */
106
- function seedPlayHistory(realHistory) {
107
- const simulated = new Map();
108
- if (!realHistory) return simulated;
109
-
110
- for (const [layoutId, timestamps] of realHistory) {
111
- const file = `${layoutId}.xlf`;
112
- simulated.set(file, [...timestamps]);
113
- }
114
- return simulated;
115
- }
116
-
117
123
  /**
118
124
  * From a list of layout metadata, apply simulated rate limiting and priority
119
125
  * filtering to determine which layouts can actually play at the given time.
@@ -143,129 +149,63 @@ function getPlayableLayouts(allLayouts, simPlays, timeMs) {
143
149
  }
144
150
 
145
151
  /**
146
- * Calculate a deterministic playback timeline by simulating round-robin scheduling
147
- * with rate limiting (maxPlaysPerHour) and priority fallback. Produces a real
148
- * schedule prediction that matches actual player behavior.
152
+ * Calculate a deterministic playback timeline by walking the pre-built schedule queue.
149
153
  *
150
- * When high-priority layouts hit their maxPlaysPerHour limit, the simulation
151
- * falls back to lower-priority scheduled layouts before using the CMS default.
154
+ * The queue already has all constraints baked in (maxPlaysPerHour, priorities,
155
+ * dayparting, default layout fills). This function simply cycles through it from
156
+ * the current position, generating time-stamped entries for the overlay.
152
157
  *
153
- * @param {Object} schedule - ScheduleManager instance (needs getAllLayoutsAtTime(), schedule.default, playHistory)
154
- * @param {Map<string, number>} durations - Map of layoutFile duration in seconds
158
+ * @param {Array<{layoutId: string, duration: number}>} queue - Pre-built schedule queue from buildScheduleQueue()
159
+ * @param {number} queuePosition - Current position in the queue (from schedule._queuePosition)
155
160
  * @param {Object} [options]
156
161
  * @param {Date} [options.from] - Start time (default: now)
157
- * @param {number} [options.hours] - Hours to simulate (default: 2)
158
- * @param {number} [options.defaultDuration] - Fallback duration in seconds (default: 60)
162
+ * @param {number} [options.hours] - Hours to project (default: 2)
163
+ * @param {string} [options.defaultLayout] - Default layout file (to tag isDefault entries)
164
+ * @param {Map<string, number>} [options.durations] - Live durations map (overrides queue entry durations with corrected values)
159
165
  * @param {Date} [options.currentLayoutStartedAt] - When current layout started (adjusts first entry to remaining time)
160
166
  * @returns {Array<{layoutFile: string, startTime: Date, endTime: Date, duration: number, isDefault: boolean}>}
161
167
  */
162
- export function calculateTimeline(schedule, durations, options = {}) {
168
+ export function calculateTimeline(queue, queuePosition, options = {}) {
163
169
  const from = options.from || new Date();
164
170
  const hours = options.hours || 2;
165
171
  const to = new Date(from.getTime() + hours * 3600000);
166
- const defaultDuration = options.defaultDuration || 60;
167
172
  const currentLayoutStartedAt = options.currentLayoutStartedAt || null;
173
+ const defaultLayout = options.defaultLayout || null;
174
+ const durations = options.durations || null;
175
+
176
+ if (!queue || queue.length === 0) return [];
177
+
168
178
  const timeline = [];
169
179
  let currentTime = new Date(from);
180
+ let pos = queuePosition % queue.length;
170
181
  let isFirstEntry = true;
171
-
172
- // Use getAllLayoutsAtTime if available (new API), fall back to getLayoutsAtTime (old API)
173
- const hasFullApi = typeof schedule.getAllLayoutsAtTime === 'function';
174
-
175
- // Seed simulated play history from real plays
176
- const simPlays = seedPlayHistory(schedule.playHistory);
177
-
178
182
  const maxEntries = 500;
179
183
 
180
184
  while (currentTime < to && timeline.length < maxEntries) {
181
- const timeMs = currentTime.getTime();
182
- let playable;
183
-
184
- let hiddenLayouts = null;
185
-
186
- if (hasFullApi) {
187
- // Full simulation: get ALL active layouts, apply rate limiting + priority
188
- const allLayouts = schedule.getAllLayoutsAtTime(currentTime);
189
- playable = allLayouts.length > 0
190
- ? getPlayableLayouts(allLayouts, simPlays, timeMs)
191
- : [];
192
- // Detect hidden layouts (lower priority, not playing)
193
- if (allLayouts.length > playable.length) {
194
- hiddenLayouts = allLayouts
195
- .filter(l => !playable.includes(l.file))
196
- .map(l => ({ file: l.file, priority: l.priority }));
197
- }
198
- } else {
199
- // Legacy fallback: no rate limiting simulation
200
- playable = schedule.getLayoutsAtTime(currentTime);
185
+ const entry = queue[pos];
186
+ // Use live-corrected duration (from video metadata, etc.) if available,
187
+ // otherwise fall back to the queue's baked-in duration
188
+ let dur = (durations && durations.get(entry.layoutId)) || entry.duration;
189
+
190
+ // First entry: use remaining duration if we know when the current layout started
191
+ if (isFirstEntry && currentLayoutStartedAt) {
192
+ const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
193
+ dur = Math.max(1, Math.round(dur - elapsedSec));
194
+ isFirstEntry = false;
201
195
  }
202
196
 
203
- if (playable.length === 0) {
204
- // No playable layouts — use CMS default or skip ahead
205
- const defaultFile = schedule.schedule?.default;
206
- if (defaultFile) {
207
- const dur = durations.get(defaultFile) || defaultDuration;
208
- timeline.push({
209
- layoutFile: defaultFile,
210
- startTime: new Date(currentTime),
211
- endTime: new Date(timeMs + dur * 1000),
212
- duration: dur,
213
- isDefault: true,
214
- });
215
- currentTime = new Date(timeMs + dur * 1000);
216
- } else {
217
- currentTime = new Date(timeMs + 60000);
218
- }
219
- continue;
220
- }
197
+ const endMs = currentTime.getTime() + dur * 1000;
221
198
 
222
- // Round-robin through playable layouts
223
- for (let i = 0; i < playable.length && currentTime < to && timeline.length < maxEntries; i++) {
224
- const file = playable[i];
225
- let dur = durations.get(file) || defaultDuration;
226
-
227
- // First entry: use remaining duration if we know when the current layout started
228
- if (isFirstEntry && currentLayoutStartedAt) {
229
- const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
230
- const remaining = Math.max(1, Math.round(dur - elapsedSec));
231
- dur = remaining;
232
- isFirstEntry = false;
233
- }
199
+ timeline.push({
200
+ layoutFile: entry.layoutId,
201
+ startTime: new Date(currentTime),
202
+ endTime: new Date(endMs),
203
+ duration: dur,
204
+ isDefault: defaultLayout ? entry.layoutId === defaultLayout : false,
205
+ });
234
206
 
235
- const endMs = currentTime.getTime() + dur * 1000;
236
-
237
- const entry = {
238
- layoutFile: file,
239
- startTime: new Date(currentTime),
240
- endTime: new Date(endMs),
241
- duration: dur,
242
- isDefault: false,
243
- };
244
- if (hiddenLayouts && hiddenLayouts.length > 0) {
245
- entry.hidden = hiddenLayouts;
246
- }
247
- timeline.push(entry);
248
-
249
- // Record simulated play
250
- if (hasFullApi) {
251
- if (!simPlays.has(file)) simPlays.set(file, []);
252
- simPlays.get(file).push(currentTime.getTime());
253
- }
254
-
255
- currentTime = new Date(endMs);
256
-
257
- // Re-evaluate: if playable set changed, re-enter outer loop
258
- if (hasFullApi) {
259
- const nextAll = schedule.getAllLayoutsAtTime(currentTime);
260
- const nextPlayable = nextAll.length > 0
261
- ? getPlayableLayouts(nextAll, simPlays, currentTime.getTime())
262
- : [];
263
- if (!arraysEqual(playable, nextPlayable)) break;
264
- } else {
265
- const next = schedule.getLayoutsAtTime(currentTime);
266
- if (!arraysEqual(playable, next)) break;
267
- }
268
- }
207
+ currentTime = new Date(endMs);
208
+ pos = (pos + 1) % queue.length;
269
209
  }
270
210
 
271
211
  return timeline;
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Timeline Calculator Tests
3
+ *
4
+ * Tests for calculateTimeline() — walks a pre-built queue to produce
5
+ * time-stamped playback predictions for the overlay.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { calculateTimeline, parseLayoutDuration } from './timeline.js';
10
+
11
+ // Fixed "now" for deterministic tests
12
+ const NOW = new Date('2026-03-03T10:00:00Z');
13
+
14
+ // ── Tests ────────────────────────────────────────────────────────
15
+
16
+ describe('calculateTimeline', () => {
17
+ describe('Basic queue walking', () => {
18
+ it('should produce entries from a single-entry queue', () => {
19
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
20
+
21
+ const timeline = calculateTimeline(queue, 0, {
22
+ from: NOW, hours: 0.1,
23
+ });
24
+
25
+ expect(timeline.length).toBeGreaterThan(0);
26
+ expect(timeline[0].layoutFile).toBe('100.xlf');
27
+ expect(timeline[0].duration).toBe(30);
28
+ expect(timeline[0].isDefault).toBe(false);
29
+ });
30
+
31
+ it('should tag default layout entries', () => {
32
+ const queue = [{ layoutId: 'default.xlf', duration: 45 }];
33
+
34
+ const timeline = calculateTimeline(queue, 0, {
35
+ from: NOW, hours: 0.1,
36
+ defaultLayout: 'default.xlf',
37
+ });
38
+
39
+ expect(timeline[0].layoutFile).toBe('default.xlf');
40
+ expect(timeline[0].isDefault).toBe(true);
41
+ expect(timeline[0].duration).toBe(45);
42
+ });
43
+
44
+ it('should cycle through multiple queue entries', () => {
45
+ const queue = [
46
+ { layoutId: '100.xlf', duration: 30 },
47
+ { layoutId: '200.xlf', duration: 45 },
48
+ ];
49
+
50
+ const timeline = calculateTimeline(queue, 0, {
51
+ from: NOW, hours: 0.1,
52
+ });
53
+
54
+ const files = timeline.map(e => e.layoutFile);
55
+ expect(files).toContain('100.xlf');
56
+ expect(files).toContain('200.xlf');
57
+ // Should alternate
58
+ expect(files[0]).toBe('100.xlf');
59
+ expect(files[1]).toBe('200.xlf');
60
+ expect(files[2]).toBe('100.xlf');
61
+ });
62
+
63
+ it('should use live durations map over queue baked-in durations', () => {
64
+ const queue = [{ layoutId: '100.xlf', duration: 60 }]; // queue says 60s
65
+ const durations = new Map([['100.xlf', 300]]); // video metadata says 300s
66
+
67
+ const timeline = calculateTimeline(queue, 0, {
68
+ from: NOW, hours: 0.5, durations,
69
+ });
70
+
71
+ expect(timeline[0].duration).toBe(300);
72
+ // Only ~6 entries in 30min at 300s each, not 30 at 60s
73
+ expect(timeline.length).toBeLessThanOrEqual(7);
74
+ });
75
+
76
+ it('should fall back to queue duration when not in durations map', () => {
77
+ const queue = [{ layoutId: '100.xlf', duration: 45 }];
78
+ const durations = new Map(); // empty
79
+
80
+ const timeline = calculateTimeline(queue, 0, {
81
+ from: NOW, hours: 0.1, durations,
82
+ });
83
+
84
+ expect(timeline[0].duration).toBe(45);
85
+ });
86
+
87
+ it('should start from the given queue position', () => {
88
+ const queue = [
89
+ { layoutId: '100.xlf', duration: 30 },
90
+ { layoutId: '200.xlf', duration: 30 },
91
+ { layoutId: '300.xlf', duration: 30 },
92
+ ];
93
+
94
+ const timeline = calculateTimeline(queue, 2, {
95
+ from: NOW, hours: 0.1,
96
+ });
97
+
98
+ expect(timeline[0].layoutFile).toBe('300.xlf');
99
+ expect(timeline[1].layoutFile).toBe('100.xlf');
100
+ expect(timeline[2].layoutFile).toBe('200.xlf');
101
+ });
102
+
103
+ it('should wrap queue position when past end', () => {
104
+ const queue = [
105
+ { layoutId: '100.xlf', duration: 30 },
106
+ { layoutId: '200.xlf', duration: 30 },
107
+ ];
108
+
109
+ const timeline = calculateTimeline(queue, 5, {
110
+ from: NOW, hours: 0.1,
111
+ });
112
+
113
+ // 5 % 2 = 1, so starts at 200.xlf
114
+ expect(timeline[0].layoutFile).toBe('200.xlf');
115
+ expect(timeline[1].layoutFile).toBe('100.xlf');
116
+ });
117
+ });
118
+
119
+ describe('currentLayoutStartedAt (remaining time adjustment)', () => {
120
+ it('should adjust first entry duration to remaining time', () => {
121
+ const queue = [{ layoutId: '100.xlf', duration: 60 }];
122
+
123
+ // Layout started 20 seconds ago → 40 seconds remaining
124
+ const startedAt = new Date(NOW.getTime() - 20000);
125
+
126
+ const timeline = calculateTimeline(queue, 0, {
127
+ from: NOW, hours: 0.5,
128
+ currentLayoutStartedAt: startedAt,
129
+ });
130
+
131
+ expect(timeline[0].duration).toBe(40); // 60 - 20 = 40
132
+ });
133
+
134
+ it('should clamp remaining time to at least 1 second', () => {
135
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
136
+
137
+ // Layout started 60 seconds ago but duration is only 30 → already overdue
138
+ const startedAt = new Date(NOW.getTime() - 60000);
139
+
140
+ const timeline = calculateTimeline(queue, 0, {
141
+ from: NOW, hours: 0.1,
142
+ currentLayoutStartedAt: startedAt,
143
+ });
144
+
145
+ expect(timeline[0].duration).toBeGreaterThanOrEqual(1);
146
+ });
147
+
148
+ it('should only adjust first entry, not subsequent', () => {
149
+ const queue = [{ layoutId: '100.xlf', duration: 60 }];
150
+ const startedAt = new Date(NOW.getTime() - 20000);
151
+
152
+ const timeline = calculateTimeline(queue, 0, {
153
+ from: NOW, hours: 0.1,
154
+ currentLayoutStartedAt: startedAt,
155
+ });
156
+
157
+ expect(timeline[0].duration).toBe(40);
158
+ expect(timeline[1].duration).toBe(60); // Full duration
159
+ });
160
+ });
161
+
162
+ describe('Time boundaries', () => {
163
+ it('should not produce entries beyond the simulation window', () => {
164
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
165
+
166
+ const timeline = calculateTimeline(queue, 0, {
167
+ from: NOW, hours: 1,
168
+ });
169
+
170
+ const endOfWindow = new Date(NOW.getTime() + 3600000);
171
+ for (const entry of timeline) {
172
+ expect(entry.startTime.getTime()).toBeLessThan(endOfWindow.getTime());
173
+ }
174
+ });
175
+
176
+ it('should produce continuous timeline (no gaps between entries)', () => {
177
+ const queue = [
178
+ { layoutId: '100.xlf', duration: 30 },
179
+ { layoutId: '200.xlf', duration: 45 },
180
+ ];
181
+
182
+ const timeline = calculateTimeline(queue, 0, {
183
+ from: NOW, hours: 0.5,
184
+ });
185
+
186
+ for (let i = 1; i < timeline.length; i++) {
187
+ expect(timeline[i].startTime.getTime()).toBe(timeline[i - 1].endTime.getTime());
188
+ }
189
+ });
190
+
191
+ it('should handle a large number of entries without exceeding 500 cap', () => {
192
+ const queue = [{ layoutId: '100.xlf', duration: 5 }]; // 5s = many entries
193
+
194
+ const timeline = calculateTimeline(queue, 0, {
195
+ from: NOW, hours: 2,
196
+ });
197
+
198
+ expect(timeline.length).toBeLessThanOrEqual(500);
199
+ });
200
+ });
201
+
202
+ describe('Determinism', () => {
203
+ it('should produce identical output for identical inputs', () => {
204
+ const queue = [
205
+ { layoutId: '100.xlf', duration: 30 },
206
+ { layoutId: '200.xlf', duration: 45 },
207
+ ];
208
+
209
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
210
+ const t2 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
211
+
212
+ expect(t1.length).toBe(t2.length);
213
+ for (let i = 0; i < t1.length; i++) {
214
+ expect(t1[i].layoutFile).toBe(t2[i].layoutFile);
215
+ expect(t1[i].startTime.getTime()).toBe(t2[i].startTime.getTime());
216
+ expect(t1[i].endTime.getTime()).toBe(t2[i].endTime.getTime());
217
+ expect(t1[i].duration).toBe(t2[i].duration);
218
+ }
219
+ });
220
+
221
+ it('should produce DIFFERENT output when "from" time changes', () => {
222
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
223
+
224
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
225
+ const laterNow = new Date(NOW.getTime() + 300000); // 5 min later
226
+ const t2 = calculateTimeline(queue, 0, { from: laterNow, hours: 1 });
227
+
228
+ // Start times must differ because the anchor moved
229
+ expect(t1[0].startTime.getTime()).not.toBe(t2[0].startTime.getTime());
230
+ });
231
+
232
+ it('should produce DIFFERENT output when position changes', () => {
233
+ const queue = [
234
+ { layoutId: '100.xlf', duration: 30 },
235
+ { layoutId: '200.xlf', duration: 30 },
236
+ ];
237
+
238
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 0.1 });
239
+ const t2 = calculateTimeline(queue, 1, { from: NOW, hours: 0.1 });
240
+
241
+ expect(t1[0].layoutFile).toBe('100.xlf');
242
+ expect(t2[0].layoutFile).toBe('200.xlf');
243
+ });
244
+ });
245
+
246
+ describe('Edge cases', () => {
247
+ it('should return empty array for empty queue', () => {
248
+ const timeline = calculateTimeline([], 0, { from: NOW, hours: 1 });
249
+ expect(timeline).toEqual([]);
250
+ });
251
+
252
+ it('should return empty array for null queue', () => {
253
+ const timeline = calculateTimeline(null, 0, { from: NOW, hours: 1 });
254
+ expect(timeline).toEqual([]);
255
+ });
256
+
257
+ it('should handle mixed default and scheduled entries in queue', () => {
258
+ const queue = [
259
+ { layoutId: '100.xlf', duration: 30 },
260
+ { layoutId: 'default.xlf', duration: 60 },
261
+ ];
262
+
263
+ const timeline = calculateTimeline(queue, 0, {
264
+ from: NOW, hours: 0.1,
265
+ defaultLayout: 'default.xlf',
266
+ });
267
+
268
+ expect(timeline[0].isDefault).toBe(false);
269
+ expect(timeline[1].isDefault).toBe(true);
270
+ });
271
+ });
272
+ });
273
+
274
+ // ── parseLayoutDuration Tests ───────────────────────────────────
275
+
276
+ /** Helper to build minimal XLF strings */
277
+ function xlf({ duration = 0, regions = [] } = {}) {
278
+ const regionXml = regions.map(r => {
279
+ const type = r.type ? ` type="${r.type}"` : '';
280
+ const widgets = (r.widgets || []).map(w => {
281
+ const attrs = Object.entries(w).map(([k, v]) => `${k}="${v}"`).join(' ');
282
+ return `<media ${attrs}/>`;
283
+ }).join('');
284
+ return `<region${type}>${widgets}</region>`;
285
+ }).join('');
286
+ return `<layout duration="${duration}">${regionXml}</layout>`;
287
+ }
288
+
289
+ describe('parseLayoutDuration', () => {
290
+ describe('Basic duration parsing', () => {
291
+ it('should return explicit layout duration when set', () => {
292
+ const result = parseLayoutDuration(xlf({ duration: 120 }));
293
+ expect(result).toEqual({ duration: 120, isDynamic: false });
294
+ });
295
+
296
+ it('should fallback to 60s when no layout element exists', () => {
297
+ const result = parseLayoutDuration('<invalid/>');
298
+ expect(result).toEqual({ duration: 60, isDynamic: false });
299
+ });
300
+
301
+ it('should calculate max region duration from widgets', () => {
302
+ const result = parseLayoutDuration(xlf({
303
+ regions: [
304
+ { widgets: [
305
+ { duration: 30, useDuration: 1 },
306
+ { duration: 20, useDuration: 1 },
307
+ ]},
308
+ { widgets: [
309
+ { duration: 10, useDuration: 1 },
310
+ ]},
311
+ ],
312
+ }));
313
+ // Region 1: 30+20=50, Region 2: 10 → max=50
314
+ expect(result).toEqual({ duration: 50, isDynamic: false });
315
+ });
316
+
317
+ it('should estimate 60s for widgets with useDuration=0 and mark as dynamic', () => {
318
+ const result = parseLayoutDuration(xlf({
319
+ regions: [
320
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'v1' }] },
321
+ ],
322
+ }));
323
+ expect(result).toEqual({ duration: 60, isDynamic: true });
324
+ });
325
+
326
+ it('should fallback to 60s when all regions are empty', () => {
327
+ const result = parseLayoutDuration(xlf({ regions: [{ widgets: [] }] }));
328
+ expect(result).toEqual({ duration: 60, isDynamic: false });
329
+ });
330
+ });
331
+
332
+ describe('Drawer region skip', () => {
333
+ it('should skip drawer regions entirely', () => {
334
+ const result = parseLayoutDuration(xlf({
335
+ regions: [
336
+ { widgets: [{ duration: 30, useDuration: 1 }] },
337
+ { type: 'drawer', widgets: [{ duration: 300, useDuration: 1 }] },
338
+ ],
339
+ }));
340
+ // Only the non-drawer region counts: 30s
341
+ expect(result).toEqual({ duration: 30, isDynamic: false });
342
+ });
343
+
344
+ it('should fallback to 60s when only drawer regions exist', () => {
345
+ const result = parseLayoutDuration(xlf({
346
+ regions: [
347
+ { type: 'drawer', widgets: [{ duration: 120, useDuration: 1 }] },
348
+ ],
349
+ }));
350
+ expect(result).toEqual({ duration: 60, isDynamic: false });
351
+ });
352
+ });
353
+
354
+ describe('Canvas region duration (#186)', () => {
355
+ it('should use max widget duration for canvas regions (not sum)', () => {
356
+ const result = parseLayoutDuration(xlf({
357
+ regions: [
358
+ { type: 'canvas', widgets: [
359
+ { duration: 10, useDuration: 1 },
360
+ { duration: 30, useDuration: 1 },
361
+ { duration: 20, useDuration: 1 },
362
+ ]},
363
+ ],
364
+ }));
365
+ // Canvas: max(10, 30, 20) = 30, not sum(10+30+20) = 60
366
+ expect(result).toEqual({ duration: 30, isDynamic: false });
367
+ });
368
+
369
+ it('should use sum for normal regions alongside canvas', () => {
370
+ const result = parseLayoutDuration(xlf({
371
+ regions: [
372
+ { type: 'canvas', widgets: [
373
+ { duration: 10, useDuration: 1 },
374
+ { duration: 20, useDuration: 1 },
375
+ ]},
376
+ { widgets: [
377
+ { duration: 15, useDuration: 1 },
378
+ { duration: 25, useDuration: 1 },
379
+ ]},
380
+ ],
381
+ }));
382
+ // Canvas region: max(10, 20) = 20
383
+ // Normal region: sum(15+25) = 40
384
+ // Layout: max(20, 40) = 40
385
+ expect(result).toEqual({ duration: 40, isDynamic: false });
386
+ });
387
+ });
388
+
389
+ describe('videoDurations (Phase 2 probing)', () => {
390
+ it('should use probed duration when fileId matches', () => {
391
+ const videoDurations = new Map([['vid1', 45]]);
392
+ const result = parseLayoutDuration(xlf({
393
+ regions: [
394
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'vid1' }] },
395
+ ],
396
+ }), videoDurations);
397
+ expect(result).toEqual({ duration: 45, isDynamic: false });
398
+ });
399
+
400
+ it('should still estimate 60s for unmatched fileIds', () => {
401
+ const videoDurations = new Map([['other', 45]]);
402
+ const result = parseLayoutDuration(xlf({
403
+ regions: [
404
+ { widgets: [{ duration: 0, useDuration: 0, fileId: 'vid1' }] },
405
+ ],
406
+ }), videoDurations);
407
+ expect(result).toEqual({ duration: 60, isDynamic: true });
408
+ });
409
+
410
+ it('should return same result when videoDurations has no matching fileIds', () => {
411
+ const without = parseLayoutDuration(xlf({
412
+ regions: [
413
+ { widgets: [
414
+ { duration: 30, useDuration: 1 },
415
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
416
+ ]},
417
+ ],
418
+ }));
419
+ const videoDurations = new Map([['no-match', 99]]);
420
+ const withMap = parseLayoutDuration(xlf({
421
+ regions: [
422
+ { widgets: [
423
+ { duration: 30, useDuration: 1 },
424
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
425
+ ]},
426
+ ],
427
+ }), videoDurations);
428
+ expect(withMap).toEqual(without);
429
+ });
430
+
431
+ it('should handle mixed: some probed, some static, some estimated', () => {
432
+ const videoDurations = new Map([['vid1', 90]]);
433
+ const result = parseLayoutDuration(xlf({
434
+ regions: [
435
+ { widgets: [
436
+ { duration: 30, useDuration: 1 }, // Static: 30s
437
+ { duration: 0, useDuration: 0, fileId: 'vid1' }, // Probed: 90s
438
+ { duration: 0, useDuration: 0, fileId: 'vid2' }, // Estimated: 60s
439
+ ]},
440
+ ],
441
+ }), videoDurations);
442
+ // 30 + 90 + 60 = 180, isDynamic=true because vid2 is unprobed
443
+ expect(result).toEqual({ duration: 180, isDynamic: true });
444
+ });
445
+
446
+ it('should not mark as dynamic when all videos are probed', () => {
447
+ const videoDurations = new Map([['vid1', 45], ['vid2', 30]]);
448
+ const result = parseLayoutDuration(xlf({
449
+ regions: [
450
+ { widgets: [
451
+ { duration: 10, useDuration: 1 },
452
+ { duration: 0, useDuration: 0, fileId: 'vid1' },
453
+ { duration: 0, useDuration: 0, fileId: 'vid2' },
454
+ ]},
455
+ ],
456
+ }), videoDurations);
457
+ // 10 + 45 + 30 = 85, all resolved
458
+ expect(result).toEqual({ duration: 85, isDynamic: false });
459
+ });
460
+ });
461
+ });