@xiboplayer/schedule 0.1.0
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/docs/README.md +102 -0
- package/docs/XIBO_CAMPAIGNS_AND_PRIORITY.md +600 -0
- package/docs/advanced-features.md +425 -0
- package/docs/integration.md +284 -0
- package/docs/interrupts-implementation.md +357 -0
- package/package.json +41 -0
- package/src/criteria.js +135 -0
- package/src/criteria.test.js +376 -0
- package/src/index.js +20 -0
- package/src/integration.test.js +351 -0
- package/src/interrupts.js +298 -0
- package/src/interrupts.test.js +482 -0
- package/src/overlays.js +174 -0
- package/src/schedule.dayparting.test.js +390 -0
- package/src/schedule.js +509 -0
- package/src/schedule.test.js +505 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interrupt Scheduler Tests
|
|
3
|
+
* Exhaustive test suite for shareOfVoice interrupt layouts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { InterruptScheduler } from './interrupts.js';
|
|
8
|
+
|
|
9
|
+
describe('InterruptScheduler', () => {
|
|
10
|
+
let scheduler;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
scheduler = new InterruptScheduler();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Helper to create mock layouts
|
|
17
|
+
const createLayout = (id, duration, shareOfVoice = 0) => ({
|
|
18
|
+
id,
|
|
19
|
+
file: id,
|
|
20
|
+
duration,
|
|
21
|
+
shareOfVoice,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('isInterrupt', () => {
|
|
25
|
+
it('should identify interrupt layouts', () => {
|
|
26
|
+
const interrupt = createLayout('int1', 10, 50);
|
|
27
|
+
const normal = createLayout('norm1', 10, 0);
|
|
28
|
+
|
|
29
|
+
expect(scheduler.isInterrupt(interrupt)).toBe(true);
|
|
30
|
+
expect(scheduler.isInterrupt(normal)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle missing shareOfVoice', () => {
|
|
34
|
+
const layout = { id: 'test', duration: 10 };
|
|
35
|
+
expect(scheduler.isInterrupt(layout)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getRequiredSeconds', () => {
|
|
40
|
+
it('should calculate required seconds from shareOfVoice percentage', () => {
|
|
41
|
+
expect(scheduler.getRequiredSeconds(createLayout('int1', 10, 10))).toBe(360); // 10% of 3600
|
|
42
|
+
expect(scheduler.getRequiredSeconds(createLayout('int2', 10, 50))).toBe(1800); // 50% of 3600
|
|
43
|
+
expect(scheduler.getRequiredSeconds(createLayout('int3', 10, 100))).toBe(3600); // 100% of 3600
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return 0 for non-interrupt layouts', () => {
|
|
47
|
+
expect(scheduler.getRequiredSeconds(createLayout('norm1', 10, 0))).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('isInterruptDurationSatisfied', () => {
|
|
52
|
+
it('should check if interrupt has met its shareOfVoice requirement', () => {
|
|
53
|
+
const layout = createLayout('int1', 60, 10); // 10% = 360s required
|
|
54
|
+
|
|
55
|
+
expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(false);
|
|
56
|
+
|
|
57
|
+
// Add some duration
|
|
58
|
+
scheduler.addCommittedDuration('int1', 180);
|
|
59
|
+
expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(false); // 180 < 360
|
|
60
|
+
|
|
61
|
+
// Add more to reach requirement
|
|
62
|
+
scheduler.addCommittedDuration('int1', 180);
|
|
63
|
+
expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(true); // 360 >= 360
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should always return true for non-interrupt layouts', () => {
|
|
67
|
+
const layout = createLayout('norm1', 10, 0);
|
|
68
|
+
expect(scheduler.isInterruptDurationSatisfied(layout)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('resetCommittedDurations', () => {
|
|
73
|
+
it('should clear all committed durations', () => {
|
|
74
|
+
scheduler.addCommittedDuration('int1', 100);
|
|
75
|
+
scheduler.addCommittedDuration('int2', 200);
|
|
76
|
+
|
|
77
|
+
expect(scheduler.getCommittedDuration('int1')).toBe(100);
|
|
78
|
+
expect(scheduler.getCommittedDuration('int2')).toBe(200);
|
|
79
|
+
|
|
80
|
+
scheduler.resetCommittedDurations();
|
|
81
|
+
|
|
82
|
+
expect(scheduler.getCommittedDuration('int1')).toBe(0);
|
|
83
|
+
expect(scheduler.getCommittedDuration('int2')).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('separateLayouts', () => {
|
|
88
|
+
it('should separate normal and interrupt layouts', () => {
|
|
89
|
+
const layouts = [
|
|
90
|
+
createLayout('norm1', 10, 0),
|
|
91
|
+
createLayout('int1', 10, 50),
|
|
92
|
+
createLayout('norm2', 20, 0),
|
|
93
|
+
createLayout('int2', 10, 25),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
|
|
97
|
+
|
|
98
|
+
expect(normalLayouts).toHaveLength(2);
|
|
99
|
+
expect(interruptLayouts).toHaveLength(2);
|
|
100
|
+
expect(normalLayouts[0].id).toBe('norm1');
|
|
101
|
+
expect(normalLayouts[1].id).toBe('norm2');
|
|
102
|
+
expect(interruptLayouts[0].id).toBe('int1');
|
|
103
|
+
expect(interruptLayouts[1].id).toBe('int2');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle all normal layouts', () => {
|
|
107
|
+
const layouts = [
|
|
108
|
+
createLayout('norm1', 10, 0),
|
|
109
|
+
createLayout('norm2', 20, 0),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
|
|
113
|
+
|
|
114
|
+
expect(normalLayouts).toHaveLength(2);
|
|
115
|
+
expect(interruptLayouts).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should handle all interrupt layouts', () => {
|
|
119
|
+
const layouts = [
|
|
120
|
+
createLayout('int1', 10, 50),
|
|
121
|
+
createLayout('int2', 10, 25),
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const { normalLayouts, interruptLayouts } = scheduler.separateLayouts(layouts);
|
|
125
|
+
|
|
126
|
+
expect(normalLayouts).toHaveLength(0);
|
|
127
|
+
expect(interruptLayouts).toHaveLength(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('fillTimeWithLayouts', () => {
|
|
132
|
+
it('should repeat layouts to fill target duration', () => {
|
|
133
|
+
const layouts = [
|
|
134
|
+
createLayout('l1', 100),
|
|
135
|
+
createLayout('l2', 100),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const result = scheduler.fillTimeWithLayouts(layouts, 500);
|
|
139
|
+
|
|
140
|
+
// Should cycle through: l1, l2, l1, l2, l1 = 500s
|
|
141
|
+
expect(result).toHaveLength(5);
|
|
142
|
+
expect(result[0].id).toBe('l1');
|
|
143
|
+
expect(result[1].id).toBe('l2');
|
|
144
|
+
expect(result[2].id).toBe('l1');
|
|
145
|
+
expect(result[3].id).toBe('l2');
|
|
146
|
+
expect(result[4].id).toBe('l1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle single layout', () => {
|
|
150
|
+
const layouts = [createLayout('l1', 60)];
|
|
151
|
+
const result = scheduler.fillTimeWithLayouts(layouts, 180);
|
|
152
|
+
|
|
153
|
+
expect(result).toHaveLength(3); // 60s * 3 = 180s
|
|
154
|
+
expect(result.every(l => l.id === 'l1')).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should overshoot slightly when layouts do not divide evenly', () => {
|
|
158
|
+
const layouts = [createLayout('l1', 70)];
|
|
159
|
+
const result = scheduler.fillTimeWithLayouts(layouts, 200);
|
|
160
|
+
|
|
161
|
+
// Will overshoot: 70 * 3 = 210 > 200
|
|
162
|
+
expect(result).toHaveLength(3);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('processInterrupts - basic scenarios', () => {
|
|
167
|
+
it('should return normal layouts when no interrupts', () => {
|
|
168
|
+
const normal = [
|
|
169
|
+
createLayout('norm1', 100),
|
|
170
|
+
createLayout('norm2', 100),
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const result = scheduler.processInterrupts(normal, []);
|
|
174
|
+
|
|
175
|
+
expect(result).toEqual(normal);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should handle 10% shareOfVoice interrupt', () => {
|
|
179
|
+
const normal = [createLayout('norm1', 60)];
|
|
180
|
+
const interrupts = [createLayout('int1', 60, 10)]; // 10% = 360s
|
|
181
|
+
|
|
182
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
183
|
+
|
|
184
|
+
// Count how many times each layout appears
|
|
185
|
+
const interruptCount = result.filter(l => l.id === 'int1').length;
|
|
186
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
187
|
+
|
|
188
|
+
// 360s / 60s = 6 interrupt plays
|
|
189
|
+
expect(interruptCount).toBe(6);
|
|
190
|
+
|
|
191
|
+
// Remaining: 3240s / 60s = 54 normal plays
|
|
192
|
+
expect(normalCount).toBe(54);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should handle 50% shareOfVoice interrupt', () => {
|
|
196
|
+
const normal = [createLayout('norm1', 60)];
|
|
197
|
+
const interrupts = [createLayout('int1', 60, 50)]; // 50% = 1800s
|
|
198
|
+
|
|
199
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
200
|
+
|
|
201
|
+
const interruptCount = result.filter(l => l.id === 'int1').length;
|
|
202
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
203
|
+
|
|
204
|
+
// 1800s / 60s = 30 interrupt plays
|
|
205
|
+
expect(interruptCount).toBe(30);
|
|
206
|
+
|
|
207
|
+
// Remaining: 1800s / 60s = 30 normal plays
|
|
208
|
+
expect(normalCount).toBe(30);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle 100% shareOfVoice interrupt (fills entire hour)', () => {
|
|
212
|
+
const normal = [createLayout('norm1', 60)];
|
|
213
|
+
const interrupts = [createLayout('int1', 60, 100)]; // 100% = 3600s
|
|
214
|
+
|
|
215
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
216
|
+
|
|
217
|
+
const interruptCount = result.filter(l => l.id === 'int1').length;
|
|
218
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
219
|
+
|
|
220
|
+
// 3600s / 60s = 60 interrupt plays
|
|
221
|
+
expect(interruptCount).toBe(60);
|
|
222
|
+
|
|
223
|
+
// No room for normal layouts
|
|
224
|
+
expect(normalCount).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('processInterrupts - multiple interrupts', () => {
|
|
229
|
+
it('should handle two interrupts with different shareOfVoice', () => {
|
|
230
|
+
const normal = [createLayout('norm1', 60)];
|
|
231
|
+
const interrupts = [
|
|
232
|
+
createLayout('int1', 60, 25), // 25% = 900s
|
|
233
|
+
createLayout('int2', 60, 25), // 25% = 900s
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
237
|
+
|
|
238
|
+
const int1Count = result.filter(l => l.id === 'int1').length;
|
|
239
|
+
const int2Count = result.filter(l => l.id === 'int2').length;
|
|
240
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
241
|
+
|
|
242
|
+
// Each interrupt: 900s / 60s = 15 plays
|
|
243
|
+
expect(int1Count).toBe(15);
|
|
244
|
+
expect(int2Count).toBe(15);
|
|
245
|
+
|
|
246
|
+
// Remaining: 1800s / 60s = 30 normal plays
|
|
247
|
+
expect(normalCount).toBe(30);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle interrupts that exceed 100% total (fill entire hour)', () => {
|
|
251
|
+
const normal = [createLayout('norm1', 60)];
|
|
252
|
+
const interrupts = [
|
|
253
|
+
createLayout('int1', 60, 60), // 60%
|
|
254
|
+
createLayout('int2', 60, 60), // 60%
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
258
|
+
|
|
259
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
260
|
+
|
|
261
|
+
// Total > 100%, so no room for normal layouts
|
|
262
|
+
expect(normalCount).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('processInterrupts - edge cases', () => {
|
|
267
|
+
it('should handle shareOfVoice = 0 (treated as normal layout)', () => {
|
|
268
|
+
const normal = [createLayout('norm1', 60)];
|
|
269
|
+
const interrupts = [createLayout('int1', 60, 0)]; // 0% = not really an interrupt
|
|
270
|
+
|
|
271
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
272
|
+
|
|
273
|
+
// With shareOfVoice=0, it's not treated as interrupt, but it's still in interrupts array
|
|
274
|
+
// The function will process it and try to satisfy 0% requirement (which is already satisfied)
|
|
275
|
+
// So it won't be added to interrupt loop, result is filled with normal layouts
|
|
276
|
+
const normalCount = result.filter(l => l.id === 'norm1').length;
|
|
277
|
+
expect(normalCount).toBeGreaterThan(0); // Should be filled with normal layouts
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should handle empty normal layouts (interrupts fill hour)', () => {
|
|
281
|
+
const normal = [];
|
|
282
|
+
const interrupts = [createLayout('int1', 60, 50)]; // 50%
|
|
283
|
+
|
|
284
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
285
|
+
|
|
286
|
+
// Should fill entire hour with interrupts
|
|
287
|
+
expect(result.length).toBeGreaterThan(0);
|
|
288
|
+
expect(result.every(l => l.id === 'int1')).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should handle different layout durations', () => {
|
|
292
|
+
const normal = [createLayout('norm1', 120)]; // 2 min layouts
|
|
293
|
+
const interrupts = [createLayout('int1', 30, 10)]; // 30s layouts, 10%
|
|
294
|
+
|
|
295
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
296
|
+
|
|
297
|
+
const interruptCount = result.filter(l => l.id === 'int1').length;
|
|
298
|
+
|
|
299
|
+
// 360s / 30s = 12 interrupt plays
|
|
300
|
+
expect(interruptCount).toBe(12);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('processInterrupts - interleaving', () => {
|
|
305
|
+
it('should interleave interrupts evenly with normal layouts', () => {
|
|
306
|
+
const normal = [createLayout('norm1', 60)];
|
|
307
|
+
const interrupts = [createLayout('int1', 60, 10)]; // 10%
|
|
308
|
+
|
|
309
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
310
|
+
|
|
311
|
+
// Check that interrupts are distributed (not all at start or end)
|
|
312
|
+
const firstHalf = result.slice(0, result.length / 2);
|
|
313
|
+
const secondHalf = result.slice(result.length / 2);
|
|
314
|
+
|
|
315
|
+
const firstHalfInterrupts = firstHalf.filter(l => l.id === 'int1').length;
|
|
316
|
+
const secondHalfInterrupts = secondHalf.filter(l => l.id === 'int1').length;
|
|
317
|
+
|
|
318
|
+
// Both halves should have some interrupts (roughly equal)
|
|
319
|
+
expect(firstHalfInterrupts).toBeGreaterThan(0);
|
|
320
|
+
expect(secondHalfInterrupts).toBeGreaterThan(0);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should maintain order within normal layouts during interleaving', () => {
|
|
324
|
+
const normal = [
|
|
325
|
+
createLayout('norm1', 60),
|
|
326
|
+
createLayout('norm2', 60),
|
|
327
|
+
];
|
|
328
|
+
const interrupts = [createLayout('int1', 60, 10)]; // 10%
|
|
329
|
+
|
|
330
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
331
|
+
|
|
332
|
+
// Extract just the normal layouts from result
|
|
333
|
+
const normalInResult = result.filter(l => l.id.startsWith('norm'));
|
|
334
|
+
|
|
335
|
+
// Should cycle norm1, norm2, norm1, norm2, ...
|
|
336
|
+
for (let i = 0; i < normalInResult.length - 1; i += 2) {
|
|
337
|
+
if (normalInResult[i]) {
|
|
338
|
+
expect(normalInResult[i].id).toBe('norm1');
|
|
339
|
+
}
|
|
340
|
+
if (normalInResult[i + 1]) {
|
|
341
|
+
expect(normalInResult[i + 1].id).toBe('norm2');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('processInterrupts - duration validation', () => {
|
|
348
|
+
it('should produce loop that approximates 3600 seconds', () => {
|
|
349
|
+
const normal = [createLayout('norm1', 60)];
|
|
350
|
+
const interrupts = [createLayout('int1', 60, 25)]; // 25%
|
|
351
|
+
|
|
352
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
353
|
+
|
|
354
|
+
const totalDuration = result.reduce((sum, layout) => sum + layout.duration, 0);
|
|
355
|
+
|
|
356
|
+
// Should be close to 3600s (may overshoot slightly)
|
|
357
|
+
expect(totalDuration).toBeGreaterThanOrEqual(3600);
|
|
358
|
+
expect(totalDuration).toBeLessThan(3700); // Allow small overshoot
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should handle hour boundary correctly', () => {
|
|
362
|
+
const normal = [createLayout('norm1', 3600)]; // 1 hour layout
|
|
363
|
+
const interrupts = [createLayout('int1', 60, 10)]; // 10% = 6 minutes
|
|
364
|
+
|
|
365
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
366
|
+
|
|
367
|
+
// Should have 6 minutes of interrupts + remaining time from normal
|
|
368
|
+
const interruptTime = result.filter(l => l.id === 'int1')
|
|
369
|
+
.reduce((sum, l) => sum + l.duration, 0);
|
|
370
|
+
|
|
371
|
+
expect(interruptTime).toBe(360); // 6 minutes
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('processInterrupts - campaign-like behavior', () => {
|
|
376
|
+
it('should cycle through multiple normal layouts', () => {
|
|
377
|
+
const normal = [
|
|
378
|
+
createLayout('norm1', 60),
|
|
379
|
+
createLayout('norm2', 60),
|
|
380
|
+
createLayout('norm3', 60),
|
|
381
|
+
];
|
|
382
|
+
const interrupts = [createLayout('int1', 60, 10)]; // 10%
|
|
383
|
+
|
|
384
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
385
|
+
|
|
386
|
+
const normalInResult = result.filter(l => l.id.startsWith('norm'));
|
|
387
|
+
|
|
388
|
+
// Should see all three normal layouts
|
|
389
|
+
expect(normalInResult.some(l => l.id === 'norm1')).toBe(true);
|
|
390
|
+
expect(normalInResult.some(l => l.id === 'norm2')).toBe(true);
|
|
391
|
+
expect(normalInResult.some(l => l.id === 'norm3')).toBe(true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('should cycle through multiple interrupt layouts', () => {
|
|
395
|
+
const normal = [createLayout('norm1', 60)];
|
|
396
|
+
const interrupts = [
|
|
397
|
+
createLayout('int1', 60, 10),
|
|
398
|
+
createLayout('int2', 60, 10),
|
|
399
|
+
createLayout('int3', 60, 10),
|
|
400
|
+
];
|
|
401
|
+
|
|
402
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
403
|
+
|
|
404
|
+
const interruptsInResult = result.filter(l => l.id.startsWith('int'));
|
|
405
|
+
|
|
406
|
+
// Should see all three interrupts
|
|
407
|
+
expect(interruptsInResult.some(l => l.id === 'int1')).toBe(true);
|
|
408
|
+
expect(interruptsInResult.some(l => l.id === 'int2')).toBe(true);
|
|
409
|
+
expect(interruptsInResult.some(l => l.id === 'int3')).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('processInterrupts - real-world scenarios', () => {
|
|
414
|
+
it('should handle typical advertising scenario (5% ads)', () => {
|
|
415
|
+
const normal = [
|
|
416
|
+
createLayout('content1', 120),
|
|
417
|
+
createLayout('content2', 120),
|
|
418
|
+
];
|
|
419
|
+
const interrupts = [
|
|
420
|
+
createLayout('ad1', 30, 5), // 5% = 3 minutes of ads per hour
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
424
|
+
|
|
425
|
+
const adDuration = result.filter(l => l.id === 'ad1')
|
|
426
|
+
.reduce((sum, l) => sum + l.duration, 0);
|
|
427
|
+
|
|
428
|
+
// Should be 180s (3 minutes)
|
|
429
|
+
expect(adDuration).toBe(180);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should handle multiple ad campaigns with different shareOfVoice', () => {
|
|
433
|
+
const normal = [createLayout('content1', 60)];
|
|
434
|
+
const interrupts = [
|
|
435
|
+
createLayout('premium_ad', 30, 15), // Premium ads: 15%
|
|
436
|
+
createLayout('regular_ad', 30, 10), // Regular ads: 10%
|
|
437
|
+
];
|
|
438
|
+
|
|
439
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
440
|
+
|
|
441
|
+
const premiumDuration = result.filter(l => l.id === 'premium_ad')
|
|
442
|
+
.reduce((sum, l) => sum + l.duration, 0);
|
|
443
|
+
const regularDuration = result.filter(l => l.id === 'regular_ad')
|
|
444
|
+
.reduce((sum, l) => sum + l.duration, 0);
|
|
445
|
+
|
|
446
|
+
// Premium: 15% of 3600 = 540s
|
|
447
|
+
expect(premiumDuration).toBe(540);
|
|
448
|
+
|
|
449
|
+
// Regular: 10% of 3600 = 360s
|
|
450
|
+
expect(regularDuration).toBe(360);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should work with dayparting schedules', () => {
|
|
454
|
+
// Simulate lunch hour with restaurant ads
|
|
455
|
+
const normal = [createLayout('menu', 120)];
|
|
456
|
+
const interrupts = [createLayout('lunch_special', 30, 20)]; // 20% during lunch
|
|
457
|
+
|
|
458
|
+
const result = scheduler.processInterrupts(normal, interrupts);
|
|
459
|
+
|
|
460
|
+
const specialDuration = result.filter(l => l.id === 'lunch_special')
|
|
461
|
+
.reduce((sum, l) => sum + l.duration, 0);
|
|
462
|
+
|
|
463
|
+
// 20% of hour = 720s (12 minutes)
|
|
464
|
+
expect(specialDuration).toBe(720);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('committed duration tracking', () => {
|
|
469
|
+
it('should track committed durations across multiple layouts', () => {
|
|
470
|
+
scheduler.addCommittedDuration('layout1', 100);
|
|
471
|
+
scheduler.addCommittedDuration('layout2', 200);
|
|
472
|
+
scheduler.addCommittedDuration('layout1', 50);
|
|
473
|
+
|
|
474
|
+
expect(scheduler.getCommittedDuration('layout1')).toBe(150);
|
|
475
|
+
expect(scheduler.getCommittedDuration('layout2')).toBe(200);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should return 0 for unknown layouts', () => {
|
|
479
|
+
expect(scheduler.getCommittedDuration('unknown')).toBe(0);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
});
|
package/src/overlays.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay Layout Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Manages overlay layouts that appear on top of main layouts.
|
|
5
|
+
* Based on upstream electron-player implementation.
|
|
6
|
+
*
|
|
7
|
+
* Overlays:
|
|
8
|
+
* - Render on top of main layout (higher z-index)
|
|
9
|
+
* - Have scheduled start/end times
|
|
10
|
+
* - Support priority ordering (multiple overlays)
|
|
11
|
+
* - Support criteria-based display (future)
|
|
12
|
+
* - Support geofencing (future)
|
|
13
|
+
*
|
|
14
|
+
* Reference: upstream_players/electron-player/src/main/xmds/response/schedule/events/overlayLayout.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
18
|
+
import { evaluateCriteria } from './criteria.js';
|
|
19
|
+
|
|
20
|
+
const logger = createLogger('schedule:overlays');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Overlay Scheduler
|
|
24
|
+
* Handles overlay layouts that display on top of main layouts
|
|
25
|
+
*/
|
|
26
|
+
export class OverlayScheduler {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.overlays = [];
|
|
29
|
+
this.displayProperties = {};
|
|
30
|
+
this.scheduleManager = null; // Reference to ScheduleManager for geo checks
|
|
31
|
+
logger.debug('OverlayScheduler initialized');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Set reference to ScheduleManager for geo-fence checks
|
|
36
|
+
* @param {ScheduleManager} scheduleManager
|
|
37
|
+
*/
|
|
38
|
+
setScheduleManager(scheduleManager) {
|
|
39
|
+
this.scheduleManager = scheduleManager;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Set display properties for criteria evaluation
|
|
44
|
+
* @param {Object} properties
|
|
45
|
+
*/
|
|
46
|
+
setDisplayProperties(properties) {
|
|
47
|
+
this.displayProperties = properties || {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Update overlays from XMDS Schedule response
|
|
52
|
+
* @param {Array} overlays - Overlay objects from XMDS
|
|
53
|
+
*/
|
|
54
|
+
setOverlays(overlays) {
|
|
55
|
+
this.overlays = overlays || [];
|
|
56
|
+
logger.info(`Loaded ${this.overlays.length} overlay(s)`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get currently active overlays
|
|
61
|
+
* @returns {Array} Active overlay objects sorted by priority (highest first)
|
|
62
|
+
*/
|
|
63
|
+
getCurrentOverlays() {
|
|
64
|
+
if (!this.overlays || this.overlays.length === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = new Date();
|
|
69
|
+
const activeOverlays = [];
|
|
70
|
+
|
|
71
|
+
for (const overlay of this.overlays) {
|
|
72
|
+
// Check time window
|
|
73
|
+
if (!this.isTimeActive(overlay, now)) {
|
|
74
|
+
logger.debug(`Overlay ${overlay.file} not in time window`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check geo-awareness
|
|
79
|
+
if (overlay.isGeoAware && overlay.geoLocation) {
|
|
80
|
+
if (this.scheduleManager && !this.scheduleManager.isWithinGeoFence(overlay.geoLocation)) {
|
|
81
|
+
logger.debug(`Overlay ${overlay.file} filtered by geofence`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check criteria conditions
|
|
87
|
+
if (overlay.criteria && overlay.criteria.length > 0) {
|
|
88
|
+
if (!evaluateCriteria(overlay.criteria, { now, displayProperties: this.displayProperties })) {
|
|
89
|
+
logger.debug(`Overlay ${overlay.file} filtered by criteria`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
activeOverlays.push(overlay);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Sort by priority (highest first)
|
|
98
|
+
activeOverlays.sort((a, b) => {
|
|
99
|
+
const priorityA = a.priority || 0;
|
|
100
|
+
const priorityB = b.priority || 0;
|
|
101
|
+
return priorityB - priorityA;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (activeOverlays.length > 0) {
|
|
105
|
+
logger.info(`Active overlays: ${activeOverlays.length}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return activeOverlays;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if overlay is within its time window
|
|
113
|
+
* @param {Object} overlay - Overlay object
|
|
114
|
+
* @param {Date} now - Current time
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
isTimeActive(overlay, now) {
|
|
118
|
+
const from = overlay.fromDt ? new Date(overlay.fromDt) : null;
|
|
119
|
+
const to = overlay.toDt ? new Date(overlay.toDt) : null;
|
|
120
|
+
|
|
121
|
+
// Check time bounds
|
|
122
|
+
if (from && now < from) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
if (to && now > to) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if overlay schedule needs update (every minute)
|
|
134
|
+
* @param {number} lastCheck - Last check timestamp
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
shouldCheckOverlays(lastCheck) {
|
|
138
|
+
if (!lastCheck) return true;
|
|
139
|
+
const elapsed = Date.now() - lastCheck;
|
|
140
|
+
return elapsed >= 60000; // 1 minute
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get overlay by file ID
|
|
145
|
+
* @param {number} fileId - Layout file ID
|
|
146
|
+
* @returns {Object|null}
|
|
147
|
+
*/
|
|
148
|
+
getOverlayByFile(fileId) {
|
|
149
|
+
return this.overlays.find(o => o.file === fileId) || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clear all overlays
|
|
154
|
+
*/
|
|
155
|
+
clear() {
|
|
156
|
+
this.overlays = [];
|
|
157
|
+
logger.debug('Cleared all overlays');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Process overlay layouts (compatibility method for interrupt scheduler pattern)
|
|
162
|
+
* @param {Array} layouts - Base layouts
|
|
163
|
+
* @param {Array} overlays - Overlay layouts
|
|
164
|
+
* @returns {Array} Layouts (unchanged, overlays are separate)
|
|
165
|
+
*/
|
|
166
|
+
processOverlays(layouts, overlays) {
|
|
167
|
+
// Overlays don't modify the main layout loop
|
|
168
|
+
// They are rendered separately on top
|
|
169
|
+
this.setOverlays(overlays);
|
|
170
|
+
return layouts;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const overlayScheduler = new OverlayScheduler();
|