@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,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for criteria evaluation and geo-fencing
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { evaluateCriteria } from './criteria.js';
|
|
6
|
+
import { ScheduleManager } from './schedule.js';
|
|
7
|
+
|
|
8
|
+
describe('Criteria Evaluator', () => {
|
|
9
|
+
describe('evaluateCriteria()', () => {
|
|
10
|
+
it('should return true for empty criteria', () => {
|
|
11
|
+
expect(evaluateCriteria([])).toBe(true);
|
|
12
|
+
expect(evaluateCriteria(null)).toBe(true);
|
|
13
|
+
expect(evaluateCriteria(undefined)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should evaluate dayOfWeek equals', () => {
|
|
17
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
18
|
+
const tuesday = new Date('2026-02-17T10:00:00'); // Tuesday
|
|
19
|
+
|
|
20
|
+
const criteria = [{ metric: 'dayOfWeek', condition: 'equals', type: 'string', value: 'Monday' }];
|
|
21
|
+
|
|
22
|
+
expect(evaluateCriteria(criteria, { now: monday })).toBe(true);
|
|
23
|
+
expect(evaluateCriteria(criteria, { now: tuesday })).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should evaluate dayOfWeek case-insensitively', () => {
|
|
27
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
28
|
+
const criteria = [{ metric: 'dayOfWeek', condition: 'equals', type: 'string', value: 'monday' }];
|
|
29
|
+
|
|
30
|
+
expect(evaluateCriteria(criteria, { now: monday })).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should evaluate hour as number', () => {
|
|
34
|
+
const morning = new Date('2026-02-16T09:30:00');
|
|
35
|
+
const evening = new Date('2026-02-16T18:30:00');
|
|
36
|
+
|
|
37
|
+
const criteria = [{ metric: 'hour', condition: 'lessThan', type: 'number', value: '12' }];
|
|
38
|
+
|
|
39
|
+
expect(evaluateCriteria(criteria, { now: morning })).toBe(true);
|
|
40
|
+
expect(evaluateCriteria(criteria, { now: evening })).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should evaluate month', () => {
|
|
44
|
+
const feb = new Date('2026-02-16T10:00:00');
|
|
45
|
+
const criteria = [{ metric: 'month', condition: 'equals', type: 'number', value: '2' }];
|
|
46
|
+
|
|
47
|
+
expect(evaluateCriteria(criteria, { now: feb })).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should evaluate dayOfMonth', () => {
|
|
51
|
+
const day16 = new Date('2026-02-16T10:00:00');
|
|
52
|
+
const criteria = [{ metric: 'dayOfMonth', condition: 'greaterThan', type: 'number', value: '15' }];
|
|
53
|
+
|
|
54
|
+
expect(evaluateCriteria(criteria, { now: day16 })).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should evaluate isoDay (1=Monday, 7=Sunday)', () => {
|
|
58
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
59
|
+
const sunday = new Date('2026-02-15T10:00:00'); // Sunday
|
|
60
|
+
|
|
61
|
+
expect(evaluateCriteria(
|
|
62
|
+
[{ metric: 'isoDay', condition: 'equals', type: 'number', value: '1' }],
|
|
63
|
+
{ now: monday }
|
|
64
|
+
)).toBe(true);
|
|
65
|
+
|
|
66
|
+
expect(evaluateCriteria(
|
|
67
|
+
[{ metric: 'isoDay', condition: 'equals', type: 'number', value: '7' }],
|
|
68
|
+
{ now: sunday }
|
|
69
|
+
)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should require ALL criteria to match (AND logic)', () => {
|
|
73
|
+
const mondayMorning = new Date('2026-02-16T09:00:00'); // Monday 9am
|
|
74
|
+
const mondayEvening = new Date('2026-02-16T18:00:00'); // Monday 6pm
|
|
75
|
+
|
|
76
|
+
const criteria = [
|
|
77
|
+
{ metric: 'dayOfWeek', condition: 'equals', type: 'string', value: 'Monday' },
|
|
78
|
+
{ metric: 'hour', condition: 'lessThan', type: 'number', value: '12' }
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
expect(evaluateCriteria(criteria, { now: mondayMorning })).toBe(true);
|
|
82
|
+
expect(evaluateCriteria(criteria, { now: mondayEvening })).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should evaluate display properties', () => {
|
|
86
|
+
const criteria = [{ metric: 'building', condition: 'equals', type: 'string', value: 'A' }];
|
|
87
|
+
|
|
88
|
+
expect(evaluateCriteria(criteria, {
|
|
89
|
+
displayProperties: { building: 'A' }
|
|
90
|
+
})).toBe(true);
|
|
91
|
+
|
|
92
|
+
expect(evaluateCriteria(criteria, {
|
|
93
|
+
displayProperties: { building: 'B' }
|
|
94
|
+
})).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should evaluate "in" condition with comma-separated list', () => {
|
|
98
|
+
const criteria = [{ metric: 'dayOfWeek', condition: 'in', type: 'string', value: 'Monday,Tuesday,Wednesday' }];
|
|
99
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
100
|
+
const saturday = new Date('2026-02-21T10:00:00'); // Saturday
|
|
101
|
+
|
|
102
|
+
expect(evaluateCriteria(criteria, { now: monday })).toBe(true);
|
|
103
|
+
expect(evaluateCriteria(criteria, { now: saturday })).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should evaluate contains condition', () => {
|
|
107
|
+
const criteria = [{ metric: 'location', condition: 'contains', type: 'string', value: 'floor' }];
|
|
108
|
+
|
|
109
|
+
expect(evaluateCriteria(criteria, {
|
|
110
|
+
displayProperties: { location: '3rd floor lobby' }
|
|
111
|
+
})).toBe(true);
|
|
112
|
+
|
|
113
|
+
expect(evaluateCriteria(criteria, {
|
|
114
|
+
displayProperties: { location: 'rooftop' }
|
|
115
|
+
})).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should evaluate notEquals condition', () => {
|
|
119
|
+
const criteria = [{ metric: 'dayOfWeek', condition: 'notEquals', type: 'string', value: 'Sunday' }];
|
|
120
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
121
|
+
const sunday = new Date('2026-02-15T10:00:00'); // Sunday
|
|
122
|
+
|
|
123
|
+
expect(evaluateCriteria(criteria, { now: monday })).toBe(true);
|
|
124
|
+
expect(evaluateCriteria(criteria, { now: sunday })).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return false for unknown metric without display property', () => {
|
|
128
|
+
const criteria = [{ metric: 'unknownMetric', condition: 'equals', type: 'string', value: 'test' }];
|
|
129
|
+
expect(evaluateCriteria(criteria)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('Geo-fencing', () => {
|
|
135
|
+
describe('haversineDistance()', () => {
|
|
136
|
+
it('should calculate distance between two points', () => {
|
|
137
|
+
const sm = new ScheduleManager();
|
|
138
|
+
// Barcelona to Tarragona: ~83km
|
|
139
|
+
const distance = sm.haversineDistance(41.3851, 2.1734, 41.1189, 1.2445);
|
|
140
|
+
expect(distance).toBeGreaterThan(80000);
|
|
141
|
+
expect(distance).toBeLessThan(90000);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should return 0 for same point', () => {
|
|
145
|
+
const sm = new ScheduleManager();
|
|
146
|
+
expect(sm.haversineDistance(41.3851, 2.1734, 41.3851, 2.1734)).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('isWithinGeoFence()', () => {
|
|
151
|
+
it('should return true when no location set (permissive)', () => {
|
|
152
|
+
const sm = new ScheduleManager();
|
|
153
|
+
expect(sm.isWithinGeoFence('41.3851,2.1734')).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return true when within radius', () => {
|
|
157
|
+
const sm = new ScheduleManager();
|
|
158
|
+
// Set location to Barcelona center
|
|
159
|
+
sm.setLocation(41.3851, 2.1734);
|
|
160
|
+
// Check geofence at Barcelona center (0 meters away)
|
|
161
|
+
expect(sm.isWithinGeoFence('41.3851,2.1734')).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return false when outside radius', () => {
|
|
165
|
+
const sm = new ScheduleManager();
|
|
166
|
+
// Set location to Barcelona
|
|
167
|
+
sm.setLocation(41.3851, 2.1734);
|
|
168
|
+
// Check geofence at Tarragona (~98km away, default radius 500m)
|
|
169
|
+
expect(sm.isWithinGeoFence('41.1189,1.2445')).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should respect custom radius in geoLocation string', () => {
|
|
173
|
+
const sm = new ScheduleManager();
|
|
174
|
+
sm.setLocation(41.3851, 2.1734);
|
|
175
|
+
// 200km radius should include Tarragona
|
|
176
|
+
expect(sm.isWithinGeoFence('41.1189,1.2445,200000')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should handle invalid geoLocation format gracefully', () => {
|
|
180
|
+
const sm = new ScheduleManager();
|
|
181
|
+
sm.setLocation(41.3851, 2.1734);
|
|
182
|
+
expect(sm.isWithinGeoFence('')).toBe(true);
|
|
183
|
+
expect(sm.isWithinGeoFence('invalid')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('getCurrentLayouts() with geo-fencing', () => {
|
|
188
|
+
it('should filter layouts by geofence', () => {
|
|
189
|
+
const sm = new ScheduleManager();
|
|
190
|
+
sm.setLocation(41.3851, 2.1734); // Barcelona
|
|
191
|
+
|
|
192
|
+
const now = new Date('2026-02-16T10:00:00');
|
|
193
|
+
sm.setSchedule({
|
|
194
|
+
default: '0',
|
|
195
|
+
layouts: [
|
|
196
|
+
{
|
|
197
|
+
id: '100', file: '100', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
198
|
+
priority: 5, scheduleid: '1', maxPlaysPerHour: 0,
|
|
199
|
+
isGeoAware: true, geoLocation: '41.3851,2.1734,1000', criteria: []
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: '200', file: '200', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
203
|
+
priority: 5, scheduleid: '2', maxPlaysPerHour: 0,
|
|
204
|
+
isGeoAware: true, geoLocation: '40.4168,-3.7038,500', criteria: [] // Madrid
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
campaigns: []
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Mock Date
|
|
211
|
+
const origDate = global.Date;
|
|
212
|
+
global.Date = class extends origDate {
|
|
213
|
+
constructor(...args) {
|
|
214
|
+
if (args.length === 0) return now;
|
|
215
|
+
return new origDate(...args);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const layouts = sm.getCurrentLayouts();
|
|
220
|
+
global.Date = origDate;
|
|
221
|
+
|
|
222
|
+
// Only Barcelona layout should be included
|
|
223
|
+
expect(layouts).toContain('100');
|
|
224
|
+
expect(layouts).not.toContain('200');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should filter layouts by criteria', () => {
|
|
228
|
+
const sm = new ScheduleManager();
|
|
229
|
+
const monday = new Date('2026-02-16T10:00:00'); // Monday
|
|
230
|
+
|
|
231
|
+
sm.setSchedule({
|
|
232
|
+
default: '0',
|
|
233
|
+
layouts: [
|
|
234
|
+
{
|
|
235
|
+
id: '100', file: '100', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
236
|
+
priority: 5, scheduleid: '1', maxPlaysPerHour: 0,
|
|
237
|
+
isGeoAware: false, geoLocation: '',
|
|
238
|
+
criteria: [{ metric: 'dayOfWeek', condition: 'equals', type: 'string', value: 'Monday' }]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: '200', file: '200', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
242
|
+
priority: 5, scheduleid: '2', maxPlaysPerHour: 0,
|
|
243
|
+
isGeoAware: false, geoLocation: '',
|
|
244
|
+
criteria: [{ metric: 'dayOfWeek', condition: 'equals', type: 'string', value: 'Tuesday' }]
|
|
245
|
+
}
|
|
246
|
+
],
|
|
247
|
+
campaigns: []
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Mock Date
|
|
251
|
+
const origDate = global.Date;
|
|
252
|
+
global.Date = class extends origDate {
|
|
253
|
+
constructor(...args) {
|
|
254
|
+
if (args.length === 0) return monday;
|
|
255
|
+
return new origDate(...args);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const layouts = sm.getCurrentLayouts();
|
|
260
|
+
global.Date = origDate;
|
|
261
|
+
|
|
262
|
+
expect(layouts).toContain('100');
|
|
263
|
+
expect(layouts).not.toContain('200');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('Sync Event Metadata', () => {
|
|
269
|
+
it('should track syncEvent in layout metadata', () => {
|
|
270
|
+
const sm = new ScheduleManager();
|
|
271
|
+
const now = new Date('2026-02-16T10:00:00');
|
|
272
|
+
|
|
273
|
+
sm.setSchedule({
|
|
274
|
+
default: '0',
|
|
275
|
+
layouts: [
|
|
276
|
+
{
|
|
277
|
+
id: '100', file: '100', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
278
|
+
priority: 5, scheduleid: '1', maxPlaysPerHour: 0,
|
|
279
|
+
isGeoAware: false, geoLocation: '', syncEvent: true, shareOfVoice: 0, criteria: []
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: '200', file: '200', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
283
|
+
priority: 5, scheduleid: '2', maxPlaysPerHour: 0,
|
|
284
|
+
isGeoAware: false, geoLocation: '', syncEvent: false, shareOfVoice: 0, criteria: []
|
|
285
|
+
}
|
|
286
|
+
],
|
|
287
|
+
campaigns: []
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const origDate = global.Date;
|
|
291
|
+
global.Date = class extends origDate {
|
|
292
|
+
constructor(...args) {
|
|
293
|
+
if (args.length === 0) return now;
|
|
294
|
+
return new origDate(...args);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const layouts = sm.getCurrentLayouts();
|
|
299
|
+
global.Date = origDate;
|
|
300
|
+
|
|
301
|
+
expect(layouts).toContain('100');
|
|
302
|
+
expect(layouts).toContain('200');
|
|
303
|
+
|
|
304
|
+
// Check sync metadata
|
|
305
|
+
expect(sm.isSyncEvent('100')).toBe(true);
|
|
306
|
+
expect(sm.isSyncEvent('200')).toBe(false);
|
|
307
|
+
expect(sm.hasSyncEvents()).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should return false for hasSyncEvents when no sync events', () => {
|
|
311
|
+
const sm = new ScheduleManager();
|
|
312
|
+
const now = new Date('2026-02-16T10:00:00');
|
|
313
|
+
|
|
314
|
+
sm.setSchedule({
|
|
315
|
+
default: '0',
|
|
316
|
+
layouts: [
|
|
317
|
+
{
|
|
318
|
+
id: '100', file: '100', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
319
|
+
priority: 5, scheduleid: '1', maxPlaysPerHour: 0,
|
|
320
|
+
isGeoAware: false, geoLocation: '', syncEvent: false, shareOfVoice: 0, criteria: []
|
|
321
|
+
}
|
|
322
|
+
],
|
|
323
|
+
campaigns: []
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const origDate = global.Date;
|
|
327
|
+
global.Date = class extends origDate {
|
|
328
|
+
constructor(...args) {
|
|
329
|
+
if (args.length === 0) return now;
|
|
330
|
+
return new origDate(...args);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
sm.getCurrentLayouts();
|
|
335
|
+
global.Date = origDate;
|
|
336
|
+
|
|
337
|
+
expect(sm.hasSyncEvents()).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should expose layout metadata with getLayoutMetadata', () => {
|
|
341
|
+
const sm = new ScheduleManager();
|
|
342
|
+
const now = new Date('2026-02-16T10:00:00');
|
|
343
|
+
|
|
344
|
+
sm.setSchedule({
|
|
345
|
+
default: '0',
|
|
346
|
+
layouts: [
|
|
347
|
+
{
|
|
348
|
+
id: '100', file: '100', fromdt: '2026-01-01 00:00:00', todt: '2027-01-01 00:00:00',
|
|
349
|
+
priority: 5, scheduleid: '1', maxPlaysPerHour: 0,
|
|
350
|
+
isGeoAware: false, geoLocation: '', syncEvent: true, shareOfVoice: 30, criteria: []
|
|
351
|
+
}
|
|
352
|
+
],
|
|
353
|
+
campaigns: []
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const origDate = global.Date;
|
|
357
|
+
global.Date = class extends origDate {
|
|
358
|
+
constructor(...args) {
|
|
359
|
+
if (args.length === 0) return now;
|
|
360
|
+
return new origDate(...args);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
sm.getCurrentLayouts();
|
|
365
|
+
global.Date = origDate;
|
|
366
|
+
|
|
367
|
+
const meta = sm.getLayoutMetadata('100');
|
|
368
|
+
expect(meta).not.toBeNull();
|
|
369
|
+
expect(meta.syncEvent).toBe(true);
|
|
370
|
+
expect(meta.shareOfVoice).toBe(30);
|
|
371
|
+
expect(meta.priority).toBe(5);
|
|
372
|
+
|
|
373
|
+
// Unknown layout returns null
|
|
374
|
+
expect(sm.getLayoutMetadata('999')).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @xiboplayer/schedule - Campaign scheduling and advanced features
|
|
2
|
+
// Basic scheduling, interrupts, overlays, and dayparting
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Core schedule manager for basic scheduling and dayparting
|
|
6
|
+
* @module @xiboplayer/schedule
|
|
7
|
+
*/
|
|
8
|
+
export { ScheduleManager, scheduleManager } from './schedule.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Interrupt scheduler for shareOfVoice layouts
|
|
12
|
+
* @module @xiboplayer/schedule/interrupts
|
|
13
|
+
*/
|
|
14
|
+
export { InterruptScheduler } from './interrupts.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Overlay layout scheduler
|
|
18
|
+
* @module @xiboplayer/schedule/overlays
|
|
19
|
+
*/
|
|
20
|
+
export { OverlayScheduler } from './overlays.js';
|