@xiboplayer/stats 0.3.7 → 0.4.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/package.json +2 -2
- package/src/index.js +1 -1
- package/src/log-reporter.js +111 -14
- package/src/log-reporter.test.js +147 -11
- package/src/stats-collector.js +147 -12
- package/src/stats-collector.test.js +228 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/stats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Proof of play tracking, stats reporting, and CMS logging",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./collector": "./src/stats-collector.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@xiboplayer/utils": "0.
|
|
12
|
+
"@xiboplayer/utils": "0.4.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"vitest": "^2.0.0",
|
package/src/index.js
CHANGED
|
@@ -12,4 +12,4 @@ export { StatsCollector, formatStats } from './stats-collector.js';
|
|
|
12
12
|
* Log reporter for CMS logging
|
|
13
13
|
* @module @xiboplayer/stats/logger
|
|
14
14
|
*/
|
|
15
|
-
export { LogReporter, formatLogs } from './log-reporter.js';
|
|
15
|
+
export { LogReporter, formatLogs, formatFaults } from './log-reporter.js';
|
package/src/log-reporter.js
CHANGED
|
@@ -171,7 +171,7 @@ export class LogReporter {
|
|
|
171
171
|
|
|
172
172
|
this._reportedFaults.set(code, Date.now());
|
|
173
173
|
|
|
174
|
-
await this.log('error', reason, '
|
|
174
|
+
await this.log('error', reason, 'event', {
|
|
175
175
|
alertType: 'Player Fault',
|
|
176
176
|
eventType: code
|
|
177
177
|
});
|
|
@@ -179,6 +179,46 @@ export class LogReporter {
|
|
|
179
179
|
log.info(`Fault reported: ${code} - ${reason}`);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Get unsubmitted fault entries for dedicated fault submission.
|
|
184
|
+
* Returns log entries that have alertType='Player Fault' and submitted=0.
|
|
185
|
+
* These are the high-priority entries that should be submitted faster
|
|
186
|
+
* than the normal log collection cycle.
|
|
187
|
+
*
|
|
188
|
+
* @param {number} [limit=10] - Maximum faults to return per batch
|
|
189
|
+
* @returns {Promise<Array>} Array of fault log objects
|
|
190
|
+
*/
|
|
191
|
+
async getFaultsForSubmission(limit = 10) {
|
|
192
|
+
if (!this.db) return [];
|
|
193
|
+
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readonly');
|
|
196
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
197
|
+
const index = store.index('submitted');
|
|
198
|
+
|
|
199
|
+
const request = index.openCursor(IDBKeyRange.only(0));
|
|
200
|
+
const faults = [];
|
|
201
|
+
|
|
202
|
+
request.onsuccess = (event) => {
|
|
203
|
+
const cursor = event.target.result;
|
|
204
|
+
|
|
205
|
+
if (cursor && faults.length < limit) {
|
|
206
|
+
if (cursor.value.alertType === 'Player Fault') {
|
|
207
|
+
faults.push(cursor.value);
|
|
208
|
+
}
|
|
209
|
+
cursor.continue();
|
|
210
|
+
} else {
|
|
211
|
+
resolve(faults);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
request.onerror = () => {
|
|
216
|
+
log.error('Failed to retrieve faults:', request.error);
|
|
217
|
+
reject(new Error(`Failed to retrieve faults: ${request.error}`));
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
182
222
|
/**
|
|
183
223
|
* Log an error message
|
|
184
224
|
*
|
|
@@ -234,13 +274,12 @@ export class LogReporter {
|
|
|
234
274
|
/**
|
|
235
275
|
* Get logs ready for submission to CMS
|
|
236
276
|
*
|
|
237
|
-
* Returns unsubmitted logs up to the
|
|
238
|
-
* Logs are ordered by ID (oldest first).
|
|
277
|
+
* Returns unsubmitted logs up to the spec limit of 50 per batch.
|
|
239
278
|
*
|
|
240
|
-
* @param {number} limit - Maximum number of logs to return (
|
|
279
|
+
* @param {number} [limit=50] - Maximum number of logs to return (spec max: 50)
|
|
241
280
|
* @returns {Promise<Array>} Array of log objects
|
|
242
281
|
*/
|
|
243
|
-
async getLogsForSubmission(limit =
|
|
282
|
+
async getLogsForSubmission(limit = 50) {
|
|
244
283
|
if (!this.db) {
|
|
245
284
|
log.warn('Logs database not initialized');
|
|
246
285
|
return [];
|
|
@@ -262,7 +301,7 @@ export class LogReporter {
|
|
|
262
301
|
logs.push(cursor.value);
|
|
263
302
|
cursor.continue();
|
|
264
303
|
} else {
|
|
265
|
-
log.debug(`Retrieved ${logs.length} unsubmitted logs`);
|
|
304
|
+
log.debug(`Retrieved ${logs.length} unsubmitted logs (limit: ${limit})`);
|
|
266
305
|
resolve(logs);
|
|
267
306
|
}
|
|
268
307
|
};
|
|
@@ -274,6 +313,25 @@ export class LogReporter {
|
|
|
274
313
|
});
|
|
275
314
|
}
|
|
276
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Count unsubmitted logs in the database.
|
|
318
|
+
* @returns {Promise<number>}
|
|
319
|
+
*/
|
|
320
|
+
async _countUnsubmitted() {
|
|
321
|
+
return new Promise((resolve) => {
|
|
322
|
+
try {
|
|
323
|
+
const transaction = this.db.transaction([LOGS_STORE], 'readonly');
|
|
324
|
+
const store = transaction.objectStore(LOGS_STORE);
|
|
325
|
+
const index = store.index('submitted');
|
|
326
|
+
const request = index.count(IDBKeyRange.only(0));
|
|
327
|
+
request.onsuccess = () => resolve(request.result);
|
|
328
|
+
request.onerror = () => resolve(0);
|
|
329
|
+
} catch (_) {
|
|
330
|
+
resolve(0);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
277
335
|
/**
|
|
278
336
|
* Clear submitted logs from database
|
|
279
337
|
*
|
|
@@ -457,11 +515,15 @@ export class LogReporter {
|
|
|
457
515
|
*
|
|
458
516
|
* Converts array of log objects to XML format expected by CMS.
|
|
459
517
|
*
|
|
460
|
-
* XML format:
|
|
518
|
+
* XML format (spec-compliant):
|
|
461
519
|
* ```xml
|
|
462
520
|
* <logs>
|
|
463
|
-
* <log date="2026-02-10 12:00:00" category="
|
|
464
|
-
*
|
|
521
|
+
* <log date="2026-02-10 12:00:00" category="error">
|
|
522
|
+
* <thread>main</thread>
|
|
523
|
+
* <method>collect</method>
|
|
524
|
+
* <message>Failed to load layout 123</message>
|
|
525
|
+
* <scheduleID>0</scheduleID>
|
|
526
|
+
* </log>
|
|
465
527
|
* </logs>
|
|
466
528
|
* ```
|
|
467
529
|
*
|
|
@@ -482,12 +544,14 @@ export function formatLogs(logs) {
|
|
|
482
544
|
// Format date as "YYYY-MM-DD HH:MM:SS"
|
|
483
545
|
const date = formatDateTime(logEntry.timestamp);
|
|
484
546
|
|
|
485
|
-
//
|
|
547
|
+
// Spec categories: only "error" and "audit" are valid
|
|
548
|
+
const category = (logEntry.level === 'error' || logEntry.level === 'audit')
|
|
549
|
+
? logEntry.level : 'audit';
|
|
550
|
+
|
|
551
|
+
// Build attributes on <log> element
|
|
486
552
|
const attrs = [
|
|
487
553
|
`date="${escapeXml(date)}"`,
|
|
488
|
-
`category="${escapeXml(
|
|
489
|
-
`type="${escapeXml(logEntry.level)}"`,
|
|
490
|
-
`message="${escapeXml(logEntry.message)}"`
|
|
554
|
+
`category="${escapeXml(category)}"`
|
|
491
555
|
];
|
|
492
556
|
|
|
493
557
|
// Fault alert fields (triggers CMS dashboard alerts)
|
|
@@ -498,12 +562,45 @@ export function formatLogs(logs) {
|
|
|
498
562
|
attrs.push(`eventType="${escapeXml(logEntry.eventType)}"`);
|
|
499
563
|
}
|
|
500
564
|
|
|
501
|
-
|
|
565
|
+
// Build child elements (spec format: thread, method, message, scheduleID)
|
|
566
|
+
const thread = escapeXml(logEntry.thread || 'main');
|
|
567
|
+
const method = escapeXml(logEntry.method || logEntry.category || 'PLAYER');
|
|
568
|
+
const message = escapeXml(logEntry.message);
|
|
569
|
+
const scheduleId = escapeXml(String(logEntry.scheduleId || '0'));
|
|
570
|
+
|
|
571
|
+
return ` <log ${attrs.join(' ')}>\n <thread>${thread}</thread>\n <method>${method}</method>\n <message>${message}</message>\n <scheduleID>${scheduleId}</scheduleID>\n </log>`;
|
|
502
572
|
});
|
|
503
573
|
|
|
504
574
|
return `<logs>\n${logElements.join('\n')}\n</logs>`;
|
|
505
575
|
}
|
|
506
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Format fault log entries as JSON for XMDS ReportFaults submission.
|
|
579
|
+
*
|
|
580
|
+
* Converts fault log objects (from getFaultsForSubmission) into the JSON
|
|
581
|
+
* string format expected by xmds.reportFaults().
|
|
582
|
+
*
|
|
583
|
+
* @param {Array} faults - Array of fault log objects from getFaultsForSubmission()
|
|
584
|
+
* @returns {string} JSON string for XMDS ReportFaults
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* const faults = await reporter.getFaultsForSubmission();
|
|
588
|
+
* if (faults.length > 0) {
|
|
589
|
+
* const json = formatFaults(faults);
|
|
590
|
+
* await xmds.reportFaults(json);
|
|
591
|
+
* }
|
|
592
|
+
*/
|
|
593
|
+
export function formatFaults(faults) {
|
|
594
|
+
if (!faults || faults.length === 0) return '[]';
|
|
595
|
+
|
|
596
|
+
return JSON.stringify(faults.map(f => ({
|
|
597
|
+
code: f.eventType || 'UNKNOWN',
|
|
598
|
+
reason: f.message || '',
|
|
599
|
+
date: formatDateTime(f.timestamp),
|
|
600
|
+
layoutId: f.scheduleId || 0
|
|
601
|
+
})));
|
|
602
|
+
}
|
|
603
|
+
|
|
507
604
|
/**
|
|
508
605
|
* Format Date object as "YYYY-MM-DD HH:MM:SS"
|
|
509
606
|
* @private
|
package/src/log-reporter.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
-
import { LogReporter, formatLogs } from './log-reporter.js';
|
|
6
|
+
import { LogReporter, formatLogs, formatFaults } from './log-reporter.js';
|
|
7
7
|
|
|
8
8
|
describe('LogReporter', () => {
|
|
9
9
|
let reporter;
|
|
@@ -286,6 +286,56 @@ describe('LogReporter', () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
describe('getFaultsForSubmission', () => {
|
|
290
|
+
it('should return only fault entries (not regular logs)', async () => {
|
|
291
|
+
await reporter.info('Normal log', 'PLAYER');
|
|
292
|
+
await reporter.error('Regular error', 'PLAYER');
|
|
293
|
+
await reporter.reportFault('LAYOUT_FAIL', 'Layout failed');
|
|
294
|
+
await reporter.reportFault('MEDIA_FAIL', 'Media failed');
|
|
295
|
+
|
|
296
|
+
const faults = await reporter.getFaultsForSubmission();
|
|
297
|
+
expect(faults.length).toBe(2);
|
|
298
|
+
expect(faults.every(f => f.alertType === 'Player Fault')).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should respect limit parameter', async () => {
|
|
302
|
+
await reporter.reportFault('FAULT_1', 'Fault 1', 1);
|
|
303
|
+
await new Promise(r => setTimeout(r, 5));
|
|
304
|
+
await reporter.reportFault('FAULT_2', 'Fault 2', 1);
|
|
305
|
+
await new Promise(r => setTimeout(r, 5));
|
|
306
|
+
await reporter.reportFault('FAULT_3', 'Fault 3', 1);
|
|
307
|
+
|
|
308
|
+
const faults = await reporter.getFaultsForSubmission(2);
|
|
309
|
+
expect(faults.length).toBe(2);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should return empty array when no faults exist', async () => {
|
|
313
|
+
await reporter.info('Normal log', 'PLAYER');
|
|
314
|
+
await reporter.error('Regular error', 'PLAYER');
|
|
315
|
+
|
|
316
|
+
const faults = await reporter.getFaultsForSubmission();
|
|
317
|
+
expect(faults).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should not return faults that have been cleared', async () => {
|
|
321
|
+
await reporter.reportFault('TEST_FAULT', 'Test');
|
|
322
|
+
|
|
323
|
+
const faults = await reporter.getFaultsForSubmission();
|
|
324
|
+
expect(faults.length).toBe(1);
|
|
325
|
+
|
|
326
|
+
await reporter.clearSubmittedLogs(faults);
|
|
327
|
+
|
|
328
|
+
const remaining = await reporter.getFaultsForSubmission();
|
|
329
|
+
expect(remaining).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should return empty array when db is not initialized', async () => {
|
|
333
|
+
const r = new LogReporter();
|
|
334
|
+
const faults = await r.getFaultsForSubmission();
|
|
335
|
+
expect(faults).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
289
339
|
describe('database operations', () => {
|
|
290
340
|
it('should get all logs', async () => {
|
|
291
341
|
await reporter.error('Error 1', 'PLAYER');
|
|
@@ -333,9 +383,11 @@ describe('formatLogs', () => {
|
|
|
333
383
|
const xml = formatLogs(logs);
|
|
334
384
|
expect(xml).toContain('<logs>');
|
|
335
385
|
expect(xml).toContain('</logs>');
|
|
336
|
-
expect(xml).toContain('
|
|
337
|
-
expect(xml).toContain('message
|
|
338
|
-
expect(xml).toContain('
|
|
386
|
+
expect(xml).toContain('category="error"');
|
|
387
|
+
expect(xml).toContain('<message>Test error</message>');
|
|
388
|
+
expect(xml).toContain('<method>PLAYER</method>');
|
|
389
|
+
expect(xml).toContain('<thread>main</thread>');
|
|
390
|
+
expect(xml).toContain('<scheduleID>0</scheduleID>');
|
|
339
391
|
expect(xml).toContain('date=');
|
|
340
392
|
});
|
|
341
393
|
|
|
@@ -375,7 +427,7 @@ describe('formatLogs', () => {
|
|
|
375
427
|
expect(xml).toContain(''');
|
|
376
428
|
});
|
|
377
429
|
|
|
378
|
-
it('should escape XML special characters in
|
|
430
|
+
it('should escape XML special characters in method', () => {
|
|
379
431
|
const logs = [{
|
|
380
432
|
level: 'error',
|
|
381
433
|
message: 'Test',
|
|
@@ -384,7 +436,8 @@ describe('formatLogs', () => {
|
|
|
384
436
|
}];
|
|
385
437
|
|
|
386
438
|
const xml = formatLogs(logs);
|
|
387
|
-
|
|
439
|
+
// category field from log entry becomes <method> child element
|
|
440
|
+
expect(xml).toContain('<method>PLAYER<></method>');
|
|
388
441
|
});
|
|
389
442
|
|
|
390
443
|
it('should format dates correctly', () => {
|
|
@@ -400,7 +453,7 @@ describe('formatLogs', () => {
|
|
|
400
453
|
expect(xml).toMatch(/date="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
|
|
401
454
|
});
|
|
402
455
|
|
|
403
|
-
it('should
|
|
456
|
+
it('should map log levels to spec categories (error/audit only)', () => {
|
|
404
457
|
const logs = [
|
|
405
458
|
{ level: 'error', message: 'Error', category: 'PLAYER', timestamp: new Date() },
|
|
406
459
|
{ level: 'audit', message: 'Audit', category: 'PLAYER', timestamp: new Date() },
|
|
@@ -409,10 +462,12 @@ describe('formatLogs', () => {
|
|
|
409
462
|
];
|
|
410
463
|
|
|
411
464
|
const xml = formatLogs(logs);
|
|
412
|
-
|
|
413
|
-
expect(xml).toContain('
|
|
414
|
-
expect(xml).toContain('
|
|
415
|
-
|
|
465
|
+
// Spec only allows "error" and "audit" as categories
|
|
466
|
+
expect(xml).toContain('category="error"');
|
|
467
|
+
expect(xml).toContain('category="audit"');
|
|
468
|
+
// info and debug should be mapped to "audit"
|
|
469
|
+
const auditCount = (xml.match(/category="audit"/g) || []).length;
|
|
470
|
+
expect(auditCount).toBe(3); // audit + info + debug
|
|
416
471
|
});
|
|
417
472
|
|
|
418
473
|
it('should include alertType and eventType in XML output for faults', () => {
|
|
@@ -481,4 +536,85 @@ describe('formatLogs', () => {
|
|
|
481
536
|
const xml = formatLogs(logs);
|
|
482
537
|
expect(xml).toContain(longMessage);
|
|
483
538
|
});
|
|
539
|
+
|
|
540
|
+
it('should use custom thread, method, and scheduleId when provided', () => {
|
|
541
|
+
const logs = [{
|
|
542
|
+
level: 'error',
|
|
543
|
+
message: 'Widget failed',
|
|
544
|
+
category: 'RENDERER',
|
|
545
|
+
thread: 'worker-2',
|
|
546
|
+
method: 'renderWidget',
|
|
547
|
+
scheduleId: 42,
|
|
548
|
+
timestamp: new Date('2026-02-10T12:00:00Z')
|
|
549
|
+
}];
|
|
550
|
+
|
|
551
|
+
const xml = formatLogs(logs);
|
|
552
|
+
expect(xml).toContain('<thread>worker-2</thread>');
|
|
553
|
+
expect(xml).toContain('<method>renderWidget</method>');
|
|
554
|
+
expect(xml).toContain('<scheduleID>42</scheduleID>');
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('should produce spec-compliant XML structure with child elements', () => {
|
|
558
|
+
const logs = [{
|
|
559
|
+
level: 'error',
|
|
560
|
+
message: 'Test',
|
|
561
|
+
category: 'PLAYER',
|
|
562
|
+
timestamp: new Date('2026-02-10T12:00:00Z')
|
|
563
|
+
}];
|
|
564
|
+
|
|
565
|
+
const xml = formatLogs(logs);
|
|
566
|
+
// Should NOT have message as attribute (old format)
|
|
567
|
+
expect(xml).not.toMatch(/message="/);
|
|
568
|
+
// Should have message as child element (spec format)
|
|
569
|
+
expect(xml).toMatch(/<message>Test<\/message>/);
|
|
570
|
+
// Should have closing </log> tag (not self-closing)
|
|
571
|
+
expect(xml).toContain('</log>');
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe('formatFaults', () => {
|
|
576
|
+
it('should format empty faults', () => {
|
|
577
|
+
expect(formatFaults([])).toBe('[]');
|
|
578
|
+
expect(formatFaults(null)).toBe('[]');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should format fault entries as JSON', () => {
|
|
582
|
+
const faults = [{
|
|
583
|
+
eventType: 'LAYOUT_LOAD_FAILED',
|
|
584
|
+
message: 'Failed to load layout 123',
|
|
585
|
+
timestamp: new Date('2026-02-10T12:00:00Z'),
|
|
586
|
+
scheduleId: 5
|
|
587
|
+
}];
|
|
588
|
+
|
|
589
|
+
const json = formatFaults(faults);
|
|
590
|
+
const parsed = JSON.parse(json);
|
|
591
|
+
expect(parsed).toHaveLength(1);
|
|
592
|
+
expect(parsed[0].code).toBe('LAYOUT_LOAD_FAILED');
|
|
593
|
+
expect(parsed[0].reason).toBe('Failed to load layout 123');
|
|
594
|
+
expect(parsed[0].date).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
595
|
+
expect(parsed[0].layoutId).toBe(5);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should handle missing fields with defaults', () => {
|
|
599
|
+
const faults = [{ timestamp: new Date() }];
|
|
600
|
+
|
|
601
|
+
const json = formatFaults(faults);
|
|
602
|
+
const parsed = JSON.parse(json);
|
|
603
|
+
expect(parsed[0].code).toBe('UNKNOWN');
|
|
604
|
+
expect(parsed[0].reason).toBe('');
|
|
605
|
+
expect(parsed[0].layoutId).toBe(0);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should format multiple faults', () => {
|
|
609
|
+
const faults = [
|
|
610
|
+
{ eventType: 'FAULT_1', message: 'First', timestamp: new Date(), scheduleId: 1 },
|
|
611
|
+
{ eventType: 'FAULT_2', message: 'Second', timestamp: new Date(), scheduleId: 2 }
|
|
612
|
+
];
|
|
613
|
+
|
|
614
|
+
const json = formatFaults(faults);
|
|
615
|
+
const parsed = JSON.parse(json);
|
|
616
|
+
expect(parsed).toHaveLength(2);
|
|
617
|
+
expect(parsed[0].code).toBe('FAULT_1');
|
|
618
|
+
expect(parsed[1].code).toBe('FAULT_2');
|
|
619
|
+
});
|
|
484
620
|
});
|
package/src/stats-collector.js
CHANGED
|
@@ -109,14 +109,22 @@ export class StatsCollector {
|
|
|
109
109
|
*
|
|
110
110
|
* @param {number} layoutId - Layout ID from CMS
|
|
111
111
|
* @param {number} scheduleId - Schedule ID that triggered this layout
|
|
112
|
+
* @param {Object} [options] - Options
|
|
113
|
+
* @param {boolean} [options.enableStat=true] - Whether stats are enabled for this layout
|
|
112
114
|
* @returns {Promise<void>}
|
|
113
115
|
*/
|
|
114
|
-
async startLayout(layoutId, scheduleId) {
|
|
116
|
+
async startLayout(layoutId, scheduleId, options) {
|
|
115
117
|
if (!this.db) {
|
|
116
118
|
log.warn('Stats database not initialized');
|
|
117
119
|
return;
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Respect enableStat flag from XLF (layout/widget level stat suppression)
|
|
123
|
+
if (options?.enableStat === false) {
|
|
124
|
+
log.debug(`Stats disabled for layout ${layoutId} (enableStat=false)`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
120
128
|
// Key excludes scheduleId: only one layout instance can be in-progress at a time,
|
|
121
129
|
// and scheduleId may change mid-play when a collection cycle completes.
|
|
122
130
|
const key = `layout-${layoutId}`;
|
|
@@ -126,7 +134,7 @@ export class StatsCollector {
|
|
|
126
134
|
const prev = this.inProgressStats.get(key);
|
|
127
135
|
prev.end = new Date();
|
|
128
136
|
prev.duration = Math.floor((prev.end - prev.start) / 1000);
|
|
129
|
-
await this.
|
|
137
|
+
await this._saveStatSplit(prev);
|
|
130
138
|
this.inProgressStats.delete(key);
|
|
131
139
|
log.debug(`Layout ${layoutId} replay - ended previous cycle (${prev.duration}s)`);
|
|
132
140
|
}
|
|
@@ -174,9 +182,9 @@ export class StatsCollector {
|
|
|
174
182
|
stat.end = new Date();
|
|
175
183
|
stat.duration = Math.floor((stat.end - stat.start) / 1000);
|
|
176
184
|
|
|
177
|
-
// Save to database
|
|
185
|
+
// Save to database (splitting at hour boundaries for CMS aggregation)
|
|
178
186
|
try {
|
|
179
|
-
await this.
|
|
187
|
+
await this._saveStatSplit(stat);
|
|
180
188
|
this.inProgressStats.delete(key);
|
|
181
189
|
log.debug(`Ended tracking layout ${layoutId} (${stat.duration}s)`);
|
|
182
190
|
} catch (error) {
|
|
@@ -195,14 +203,23 @@ export class StatsCollector {
|
|
|
195
203
|
* @param {number} mediaId - Media ID from CMS
|
|
196
204
|
* @param {number} layoutId - Parent layout ID
|
|
197
205
|
* @param {number} scheduleId - Schedule ID
|
|
206
|
+
* @param {string|number} [widgetId] - Widget ID (for non-library widgets with no mediaId)
|
|
207
|
+
* @param {Object} [options] - Options
|
|
208
|
+
* @param {boolean} [options.enableStat=true] - Whether stats are enabled for this widget
|
|
198
209
|
* @returns {Promise<void>}
|
|
199
210
|
*/
|
|
200
|
-
async startWidget(mediaId, layoutId, scheduleId) {
|
|
211
|
+
async startWidget(mediaId, layoutId, scheduleId, widgetId, options) {
|
|
201
212
|
if (!this.db) {
|
|
202
213
|
log.warn('Stats database not initialized');
|
|
203
214
|
return;
|
|
204
215
|
}
|
|
205
216
|
|
|
217
|
+
// Respect enableStat flag from XLF (layout/widget level stat suppression)
|
|
218
|
+
if (options?.enableStat === false) {
|
|
219
|
+
log.debug(`Stats disabled for widget ${mediaId} (enableStat=false)`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
206
223
|
// Key excludes scheduleId: it may change mid-play during collection cycles.
|
|
207
224
|
const key = `media-${mediaId}-${layoutId}`;
|
|
208
225
|
|
|
@@ -211,7 +228,7 @@ export class StatsCollector {
|
|
|
211
228
|
const prev = this.inProgressStats.get(key);
|
|
212
229
|
prev.end = new Date();
|
|
213
230
|
prev.duration = Math.floor((prev.end - prev.start) / 1000);
|
|
214
|
-
await this.
|
|
231
|
+
await this._saveStatSplit(prev);
|
|
215
232
|
this.inProgressStats.delete(key);
|
|
216
233
|
log.debug(`Widget ${mediaId} replay - ended previous cycle (${prev.duration}s)`);
|
|
217
234
|
}
|
|
@@ -219,6 +236,7 @@ export class StatsCollector {
|
|
|
219
236
|
const stat = {
|
|
220
237
|
type: 'media',
|
|
221
238
|
mediaId,
|
|
239
|
+
widgetId: widgetId || null,
|
|
222
240
|
layoutId,
|
|
223
241
|
scheduleId,
|
|
224
242
|
start: new Date(),
|
|
@@ -261,9 +279,9 @@ export class StatsCollector {
|
|
|
261
279
|
stat.end = new Date();
|
|
262
280
|
stat.duration = Math.floor((stat.end - stat.start) / 1000);
|
|
263
281
|
|
|
264
|
-
// Save to database
|
|
282
|
+
// Save to database (splitting at hour boundaries for CMS aggregation)
|
|
265
283
|
try {
|
|
266
|
-
await this.
|
|
284
|
+
await this._saveStatSplit(stat);
|
|
267
285
|
this.inProgressStats.delete(key);
|
|
268
286
|
log.debug(`Ended tracking widget ${mediaId} (${stat.duration}s)`);
|
|
269
287
|
} catch (error) {
|
|
@@ -272,6 +290,48 @@ export class StatsCollector {
|
|
|
272
290
|
}
|
|
273
291
|
}
|
|
274
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Record an event stat (point-in-time engagement data)
|
|
295
|
+
*
|
|
296
|
+
* Creates an instant stat entry with no duration. Used for tracking
|
|
297
|
+
* interactive touches, webhook triggers, and other engagement events.
|
|
298
|
+
* Unlike layout/widget stats, events have no start/end cycle.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} tag - Event tag describing the interaction (e.g. 'touch', 'webhook')
|
|
301
|
+
* @param {number} layoutId - Layout ID where the event occurred
|
|
302
|
+
* @param {number} widgetId - Widget ID that triggered the event
|
|
303
|
+
* @param {number} scheduleId - Schedule ID for the current schedule
|
|
304
|
+
* @returns {Promise<void>}
|
|
305
|
+
*/
|
|
306
|
+
async recordEvent(tag, layoutId, widgetId, scheduleId) {
|
|
307
|
+
if (!this.db) {
|
|
308
|
+
log.warn('Stats database not initialized');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const now = new Date();
|
|
313
|
+
const stat = {
|
|
314
|
+
type: 'event',
|
|
315
|
+
tag,
|
|
316
|
+
layoutId,
|
|
317
|
+
widgetId,
|
|
318
|
+
scheduleId,
|
|
319
|
+
start: now,
|
|
320
|
+
end: now,
|
|
321
|
+
duration: 0,
|
|
322
|
+
count: 1,
|
|
323
|
+
submitted: 0
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await this._saveStat(stat);
|
|
328
|
+
log.debug(`Recorded event '${tag}' for widget ${widgetId} in layout ${layoutId}`);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
log.error(`Failed to record event '${tag}':`, error);
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
275
335
|
/**
|
|
276
336
|
* Get stats ready for submission to CMS
|
|
277
337
|
*
|
|
@@ -382,7 +442,7 @@ export class StatsCollector {
|
|
|
382
442
|
const hour = stat.start instanceof Date
|
|
383
443
|
? stat.start.toISOString().slice(0, 13)
|
|
384
444
|
: new Date(stat.start).toISOString().slice(0, 13);
|
|
385
|
-
const key = `${stat.type}|${stat.layoutId}|${stat.mediaId || ''}|${stat.scheduleId}|${hour}`;
|
|
445
|
+
const key = `${stat.type}|${stat.layoutId}|${stat.mediaId || ''}|${stat.widgetId || ''}|${stat.tag || ''}|${stat.scheduleId}|${hour}`;
|
|
386
446
|
|
|
387
447
|
if (groups.has(key)) {
|
|
388
448
|
const group = groups.get(key);
|
|
@@ -495,6 +555,65 @@ export class StatsCollector {
|
|
|
495
555
|
});
|
|
496
556
|
}
|
|
497
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Split a stat record at hour boundaries.
|
|
560
|
+
* If a stat spans multiple hours (e.g. 12:50→13:10), it is split into
|
|
561
|
+
* separate records at each hour boundary for correct CMS aggregation.
|
|
562
|
+
* Returns an array of one or more stat objects.
|
|
563
|
+
* @param {Object} stat - Finalized stat with start, end, duration
|
|
564
|
+
* @returns {Object[]}
|
|
565
|
+
* @private
|
|
566
|
+
*/
|
|
567
|
+
_splitAtHourBoundaries(stat) {
|
|
568
|
+
const start = stat.start;
|
|
569
|
+
const end = stat.end;
|
|
570
|
+
|
|
571
|
+
// No split needed if start and end are in the same hour
|
|
572
|
+
if (start.getFullYear() === end.getFullYear() &&
|
|
573
|
+
start.getMonth() === end.getMonth() &&
|
|
574
|
+
start.getDate() === end.getDate() &&
|
|
575
|
+
start.getHours() === end.getHours()) {
|
|
576
|
+
return [stat];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const results = [];
|
|
580
|
+
let segStart = new Date(start.getTime());
|
|
581
|
+
|
|
582
|
+
while (segStart < end) {
|
|
583
|
+
// Next hour boundary: top of the next hour from segStart
|
|
584
|
+
const nextHour = new Date(segStart.getTime());
|
|
585
|
+
nextHour.setMinutes(0, 0, 0);
|
|
586
|
+
nextHour.setHours(nextHour.getHours() + 1);
|
|
587
|
+
|
|
588
|
+
const segEnd = nextHour < end ? nextHour : end;
|
|
589
|
+
const duration = Math.floor((segEnd - segStart) / 1000);
|
|
590
|
+
|
|
591
|
+
results.push({
|
|
592
|
+
...stat,
|
|
593
|
+
start: new Date(segStart.getTime()),
|
|
594
|
+
end: new Date(segEnd.getTime()),
|
|
595
|
+
duration,
|
|
596
|
+
count: 1
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
segStart = segEnd;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return results;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Save a stat to IndexedDB, splitting at hour boundaries first.
|
|
607
|
+
* @param {Object} stat - Finalized stat with start, end, duration
|
|
608
|
+
* @private
|
|
609
|
+
*/
|
|
610
|
+
async _saveStatSplit(stat) {
|
|
611
|
+
const parts = this._splitAtHourBoundaries(stat);
|
|
612
|
+
for (const part of parts) {
|
|
613
|
+
await this._saveStat(part);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
498
617
|
/**
|
|
499
618
|
* Clean old stats when quota is exceeded
|
|
500
619
|
* Deletes oldest 100 submitted stats
|
|
@@ -581,9 +700,25 @@ export function formatStats(stats) {
|
|
|
581
700
|
`layoutid="${stat.layoutId}"`,
|
|
582
701
|
];
|
|
583
702
|
|
|
584
|
-
// Add mediaId for media stats
|
|
585
|
-
if (stat.type === 'media'
|
|
586
|
-
|
|
703
|
+
// Add mediaId and widgetId for media/widget stats
|
|
704
|
+
if (stat.type === 'media') {
|
|
705
|
+
if (stat.mediaId) {
|
|
706
|
+
attrs.push(`mediaid="${stat.mediaId}"`);
|
|
707
|
+
}
|
|
708
|
+
// Include widgetId for non-library widgets (native widgets have no mediaId)
|
|
709
|
+
if (stat.widgetId) {
|
|
710
|
+
attrs.push(`widgetid="${stat.widgetId}"`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Add tag and widgetId for event stats
|
|
715
|
+
if (stat.type === 'event') {
|
|
716
|
+
if (stat.tag) {
|
|
717
|
+
attrs.push(`tag="${escapeXml(stat.tag)}"`);
|
|
718
|
+
}
|
|
719
|
+
if (stat.widgetId) {
|
|
720
|
+
attrs.push(`widgetid="${stat.widgetId}"`);
|
|
721
|
+
}
|
|
587
722
|
}
|
|
588
723
|
|
|
589
724
|
// Add count and duration
|
|
@@ -189,6 +189,70 @@ describe('StatsCollector', () => {
|
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
describe('event tracking', () => {
|
|
193
|
+
it('should record an event stat', async () => {
|
|
194
|
+
await collector.recordEvent('touch', 123, 456, 789);
|
|
195
|
+
|
|
196
|
+
const stats = await collector.getAllStats();
|
|
197
|
+
expect(stats.length).toBe(1);
|
|
198
|
+
expect(stats[0].type).toBe('event');
|
|
199
|
+
expect(stats[0].tag).toBe('touch');
|
|
200
|
+
expect(stats[0].layoutId).toBe(123);
|
|
201
|
+
expect(stats[0].widgetId).toBe(456);
|
|
202
|
+
expect(stats[0].scheduleId).toBe(789);
|
|
203
|
+
expect(stats[0].duration).toBe(0);
|
|
204
|
+
expect(stats[0].count).toBe(1);
|
|
205
|
+
expect(stats[0].submitted).toBe(0);
|
|
206
|
+
expect(stats[0].start).toBeInstanceOf(Date);
|
|
207
|
+
expect(stats[0].end).toBeInstanceOf(Date);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should record multiple events', async () => {
|
|
211
|
+
await collector.recordEvent('touch', 123, 456, 789);
|
|
212
|
+
await collector.recordEvent('webhook', 123, 456, 789);
|
|
213
|
+
await collector.recordEvent('touch', 123, 457, 789);
|
|
214
|
+
|
|
215
|
+
const stats = await collector.getAllStats();
|
|
216
|
+
expect(stats.length).toBe(3);
|
|
217
|
+
expect(stats.every(s => s.type === 'event')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should not store event in inProgressStats', async () => {
|
|
221
|
+
await collector.recordEvent('touch', 123, 456, 789);
|
|
222
|
+
|
|
223
|
+
expect(collector.inProgressStats.size).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should be retrievable for submission', async () => {
|
|
227
|
+
await collector.recordEvent('touch', 123, 456, 789);
|
|
228
|
+
|
|
229
|
+
const stats = await collector.getStatsForSubmission();
|
|
230
|
+
expect(stats.length).toBe(1);
|
|
231
|
+
expect(stats[0].type).toBe('event');
|
|
232
|
+
expect(stats[0].tag).toBe('touch');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should handle missing db gracefully', async () => {
|
|
236
|
+
const c = new StatsCollector();
|
|
237
|
+
// Should not throw
|
|
238
|
+
await c.recordEvent('touch', 123, 456, 789);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should coexist with layout and widget stats', async () => {
|
|
242
|
+
await collector.startLayout(123, 789);
|
|
243
|
+
await collector.endLayout(123, 789);
|
|
244
|
+
await collector.startWidget(111, 123, 789);
|
|
245
|
+
await collector.endWidget(111, 123, 789);
|
|
246
|
+
await collector.recordEvent('touch', 123, 456, 789);
|
|
247
|
+
|
|
248
|
+
const stats = await collector.getAllStats();
|
|
249
|
+
expect(stats.length).toBe(3);
|
|
250
|
+
expect(stats.some(s => s.type === 'layout')).toBe(true);
|
|
251
|
+
expect(stats.some(s => s.type === 'media')).toBe(true);
|
|
252
|
+
expect(stats.some(s => s.type === 'event')).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
192
256
|
describe('stats submission flow', () => {
|
|
193
257
|
it('should get unsubmitted stats', async () => {
|
|
194
258
|
// Create some stats
|
|
@@ -442,6 +506,45 @@ describe('formatStats', () => {
|
|
|
442
506
|
expect(xml).toMatch(/todt="\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"/);
|
|
443
507
|
});
|
|
444
508
|
|
|
509
|
+
it('should format event stat', () => {
|
|
510
|
+
const stats = [{
|
|
511
|
+
type: 'event',
|
|
512
|
+
tag: 'touch',
|
|
513
|
+
layoutId: 123,
|
|
514
|
+
widgetId: 456,
|
|
515
|
+
scheduleId: 789,
|
|
516
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
517
|
+
end: new Date('2026-02-10T12:00:00Z'),
|
|
518
|
+
duration: 0,
|
|
519
|
+
count: 1
|
|
520
|
+
}];
|
|
521
|
+
|
|
522
|
+
const xml = formatStats(stats);
|
|
523
|
+
expect(xml).toContain('type="event"');
|
|
524
|
+
expect(xml).toContain('tag="touch"');
|
|
525
|
+
expect(xml).toContain('widgetid="456"');
|
|
526
|
+
expect(xml).toContain('layoutid="123"');
|
|
527
|
+
expect(xml).toContain('scheduleid="789"');
|
|
528
|
+
expect(xml).toContain('duration="0"');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should escape XML in event tag', () => {
|
|
532
|
+
const stats = [{
|
|
533
|
+
type: 'event',
|
|
534
|
+
tag: 'touch&click<script>',
|
|
535
|
+
layoutId: 123,
|
|
536
|
+
widgetId: 456,
|
|
537
|
+
scheduleId: 789,
|
|
538
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
539
|
+
end: new Date('2026-02-10T12:00:00Z'),
|
|
540
|
+
duration: 0,
|
|
541
|
+
count: 1
|
|
542
|
+
}];
|
|
543
|
+
|
|
544
|
+
const xml = formatStats(stats);
|
|
545
|
+
expect(xml).toContain('tag="touch&click<script>"');
|
|
546
|
+
});
|
|
547
|
+
|
|
445
548
|
it('should handle missing end date', () => {
|
|
446
549
|
const stats = [{
|
|
447
550
|
type: 'layout',
|
|
@@ -458,4 +561,129 @@ describe('formatStats', () => {
|
|
|
458
561
|
expect(xml).toContain('fromdt=');
|
|
459
562
|
expect(xml).toContain('todt=');
|
|
460
563
|
});
|
|
564
|
+
|
|
565
|
+
it('should include widgetId for media stats with no mediaId (native widgets)', () => {
|
|
566
|
+
const stats = [{
|
|
567
|
+
type: 'media',
|
|
568
|
+
mediaId: null,
|
|
569
|
+
widgetId: 42,
|
|
570
|
+
layoutId: 123,
|
|
571
|
+
scheduleId: 456,
|
|
572
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
573
|
+
end: new Date('2026-02-10T12:01:00Z'),
|
|
574
|
+
duration: 60,
|
|
575
|
+
count: 1
|
|
576
|
+
}];
|
|
577
|
+
|
|
578
|
+
const xml = formatStats(stats);
|
|
579
|
+
expect(xml).toContain('type="media"');
|
|
580
|
+
expect(xml).toContain('widgetid="42"');
|
|
581
|
+
expect(xml).not.toContain('mediaid=');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should include both mediaid and widgetid for library widgets', () => {
|
|
585
|
+
const stats = [{
|
|
586
|
+
type: 'media',
|
|
587
|
+
mediaId: 789,
|
|
588
|
+
widgetId: 42,
|
|
589
|
+
layoutId: 123,
|
|
590
|
+
scheduleId: 456,
|
|
591
|
+
start: new Date('2026-02-10T12:00:00Z'),
|
|
592
|
+
end: new Date('2026-02-10T12:01:00Z'),
|
|
593
|
+
duration: 60,
|
|
594
|
+
count: 1
|
|
595
|
+
}];
|
|
596
|
+
|
|
597
|
+
const xml = formatStats(stats);
|
|
598
|
+
expect(xml).toContain('mediaid="789"');
|
|
599
|
+
expect(xml).toContain('widgetid="42"');
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('_splitAtHourBoundaries', () => {
|
|
604
|
+
let collector;
|
|
605
|
+
|
|
606
|
+
beforeEach(async () => {
|
|
607
|
+
collector = new StatsCollector();
|
|
608
|
+
await collector.init();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
afterEach(() => {
|
|
612
|
+
if (collector?.db) collector.db.close();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('should not split a stat within the same hour', () => {
|
|
616
|
+
const stat = {
|
|
617
|
+
type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
|
|
618
|
+
start: new Date('2026-02-10T12:10:00Z'),
|
|
619
|
+
end: new Date('2026-02-10T12:50:00Z'),
|
|
620
|
+
duration: 2400
|
|
621
|
+
};
|
|
622
|
+
const parts = collector._splitAtHourBoundaries(stat);
|
|
623
|
+
expect(parts).toHaveLength(1);
|
|
624
|
+
expect(parts[0].duration).toBe(2400);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('should split a stat spanning two hours', () => {
|
|
628
|
+
const stat = {
|
|
629
|
+
type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
|
|
630
|
+
start: new Date('2026-02-10T12:50:00Z'),
|
|
631
|
+
end: new Date('2026-02-10T13:10:00Z'),
|
|
632
|
+
duration: 1200
|
|
633
|
+
};
|
|
634
|
+
const parts = collector._splitAtHourBoundaries(stat);
|
|
635
|
+
expect(parts).toHaveLength(2);
|
|
636
|
+
expect(parts[0].duration).toBe(600); // 12:50 → 13:00
|
|
637
|
+
expect(parts[1].duration).toBe(600); // 13:00 → 13:10
|
|
638
|
+
expect(parts[0].end.toISOString()).toBe('2026-02-10T13:00:00.000Z');
|
|
639
|
+
expect(parts[1].start.toISOString()).toBe('2026-02-10T13:00:00.000Z');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should split a stat spanning three hours', () => {
|
|
643
|
+
const stat = {
|
|
644
|
+
type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
|
|
645
|
+
start: new Date('2026-02-10T11:30:00Z'),
|
|
646
|
+
end: new Date('2026-02-10T13:15:00Z'),
|
|
647
|
+
duration: 6300
|
|
648
|
+
};
|
|
649
|
+
const parts = collector._splitAtHourBoundaries(stat);
|
|
650
|
+
expect(parts).toHaveLength(3);
|
|
651
|
+
expect(parts[0].duration).toBe(1800); // 11:30 → 12:00
|
|
652
|
+
expect(parts[1].duration).toBe(3600); // 12:00 → 13:00
|
|
653
|
+
expect(parts[2].duration).toBe(900); // 13:00 → 13:15
|
|
654
|
+
// Durations sum to original
|
|
655
|
+
const total = parts.reduce((s, p) => s + p.duration, 0);
|
|
656
|
+
expect(total).toBe(6300);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('should split at day boundary (midnight)', () => {
|
|
660
|
+
const stat = {
|
|
661
|
+
type: 'layout', layoutId: 1, scheduleId: 1, count: 1, submitted: 0,
|
|
662
|
+
start: new Date('2026-02-10T23:50:00Z'),
|
|
663
|
+
end: new Date('2026-02-11T00:10:00Z'),
|
|
664
|
+
duration: 1200
|
|
665
|
+
};
|
|
666
|
+
const parts = collector._splitAtHourBoundaries(stat);
|
|
667
|
+
expect(parts).toHaveLength(2);
|
|
668
|
+
expect(parts[0].duration).toBe(600);
|
|
669
|
+
expect(parts[1].duration).toBe(600);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('should preserve all stat fields in split records', () => {
|
|
673
|
+
const stat = {
|
|
674
|
+
type: 'media', layoutId: 5, mediaId: 42, scheduleId: 7, count: 1, submitted: 0,
|
|
675
|
+
start: new Date('2026-02-10T12:50:00Z'),
|
|
676
|
+
end: new Date('2026-02-10T13:10:00Z'),
|
|
677
|
+
duration: 1200
|
|
678
|
+
};
|
|
679
|
+
const parts = collector._splitAtHourBoundaries(stat);
|
|
680
|
+
for (const part of parts) {
|
|
681
|
+
expect(part.type).toBe('media');
|
|
682
|
+
expect(part.layoutId).toBe(5);
|
|
683
|
+
expect(part.mediaId).toBe(42);
|
|
684
|
+
expect(part.scheduleId).toBe(7);
|
|
685
|
+
expect(part.count).toBe(1);
|
|
686
|
+
expect(part.submitted).toBe(0);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
461
689
|
});
|