@xiboplayer/schedule 0.6.2 → 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 +1 -1
- package/src/schedule.dayparting.test.js +3 -12
- package/src/timeline.js +50 -123
- package/src/timeline.test.js +180 -262
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(
|
|
17
|
+
export function calculateTimeline(queue: Array<{layoutId: string, duration: number}>, queuePosition: number, options?: any): any[];
|
|
18
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
|
@@ -38,7 +38,9 @@ export function parseLayoutDuration(xlfXml, videoDurations = null) {
|
|
|
38
38
|
let maxDuration = 0;
|
|
39
39
|
let isDynamic = false;
|
|
40
40
|
for (const regionEl of layoutEl.querySelectorAll('region')) {
|
|
41
|
-
|
|
41
|
+
const regionType = regionEl.getAttribute('type');
|
|
42
|
+
if (regionType === 'drawer') continue; // Drawers are action-triggered, not timed
|
|
43
|
+
const isCanvas = regionType === 'canvas';
|
|
42
44
|
let regionDuration = 0;
|
|
43
45
|
for (const mediaEl of regionEl.querySelectorAll('media')) {
|
|
44
46
|
const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
|
|
@@ -46,16 +48,24 @@ export function parseLayoutDuration(xlfXml, videoDurations = null) {
|
|
|
46
48
|
const fileId = mediaEl.getAttribute('fileId') || '';
|
|
47
49
|
const probed = videoDurations?.get(fileId);
|
|
48
50
|
|
|
51
|
+
let widgetDuration;
|
|
49
52
|
if (probed !== undefined) {
|
|
50
|
-
|
|
53
|
+
widgetDuration = probed; // Phase 2: probed video duration
|
|
51
54
|
} else if (dur > 0 && useDuration !== 0) {
|
|
52
|
-
|
|
55
|
+
widgetDuration = dur; // Explicit CMS duration
|
|
53
56
|
} else {
|
|
54
57
|
// Video with useDuration=0 means "play to end" — estimate 60s,
|
|
55
58
|
// corrected later via recordLayoutDuration() when video metadata loads
|
|
56
|
-
|
|
59
|
+
widgetDuration = 60;
|
|
57
60
|
isDynamic = true;
|
|
58
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
|
+
}
|
|
59
69
|
}
|
|
60
70
|
maxDuration = Math.max(maxDuration, regionDuration);
|
|
61
71
|
}
|
|
@@ -110,23 +120,6 @@ function canSimulatedPlay(history, maxPlaysPerHour, timeMs) {
|
|
|
110
120
|
return true;
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
/**
|
|
114
|
-
* Seed simulated play history from real play history.
|
|
115
|
-
* Maps layoutId-based history to layoutFile-based history.
|
|
116
|
-
* @param {Map<string, number[]>} realHistory - schedule.playHistory (layoutId → [timestamps])
|
|
117
|
-
* @returns {Map<string, number[]>} layoutFile → [timestamps]
|
|
118
|
-
*/
|
|
119
|
-
function seedPlayHistory(realHistory) {
|
|
120
|
-
const simulated = new Map();
|
|
121
|
-
if (!realHistory) return simulated;
|
|
122
|
-
|
|
123
|
-
for (const [layoutId, timestamps] of realHistory) {
|
|
124
|
-
const file = `${layoutId}.xlf`;
|
|
125
|
-
simulated.set(file, [...timestamps]);
|
|
126
|
-
}
|
|
127
|
-
return simulated;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
123
|
/**
|
|
131
124
|
* From a list of layout metadata, apply simulated rate limiting and priority
|
|
132
125
|
* filtering to determine which layouts can actually play at the given time.
|
|
@@ -156,129 +149,63 @@ function getPlayableLayouts(allLayouts, simPlays, timeMs) {
|
|
|
156
149
|
}
|
|
157
150
|
|
|
158
151
|
/**
|
|
159
|
-
* Calculate a deterministic playback timeline by
|
|
160
|
-
* with rate limiting (maxPlaysPerHour) and priority fallback. Produces a real
|
|
161
|
-
* schedule prediction that matches actual player behavior.
|
|
152
|
+
* Calculate a deterministic playback timeline by walking the pre-built schedule queue.
|
|
162
153
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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.
|
|
165
157
|
*
|
|
166
|
-
* @param {
|
|
167
|
-
* @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)
|
|
168
160
|
* @param {Object} [options]
|
|
169
161
|
* @param {Date} [options.from] - Start time (default: now)
|
|
170
|
-
* @param {number} [options.hours] - Hours to
|
|
171
|
-
* @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)
|
|
172
165
|
* @param {Date} [options.currentLayoutStartedAt] - When current layout started (adjusts first entry to remaining time)
|
|
173
166
|
* @returns {Array<{layoutFile: string, startTime: Date, endTime: Date, duration: number, isDefault: boolean}>}
|
|
174
167
|
*/
|
|
175
|
-
export function calculateTimeline(
|
|
168
|
+
export function calculateTimeline(queue, queuePosition, options = {}) {
|
|
176
169
|
const from = options.from || new Date();
|
|
177
170
|
const hours = options.hours || 2;
|
|
178
171
|
const to = new Date(from.getTime() + hours * 3600000);
|
|
179
|
-
const defaultDuration = options.defaultDuration || 60;
|
|
180
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
|
+
|
|
181
178
|
const timeline = [];
|
|
182
179
|
let currentTime = new Date(from);
|
|
180
|
+
let pos = queuePosition % queue.length;
|
|
183
181
|
let isFirstEntry = true;
|
|
184
|
-
|
|
185
|
-
// Use getAllLayoutsAtTime if available (new API), fall back to getLayoutsAtTime (old API)
|
|
186
|
-
const hasFullApi = typeof schedule.getAllLayoutsAtTime === 'function';
|
|
187
|
-
|
|
188
|
-
// Seed simulated play history from real plays
|
|
189
|
-
const simPlays = seedPlayHistory(schedule.playHistory);
|
|
190
|
-
|
|
191
182
|
const maxEntries = 500;
|
|
192
183
|
|
|
193
184
|
while (currentTime < to && timeline.length < maxEntries) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let
|
|
198
|
-
|
|
199
|
-
if
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
: [];
|
|
205
|
-
// Detect hidden layouts (lower priority, not playing)
|
|
206
|
-
if (allLayouts.length > playable.length) {
|
|
207
|
-
hiddenLayouts = allLayouts
|
|
208
|
-
.filter(l => !playable.includes(l.file))
|
|
209
|
-
.map(l => ({ file: l.file, priority: l.priority }));
|
|
210
|
-
}
|
|
211
|
-
} else {
|
|
212
|
-
// Legacy fallback: no rate limiting simulation
|
|
213
|
-
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;
|
|
214
195
|
}
|
|
215
196
|
|
|
216
|
-
|
|
217
|
-
// No playable layouts — use CMS default or skip ahead
|
|
218
|
-
const defaultFile = schedule.schedule?.default;
|
|
219
|
-
if (defaultFile) {
|
|
220
|
-
const dur = durations.get(defaultFile) || defaultDuration;
|
|
221
|
-
timeline.push({
|
|
222
|
-
layoutFile: defaultFile,
|
|
223
|
-
startTime: new Date(currentTime),
|
|
224
|
-
endTime: new Date(timeMs + dur * 1000),
|
|
225
|
-
duration: dur,
|
|
226
|
-
isDefault: true,
|
|
227
|
-
});
|
|
228
|
-
currentTime = new Date(timeMs + dur * 1000);
|
|
229
|
-
} else {
|
|
230
|
-
currentTime = new Date(timeMs + 60000);
|
|
231
|
-
}
|
|
232
|
-
continue;
|
|
233
|
-
}
|
|
197
|
+
const endMs = currentTime.getTime() + dur * 1000;
|
|
234
198
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
|
|
243
|
-
const remaining = Math.max(1, Math.round(dur - elapsedSec));
|
|
244
|
-
dur = remaining;
|
|
245
|
-
isFirstEntry = false;
|
|
246
|
-
}
|
|
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
|
+
});
|
|
247
206
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const entry = {
|
|
251
|
-
layoutFile: file,
|
|
252
|
-
startTime: new Date(currentTime),
|
|
253
|
-
endTime: new Date(endMs),
|
|
254
|
-
duration: dur,
|
|
255
|
-
isDefault: false,
|
|
256
|
-
};
|
|
257
|
-
if (hiddenLayouts && hiddenLayouts.length > 0) {
|
|
258
|
-
entry.hidden = hiddenLayouts;
|
|
259
|
-
}
|
|
260
|
-
timeline.push(entry);
|
|
261
|
-
|
|
262
|
-
// Record simulated play
|
|
263
|
-
if (hasFullApi) {
|
|
264
|
-
if (!simPlays.has(file)) simPlays.set(file, []);
|
|
265
|
-
simPlays.get(file).push(currentTime.getTime());
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
currentTime = new Date(endMs);
|
|
269
|
-
|
|
270
|
-
// Re-evaluate: if playable set changed, re-enter outer loop
|
|
271
|
-
if (hasFullApi) {
|
|
272
|
-
const nextAll = schedule.getAllLayoutsAtTime(currentTime);
|
|
273
|
-
const nextPlayable = nextAll.length > 0
|
|
274
|
-
? getPlayableLayouts(nextAll, simPlays, currentTime.getTime())
|
|
275
|
-
: [];
|
|
276
|
-
if (!arraysEqual(playable, nextPlayable)) break;
|
|
277
|
-
} else {
|
|
278
|
-
const next = schedule.getLayoutsAtTime(currentTime);
|
|
279
|
-
if (!arraysEqual(playable, next)) break;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
207
|
+
currentTime = new Date(endMs);
|
|
208
|
+
pos = (pos + 1) % queue.length;
|
|
282
209
|
}
|
|
283
210
|
|
|
284
211
|
return timeline;
|
package/src/timeline.test.js
CHANGED
|
@@ -1,60 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Timeline Calculator Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests for calculateTimeline() —
|
|
5
|
-
*
|
|
4
|
+
* Tests for calculateTimeline() — walks a pre-built queue to produce
|
|
5
|
+
* time-stamped playback predictions for the overlay.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
9
|
import { calculateTimeline, parseLayoutDuration } from './timeline.js';
|
|
10
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
11
|
// Fixed "now" for deterministic tests
|
|
37
12
|
const NOW = new Date('2026-03-03T10:00:00Z');
|
|
38
13
|
|
|
39
|
-
function fixedDate(isoTime) {
|
|
40
|
-
return new Date(isoTime);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
14
|
// ── Tests ────────────────────────────────────────────────────────
|
|
44
15
|
|
|
45
16
|
describe('calculateTimeline', () => {
|
|
46
|
-
describe('Basic
|
|
47
|
-
it('should produce entries
|
|
48
|
-
const
|
|
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]]);
|
|
17
|
+
describe('Basic queue walking', () => {
|
|
18
|
+
it('should produce entries from a single-entry queue', () => {
|
|
19
|
+
const queue = [{ layoutId: '100.xlf', duration: 30 }];
|
|
55
20
|
|
|
56
|
-
const timeline = calculateTimeline(
|
|
57
|
-
from: NOW, hours: 1,
|
|
21
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
22
|
+
from: NOW, hours: 0.1,
|
|
58
23
|
});
|
|
59
24
|
|
|
60
25
|
expect(timeline.length).toBeGreaterThan(0);
|
|
@@ -63,189 +28,142 @@ describe('calculateTimeline', () => {
|
|
|
63
28
|
expect(timeline[0].isDefault).toBe(false);
|
|
64
29
|
});
|
|
65
30
|
|
|
66
|
-
it('should
|
|
67
|
-
const
|
|
68
|
-
layouts: [],
|
|
69
|
-
defaultLayout: 'default.xlf',
|
|
70
|
-
});
|
|
71
|
-
const durations = new Map([['default.xlf', 45]]);
|
|
31
|
+
it('should tag default layout entries', () => {
|
|
32
|
+
const queue = [{ layoutId: 'default.xlf', duration: 45 }];
|
|
72
33
|
|
|
73
|
-
const timeline = calculateTimeline(
|
|
34
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
74
35
|
from: NOW, hours: 0.1,
|
|
36
|
+
defaultLayout: 'default.xlf',
|
|
75
37
|
});
|
|
76
38
|
|
|
77
|
-
expect(timeline.length).toBeGreaterThan(0);
|
|
78
39
|
expect(timeline[0].layoutFile).toBe('default.xlf');
|
|
79
40
|
expect(timeline[0].isDefault).toBe(true);
|
|
80
41
|
expect(timeline[0].duration).toBe(45);
|
|
81
42
|
});
|
|
82
43
|
|
|
83
|
-
it('should
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
});
|
|
44
|
+
it('should cycle through multiple queue entries', () => {
|
|
45
|
+
const queue = [
|
|
46
|
+
{ layoutId: '100.xlf', duration: 30 },
|
|
47
|
+
{ layoutId: '200.xlf', duration: 45 },
|
|
48
|
+
];
|
|
98
49
|
|
|
99
|
-
|
|
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, {
|
|
50
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
111
51
|
from: NOW, hours: 0.1,
|
|
112
52
|
});
|
|
113
53
|
|
|
114
|
-
// Both layouts should appear in the timeline
|
|
115
54
|
const files = timeline.map(e => e.layoutFile);
|
|
116
55
|
expect(files).toContain('100.xlf');
|
|
117
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');
|
|
118
61
|
});
|
|
119
|
-
});
|
|
120
62
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{ file: 'high.xlf', priority: 10, maxPlaysPerHour: 0,
|
|
128
|
-
fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
|
|
129
|
-
],
|
|
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,
|
|
130
69
|
});
|
|
131
|
-
const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
|
|
132
70
|
|
|
133
|
-
|
|
134
|
-
|
|
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,
|
|
135
82
|
});
|
|
136
83
|
|
|
137
|
-
|
|
138
|
-
const uniqueFiles = [...new Set(timeline.map(e => e.layoutFile))];
|
|
139
|
-
expect(uniqueFiles).toEqual(['high.xlf']);
|
|
84
|
+
expect(timeline[0].duration).toBe(45);
|
|
140
85
|
});
|
|
141
86
|
|
|
142
|
-
it('should
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,
|
|
150
96
|
});
|
|
151
|
-
const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
|
|
152
97
|
|
|
153
|
-
|
|
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, {
|
|
154
110
|
from: NOW, hours: 0.1,
|
|
155
111
|
});
|
|
156
112
|
|
|
157
|
-
//
|
|
158
|
-
expect(timeline[0].
|
|
159
|
-
expect(timeline[
|
|
160
|
-
expect.arrayContaining([expect.objectContaining({ file: 'low.xlf' })])
|
|
161
|
-
);
|
|
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');
|
|
162
116
|
});
|
|
163
117
|
});
|
|
164
118
|
|
|
165
|
-
describe('
|
|
166
|
-
it('should
|
|
167
|
-
const
|
|
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]]);
|
|
119
|
+
describe('currentLayoutStartedAt (remaining time adjustment)', () => {
|
|
120
|
+
it('should adjust first entry duration to remaining time', () => {
|
|
121
|
+
const queue = [{ layoutId: '100.xlf', duration: 60 }];
|
|
176
122
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
});
|
|
123
|
+
// Layout started 20 seconds ago → 40 seconds remaining
|
|
124
|
+
const startedAt = new Date(NOW.getTime() - 20000);
|
|
180
125
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
126
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
127
|
+
from: NOW, hours: 0.5,
|
|
128
|
+
currentLayoutStartedAt: startedAt,
|
|
129
|
+
});
|
|
184
130
|
|
|
185
|
-
//
|
|
186
|
-
const fillerPlays = timeline.filter(e => e.layoutFile === 'filler.xlf');
|
|
187
|
-
expect(fillerPlays.length).toBeGreaterThan(0);
|
|
131
|
+
expect(timeline[0].duration).toBe(40); // 60 - 20 = 40
|
|
188
132
|
});
|
|
189
133
|
|
|
190
|
-
it('should
|
|
191
|
-
const
|
|
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]]);
|
|
134
|
+
it('should clamp remaining time to at least 1 second', () => {
|
|
135
|
+
const queue = [{ layoutId: '100.xlf', duration: 30 }];
|
|
199
136
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
137
|
+
// Layout started 60 seconds ago but duration is only 30 → already overdue
|
|
138
|
+
const startedAt = new Date(NOW.getTime() - 60000);
|
|
203
139
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
140
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
141
|
+
from: NOW, hours: 0.1,
|
|
142
|
+
currentLayoutStartedAt: startedAt,
|
|
143
|
+
});
|
|
208
144
|
|
|
209
|
-
|
|
210
|
-
const limitedPlays = timeline.filter(e => e.layoutFile === 'limited.xlf');
|
|
211
|
-
expect(limitedPlays.length).toBeLessThanOrEqual(1);
|
|
145
|
+
expect(timeline[0].duration).toBeGreaterThanOrEqual(1);
|
|
212
146
|
});
|
|
213
|
-
});
|
|
214
147
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
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]]);
|
|
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);
|
|
225
151
|
|
|
226
|
-
const timeline = calculateTimeline(
|
|
227
|
-
from: NOW, hours: 1,
|
|
152
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
153
|
+
from: NOW, hours: 0.1,
|
|
154
|
+
currentLayoutStartedAt: startedAt,
|
|
228
155
|
});
|
|
229
156
|
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
}
|
|
157
|
+
expect(timeline[0].duration).toBe(40);
|
|
158
|
+
expect(timeline[1].duration).toBe(60); // Full duration
|
|
237
159
|
});
|
|
160
|
+
});
|
|
238
161
|
|
|
162
|
+
describe('Time boundaries', () => {
|
|
239
163
|
it('should not produce entries beyond the simulation window', () => {
|
|
240
|
-
const
|
|
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]]);
|
|
164
|
+
const queue = [{ layoutId: '100.xlf', duration: 30 }];
|
|
247
165
|
|
|
248
|
-
const timeline = calculateTimeline(
|
|
166
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
249
167
|
from: NOW, hours: 1,
|
|
250
168
|
});
|
|
251
169
|
|
|
@@ -254,64 +172,42 @@ describe('calculateTimeline', () => {
|
|
|
254
172
|
expect(entry.startTime.getTime()).toBeLessThan(endOfWindow.getTime());
|
|
255
173
|
}
|
|
256
174
|
});
|
|
257
|
-
});
|
|
258
175
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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);
|
|
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
|
+
];
|
|
271
181
|
|
|
272
|
-
const timeline = calculateTimeline(
|
|
182
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
273
183
|
from: NOW, hours: 0.5,
|
|
274
|
-
currentLayoutStartedAt: startedAt,
|
|
275
184
|
});
|
|
276
185
|
|
|
277
|
-
|
|
186
|
+
for (let i = 1; i < timeline.length; i++) {
|
|
187
|
+
expect(timeline[i].startTime.getTime()).toBe(timeline[i - 1].endTime.getTime());
|
|
188
|
+
}
|
|
278
189
|
});
|
|
279
190
|
|
|
280
|
-
it('should
|
|
281
|
-
const
|
|
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);
|
|
191
|
+
it('should handle a large number of entries without exceeding 500 cap', () => {
|
|
192
|
+
const queue = [{ layoutId: '100.xlf', duration: 5 }]; // 5s = many entries
|
|
291
193
|
|
|
292
|
-
const timeline = calculateTimeline(
|
|
293
|
-
from: NOW, hours:
|
|
294
|
-
currentLayoutStartedAt: startedAt,
|
|
194
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
195
|
+
from: NOW, hours: 2,
|
|
295
196
|
});
|
|
296
197
|
|
|
297
|
-
expect(timeline
|
|
198
|
+
expect(timeline.length).toBeLessThanOrEqual(500);
|
|
298
199
|
});
|
|
299
200
|
});
|
|
300
201
|
|
|
301
202
|
describe('Determinism', () => {
|
|
302
203
|
it('should produce identical output for identical inputs', () => {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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]]);
|
|
204
|
+
const queue = [
|
|
205
|
+
{ layoutId: '100.xlf', duration: 30 },
|
|
206
|
+
{ layoutId: '200.xlf', duration: 45 },
|
|
207
|
+
];
|
|
312
208
|
|
|
313
|
-
const t1 = calculateTimeline(
|
|
314
|
-
const t2 = calculateTimeline(
|
|
209
|
+
const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
|
|
210
|
+
const t2 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
|
|
315
211
|
|
|
316
212
|
expect(t1.length).toBe(t2.length);
|
|
317
213
|
for (let i = 0; i < t1.length; i++) {
|
|
@@ -323,67 +219,54 @@ describe('calculateTimeline', () => {
|
|
|
323
219
|
});
|
|
324
220
|
|
|
325
221
|
it('should produce DIFFERENT output when "from" time changes', () => {
|
|
326
|
-
const
|
|
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]]);
|
|
222
|
+
const queue = [{ layoutId: '100.xlf', duration: 30 }];
|
|
333
223
|
|
|
334
|
-
const t1 = calculateTimeline(
|
|
224
|
+
const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
|
|
335
225
|
const laterNow = new Date(NOW.getTime() + 300000); // 5 min later
|
|
336
|
-
const t2 = calculateTimeline(
|
|
226
|
+
const t2 = calculateTimeline(queue, 0, { from: laterNow, hours: 1 });
|
|
337
227
|
|
|
338
228
|
// Start times must differ because the anchor moved
|
|
339
229
|
expect(t1[0].startTime.getTime()).not.toBe(t2[0].startTime.getTime());
|
|
340
230
|
});
|
|
341
|
-
});
|
|
342
231
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
];
|
|
347
237
|
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
});
|
|
238
|
+
const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 0.1 });
|
|
239
|
+
const t2 = calculateTimeline(queue, 1, { from: NOW, hours: 0.1 });
|
|
351
240
|
|
|
352
|
-
expect(
|
|
241
|
+
expect(t1[0].layoutFile).toBe('100.xlf');
|
|
242
|
+
expect(t2[0].layoutFile).toBe('200.xlf');
|
|
353
243
|
});
|
|
244
|
+
});
|
|
354
245
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
});
|
|
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
|
+
});
|
|
367
251
|
|
|
368
|
-
|
|
252
|
+
it('should return empty array for null queue', () => {
|
|
253
|
+
const timeline = calculateTimeline(null, 0, { from: NOW, hours: 1 });
|
|
254
|
+
expect(timeline).toEqual([]);
|
|
369
255
|
});
|
|
370
256
|
|
|
371
|
-
it('should
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}],
|
|
377
|
-
});
|
|
378
|
-
const durations = new Map([['100.xlf', 30]]);
|
|
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
|
+
];
|
|
379
262
|
|
|
380
|
-
const timeline = calculateTimeline(
|
|
381
|
-
from: NOW, hours: 0.
|
|
263
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
264
|
+
from: NOW, hours: 0.1,
|
|
265
|
+
defaultLayout: 'default.xlf',
|
|
382
266
|
});
|
|
383
267
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
268
|
+
expect(timeline[0].isDefault).toBe(false);
|
|
269
|
+
expect(timeline[1].isDefault).toBe(true);
|
|
387
270
|
});
|
|
388
271
|
});
|
|
389
272
|
});
|
|
@@ -468,6 +351,41 @@ describe('parseLayoutDuration', () => {
|
|
|
468
351
|
});
|
|
469
352
|
});
|
|
470
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
|
+
|
|
471
389
|
describe('videoDurations (Phase 2 probing)', () => {
|
|
472
390
|
it('should use probed duration when fileId matches', () => {
|
|
473
391
|
const videoDurations = new Map([['vid1', 45]]);
|