@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 +2 -2
- package/src/index.d.ts +2 -2
- package/src/schedule.dayparting.test.js +3 -12
- package/src/timeline.js +63 -123
- package/src/timeline.test.js +461 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
39
|
-
vi.
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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 {
|
|
154
|
-
* @param {
|
|
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
|
|
158
|
-
* @param {
|
|
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(
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
let
|
|
185
|
-
|
|
186
|
-
if
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
});
|