@xiboplayer/stats 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/README.md +375 -0
- package/package.json +37 -0
- package/src/index.js +13 -0
- package/src/log-reporter.js +541 -0
- package/src/log-reporter.test.js +484 -0
- package/src/stats-collector.js +633 -0
- package/src/stats-collector.test.js +461 -0
- package/vitest.config.js +9 -0
- package/vitest.setup.js +2 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for StatsCollector and formatStats
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { StatsCollector, formatStats } from './stats-collector.js';
|
|
7
|
+
|
|
8
|
+
describe('StatsCollector', () => {
|
|
9
|
+
let collector;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
collector = new StatsCollector();
|
|
13
|
+
await collector.init();
|
|
14
|
+
await collector.clearAllStats();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
if (collector && collector.db) {
|
|
19
|
+
await collector.clearAllStats();
|
|
20
|
+
collector.db.close();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('constructor and initialization', () => {
|
|
25
|
+
it('should create a new collector', () => {
|
|
26
|
+
const c = new StatsCollector();
|
|
27
|
+
expect(c).toBeDefined();
|
|
28
|
+
expect(c.db).toBeNull();
|
|
29
|
+
expect(c.inProgressStats).toBeInstanceOf(Map);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should initialize IndexedDB', async () => {
|
|
33
|
+
const c = new StatsCollector();
|
|
34
|
+
await c.init();
|
|
35
|
+
expect(c.db).toBeDefined();
|
|
36
|
+
expect(c.db.name).toBe('xibo-player-stats');
|
|
37
|
+
c.db.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should be idempotent (safe to call init multiple times)', async () => {
|
|
41
|
+
await collector.init();
|
|
42
|
+
await collector.init();
|
|
43
|
+
expect(collector.db).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle missing IndexedDB gracefully', async () => {
|
|
47
|
+
const originalIndexedDB = global.indexedDB;
|
|
48
|
+
global.indexedDB = undefined;
|
|
49
|
+
|
|
50
|
+
const c = new StatsCollector();
|
|
51
|
+
await expect(c.init()).rejects.toThrow('IndexedDB not available');
|
|
52
|
+
|
|
53
|
+
global.indexedDB = originalIndexedDB;
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('layout tracking', () => {
|
|
58
|
+
it('should start tracking a layout', async () => {
|
|
59
|
+
await collector.startLayout(123, 456);
|
|
60
|
+
|
|
61
|
+
const key = 'layout-123';
|
|
62
|
+
expect(collector.inProgressStats.has(key)).toBe(true);
|
|
63
|
+
|
|
64
|
+
const stat = collector.inProgressStats.get(key);
|
|
65
|
+
expect(stat.type).toBe('layout');
|
|
66
|
+
expect(stat.layoutId).toBe(123);
|
|
67
|
+
expect(stat.scheduleId).toBe(456);
|
|
68
|
+
expect(stat.start).toBeInstanceOf(Date);
|
|
69
|
+
expect(stat.end).toBeNull();
|
|
70
|
+
expect(stat.count).toBe(1);
|
|
71
|
+
expect(stat.submitted).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should warn on duplicate start', async () => {
|
|
75
|
+
await collector.startLayout(123, 456);
|
|
76
|
+
await collector.startLayout(123, 456);
|
|
77
|
+
|
|
78
|
+
// Should still have only one entry
|
|
79
|
+
expect(collector.inProgressStats.size).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should end tracking a layout', async () => {
|
|
83
|
+
await collector.startLayout(123, 456);
|
|
84
|
+
|
|
85
|
+
// Wait a bit to ensure duration > 0
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
87
|
+
|
|
88
|
+
await collector.endLayout(123, 456);
|
|
89
|
+
|
|
90
|
+
// Should be removed from in-progress
|
|
91
|
+
const key = 'layout-123';
|
|
92
|
+
expect(collector.inProgressStats.has(key)).toBe(false);
|
|
93
|
+
|
|
94
|
+
// Should be saved to database
|
|
95
|
+
const stats = await collector.getAllStats();
|
|
96
|
+
expect(stats.length).toBe(1);
|
|
97
|
+
expect(stats[0].type).toBe('layout');
|
|
98
|
+
expect(stats[0].layoutId).toBe(123);
|
|
99
|
+
expect(stats[0].scheduleId).toBe(456);
|
|
100
|
+
expect(stats[0].duration).toBeGreaterThanOrEqual(0); // Allow 0 for fast execution
|
|
101
|
+
expect(stats[0].end).toBeInstanceOf(Date);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle end without start', async () => {
|
|
105
|
+
// Should not throw
|
|
106
|
+
await collector.endLayout(999, 888);
|
|
107
|
+
|
|
108
|
+
const stats = await collector.getAllStats();
|
|
109
|
+
expect(stats.length).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should calculate duration correctly', async () => {
|
|
113
|
+
await collector.startLayout(123, 456);
|
|
114
|
+
|
|
115
|
+
// Wait 1 second
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
117
|
+
|
|
118
|
+
await collector.endLayout(123, 456);
|
|
119
|
+
|
|
120
|
+
const stats = await collector.getAllStats();
|
|
121
|
+
expect(stats[0].duration).toBeGreaterThanOrEqual(1);
|
|
122
|
+
expect(stats[0].duration).toBeLessThan(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should track multiple layouts simultaneously', async () => {
|
|
126
|
+
await collector.startLayout(123, 456);
|
|
127
|
+
await collector.startLayout(789, 456);
|
|
128
|
+
|
|
129
|
+
expect(collector.inProgressStats.size).toBe(2);
|
|
130
|
+
|
|
131
|
+
await collector.endLayout(123, 456);
|
|
132
|
+
expect(collector.inProgressStats.size).toBe(1);
|
|
133
|
+
|
|
134
|
+
await collector.endLayout(789, 456);
|
|
135
|
+
expect(collector.inProgressStats.size).toBe(0);
|
|
136
|
+
|
|
137
|
+
const stats = await collector.getAllStats();
|
|
138
|
+
expect(stats.length).toBe(2);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('widget tracking', () => {
|
|
143
|
+
it('should start tracking a widget', async () => {
|
|
144
|
+
await collector.startWidget(111, 123, 456);
|
|
145
|
+
|
|
146
|
+
const key = 'media-111-123';
|
|
147
|
+
expect(collector.inProgressStats.has(key)).toBe(true);
|
|
148
|
+
|
|
149
|
+
const stat = collector.inProgressStats.get(key);
|
|
150
|
+
expect(stat.type).toBe('media');
|
|
151
|
+
expect(stat.mediaId).toBe(111);
|
|
152
|
+
expect(stat.layoutId).toBe(123);
|
|
153
|
+
expect(stat.scheduleId).toBe(456);
|
|
154
|
+
expect(stat.start).toBeInstanceOf(Date);
|
|
155
|
+
expect(stat.end).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should end tracking a widget', async () => {
|
|
159
|
+
await collector.startWidget(111, 123, 456);
|
|
160
|
+
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
162
|
+
|
|
163
|
+
await collector.endWidget(111, 123, 456);
|
|
164
|
+
|
|
165
|
+
const key = 'media-111-123';
|
|
166
|
+
expect(collector.inProgressStats.has(key)).toBe(false);
|
|
167
|
+
|
|
168
|
+
const stats = await collector.getAllStats();
|
|
169
|
+
expect(stats.length).toBe(1);
|
|
170
|
+
expect(stats[0].type).toBe('media');
|
|
171
|
+
expect(stats[0].mediaId).toBe(111);
|
|
172
|
+
expect(stats[0].duration).toBeGreaterThanOrEqual(0); // Allow 0 for fast execution
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should handle multiple widgets in same layout', async () => {
|
|
176
|
+
await collector.startWidget(111, 123, 456);
|
|
177
|
+
await collector.startWidget(222, 123, 456);
|
|
178
|
+
await collector.startWidget(333, 123, 456);
|
|
179
|
+
|
|
180
|
+
expect(collector.inProgressStats.size).toBe(3);
|
|
181
|
+
|
|
182
|
+
await collector.endWidget(111, 123, 456);
|
|
183
|
+
await collector.endWidget(222, 123, 456);
|
|
184
|
+
await collector.endWidget(333, 123, 456);
|
|
185
|
+
|
|
186
|
+
const stats = await collector.getAllStats();
|
|
187
|
+
expect(stats.length).toBe(3);
|
|
188
|
+
expect(stats.every(s => s.type === 'media')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('stats submission flow', () => {
|
|
193
|
+
it('should get unsubmitted stats', async () => {
|
|
194
|
+
// Create some stats
|
|
195
|
+
await collector.startLayout(123, 456);
|
|
196
|
+
await collector.endLayout(123, 456);
|
|
197
|
+
|
|
198
|
+
await collector.startLayout(789, 456);
|
|
199
|
+
await collector.endLayout(789, 456);
|
|
200
|
+
|
|
201
|
+
const stats = await collector.getStatsForSubmission();
|
|
202
|
+
expect(stats.length).toBe(2);
|
|
203
|
+
expect(stats.every(s => s.submitted === 0)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should respect limit parameter', async () => {
|
|
207
|
+
// Create 5 stats
|
|
208
|
+
for (let i = 0; i < 5; i++) {
|
|
209
|
+
await collector.startLayout(100 + i, 456);
|
|
210
|
+
await collector.endLayout(100 + i, 456);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const stats = await collector.getStatsForSubmission(3);
|
|
214
|
+
expect(stats.length).toBe(3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should return empty array when no unsubmitted stats', async () => {
|
|
218
|
+
const stats = await collector.getStatsForSubmission();
|
|
219
|
+
expect(stats).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should clear submitted stats', async () => {
|
|
223
|
+
// Create stats
|
|
224
|
+
await collector.startLayout(123, 456);
|
|
225
|
+
await collector.endLayout(123, 456);
|
|
226
|
+
|
|
227
|
+
await collector.startLayout(789, 456);
|
|
228
|
+
await collector.endLayout(789, 456);
|
|
229
|
+
|
|
230
|
+
// Get stats
|
|
231
|
+
const stats = await collector.getStatsForSubmission();
|
|
232
|
+
expect(stats.length).toBe(2);
|
|
233
|
+
|
|
234
|
+
// Clear them
|
|
235
|
+
await collector.clearSubmittedStats(stats);
|
|
236
|
+
|
|
237
|
+
// Verify they're gone
|
|
238
|
+
const remaining = await collector.getAllStats();
|
|
239
|
+
expect(remaining.length).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should handle clearing empty array', async () => {
|
|
243
|
+
await collector.clearSubmittedStats([]);
|
|
244
|
+
// Should not throw
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle clearing null', async () => {
|
|
248
|
+
await collector.clearSubmittedStats(null);
|
|
249
|
+
// Should not throw
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('edge cases', () => {
|
|
254
|
+
it('should handle operations without initialization', async () => {
|
|
255
|
+
const c = new StatsCollector();
|
|
256
|
+
|
|
257
|
+
// Should not throw, but should log warnings
|
|
258
|
+
await c.startLayout(123, 456);
|
|
259
|
+
await c.endLayout(123, 456);
|
|
260
|
+
await c.startWidget(111, 123, 456);
|
|
261
|
+
await c.endWidget(111, 123, 456);
|
|
262
|
+
|
|
263
|
+
const stats = await c.getStatsForSubmission();
|
|
264
|
+
expect(stats).toEqual([]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should handle interrupted playback', async () => {
|
|
268
|
+
// Start layout but never end it
|
|
269
|
+
await collector.startLayout(123, 456);
|
|
270
|
+
|
|
271
|
+
// Create a new collector (simulating app restart)
|
|
272
|
+
const newCollector = new StatsCollector();
|
|
273
|
+
await newCollector.init();
|
|
274
|
+
|
|
275
|
+
// Should still be able to track new stats
|
|
276
|
+
await newCollector.startLayout(789, 456);
|
|
277
|
+
await newCollector.endLayout(789, 456);
|
|
278
|
+
|
|
279
|
+
const stats = await newCollector.getAllStats();
|
|
280
|
+
expect(stats.length).toBe(1);
|
|
281
|
+
expect(stats[0].layoutId).toBe(789);
|
|
282
|
+
|
|
283
|
+
newCollector.db.close();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle invalid stat IDs in clearSubmittedStats', async () => {
|
|
287
|
+
const invalidStats = [
|
|
288
|
+
{ id: null, layoutId: 123 },
|
|
289
|
+
{ id: undefined, layoutId: 456 },
|
|
290
|
+
{ layoutId: 789 } // No id property
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
// Should not throw
|
|
294
|
+
await collector.clearSubmittedStats(invalidStats);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('database operations', () => {
|
|
299
|
+
it('should get all stats', async () => {
|
|
300
|
+
// Create mixed stats
|
|
301
|
+
await collector.startLayout(123, 456);
|
|
302
|
+
await collector.endLayout(123, 456);
|
|
303
|
+
|
|
304
|
+
await collector.startWidget(111, 123, 456);
|
|
305
|
+
await collector.endWidget(111, 123, 456);
|
|
306
|
+
|
|
307
|
+
const stats = await collector.getAllStats();
|
|
308
|
+
expect(stats.length).toBe(2);
|
|
309
|
+
expect(stats.some(s => s.type === 'layout')).toBe(true);
|
|
310
|
+
expect(stats.some(s => s.type === 'media')).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should clear all stats', async () => {
|
|
314
|
+
// Create stats
|
|
315
|
+
await collector.startLayout(123, 456);
|
|
316
|
+
await collector.endLayout(123, 456);
|
|
317
|
+
|
|
318
|
+
await collector.startWidget(111, 123, 456);
|
|
319
|
+
await collector.endWidget(111, 123, 456);
|
|
320
|
+
|
|
321
|
+
await collector.clearAllStats();
|
|
322
|
+
|
|
323
|
+
const stats = await collector.getAllStats();
|
|
324
|
+
expect(stats.length).toBe(0);
|
|
325
|
+
expect(collector.inProgressStats.size).toBe(0);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('formatStats', () => {
|
|
331
|
+
it('should format empty stats', () => {
|
|
332
|
+
const xml = formatStats([]);
|
|
333
|
+
expect(xml).toBe('<stats></stats>');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should format null stats', () => {
|
|
337
|
+
const xml = formatStats(null);
|
|
338
|
+
expect(xml).toBe('<stats></stats>');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should format layout stat', () => {
|
|
342
|
+
const stats = [{
|
|
343
|
+
type: 'layout',
|
|
344
|
+
layoutId: 123,
|
|
345
|
+
scheduleId: 456,
|
|
346
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
347
|
+
end: new Date('2026-02-10T12:05:00Z'),
|
|
348
|
+
duration: 300,
|
|
349
|
+
count: 1
|
|
350
|
+
}];
|
|
351
|
+
|
|
352
|
+
const xml = formatStats(stats);
|
|
353
|
+
expect(xml).toContain('<stats>');
|
|
354
|
+
expect(xml).toContain('</stats>');
|
|
355
|
+
expect(xml).toContain('type="layout"');
|
|
356
|
+
expect(xml).toContain('layoutid="123"');
|
|
357
|
+
expect(xml).toContain('scheduleid="456"');
|
|
358
|
+
expect(xml).toContain('duration="300"');
|
|
359
|
+
expect(xml).toContain('count="1"');
|
|
360
|
+
expect(xml).toContain('fromdt=');
|
|
361
|
+
expect(xml).toContain('todt=');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should format media stat', () => {
|
|
365
|
+
const stats = [{
|
|
366
|
+
type: 'media',
|
|
367
|
+
mediaId: 789,
|
|
368
|
+
layoutId: 123,
|
|
369
|
+
scheduleId: 456,
|
|
370
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
371
|
+
end: new Date('2026-02-10T12:01:00Z'),
|
|
372
|
+
duration: 60,
|
|
373
|
+
count: 1
|
|
374
|
+
}];
|
|
375
|
+
|
|
376
|
+
const xml = formatStats(stats);
|
|
377
|
+
expect(xml).toContain('type="media"');
|
|
378
|
+
expect(xml).toContain('mediaid="789"');
|
|
379
|
+
expect(xml).toContain('layoutid="123"');
|
|
380
|
+
expect(xml).toContain('duration="60"');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should format multiple stats', () => {
|
|
384
|
+
const stats = [
|
|
385
|
+
{
|
|
386
|
+
type: 'layout',
|
|
387
|
+
layoutId: 123,
|
|
388
|
+
scheduleId: 456,
|
|
389
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
390
|
+
end: new Date('2026-02-10T12:05:00Z'),
|
|
391
|
+
duration: 300,
|
|
392
|
+
count: 1
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
type: 'media',
|
|
396
|
+
mediaId: 789,
|
|
397
|
+
layoutId: 123,
|
|
398
|
+
scheduleId: 456,
|
|
399
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
400
|
+
end: new Date('2026-02-10T12:01:00Z'),
|
|
401
|
+
duration: 60,
|
|
402
|
+
count: 1
|
|
403
|
+
}
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
const xml = formatStats(stats);
|
|
407
|
+
const statCount = (xml.match(/<stat /g) || []).length;
|
|
408
|
+
expect(statCount).toBe(2);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should escape XML special characters', () => {
|
|
412
|
+
const stats = [{
|
|
413
|
+
type: 'layout',
|
|
414
|
+
layoutId: 123,
|
|
415
|
+
scheduleId: 456,
|
|
416
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
417
|
+
end: new Date('2026-02-10T12:00:00Z'),
|
|
418
|
+
duration: 0,
|
|
419
|
+
count: 1
|
|
420
|
+
}];
|
|
421
|
+
|
|
422
|
+
const xml = formatStats(stats);
|
|
423
|
+
// Should not contain unescaped characters
|
|
424
|
+
expect(xml).not.toContain('&amp;');
|
|
425
|
+
expect(xml).not.toContain('<lt;');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should format dates correctly', () => {
|
|
429
|
+
const stats = [{
|
|
430
|
+
type: 'layout',
|
|
431
|
+
layoutId: 123,
|
|
432
|
+
scheduleId: 456,
|
|
433
|
+
start: new Date('2026-02-10T12:34:56Z'),
|
|
434
|
+
end: new Date('2026-02-10T12:35:56Z'),
|
|
435
|
+
duration: 60,
|
|
436
|
+
count: 1
|
|
437
|
+
}];
|
|
438
|
+
|
|
439
|
+
const xml = formatStats(stats);
|
|
440
|
+
// Should contain date in YYYY-MM-DD HH:MM:SS format
|
|
441
|
+
expect(xml).toMatch(/fromdt="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
|
|
442
|
+
expect(xml).toMatch(/todt="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should handle missing end date', () => {
|
|
446
|
+
const stats = [{
|
|
447
|
+
type: 'layout',
|
|
448
|
+
layoutId: 123,
|
|
449
|
+
scheduleId: 456,
|
|
450
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
451
|
+
end: null,
|
|
452
|
+
duration: 0,
|
|
453
|
+
count: 1
|
|
454
|
+
}];
|
|
455
|
+
|
|
456
|
+
const xml = formatStats(stats);
|
|
457
|
+
// Should use start date for both fromdt and todt
|
|
458
|
+
expect(xml).toContain('fromdt=');
|
|
459
|
+
expect(xml).toContain('todt=');
|
|
460
|
+
});
|
|
461
|
+
});
|
package/vitest.config.js
ADDED
package/vitest.setup.js
ADDED