@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.
@@ -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';