@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 +2 -2
- package/src/index.d.ts +1 -1
- package/src/timeline.js +16 -3
- package/src/timeline.test.js +543 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/schedule",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
});
|