@xiboplayer/renderer 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 +3 -3
- package/src/layout.js +12 -9
- package/src/renderer-lite.js +594 -34
- package/src/renderer-lite.test.js +1269 -0
|
@@ -155,6 +155,121 @@ describe('RendererLite', () => {
|
|
|
155
155
|
direction: 'S'
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
it('should parse region exit transition from options', () => {
|
|
160
|
+
const xlf = `
|
|
161
|
+
<layout>
|
|
162
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
163
|
+
<options>
|
|
164
|
+
<exitTransType>fadeOut</exitTransType>
|
|
165
|
+
<exitTransDuration>500</exitTransDuration>
|
|
166
|
+
<exitTransDirection>N</exitTransDirection>
|
|
167
|
+
</options>
|
|
168
|
+
<media id="m1" type="image" duration="10">
|
|
169
|
+
<options><uri>test.png</uri></options>
|
|
170
|
+
</media>
|
|
171
|
+
</region>
|
|
172
|
+
</layout>
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const layout = renderer.parseXlf(xlf);
|
|
176
|
+
const region = layout.regions[0];
|
|
177
|
+
|
|
178
|
+
expect(region.exitTransition).toEqual({
|
|
179
|
+
type: 'fadeOut',
|
|
180
|
+
duration: 500,
|
|
181
|
+
direction: 'N'
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should parse region exit transition with defaults for missing duration and direction', () => {
|
|
186
|
+
const xlf = `
|
|
187
|
+
<layout>
|
|
188
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
189
|
+
<options>
|
|
190
|
+
<exitTransType>flyOut</exitTransType>
|
|
191
|
+
</options>
|
|
192
|
+
<media id="m1" type="image" duration="10">
|
|
193
|
+
<options><uri>test.png</uri></options>
|
|
194
|
+
</media>
|
|
195
|
+
</region>
|
|
196
|
+
</layout>
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
const layout = renderer.parseXlf(xlf);
|
|
200
|
+
const region = layout.regions[0];
|
|
201
|
+
|
|
202
|
+
expect(region.exitTransition).toEqual({
|
|
203
|
+
type: 'flyOut',
|
|
204
|
+
duration: 1000,
|
|
205
|
+
direction: 'N'
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should set exitTransition to null when region has no options', () => {
|
|
210
|
+
const xlf = `
|
|
211
|
+
<layout>
|
|
212
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
213
|
+
<media id="m1" type="image" duration="10">
|
|
214
|
+
<options><uri>test.png</uri></options>
|
|
215
|
+
</media>
|
|
216
|
+
</region>
|
|
217
|
+
</layout>
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
const layout = renderer.parseXlf(xlf);
|
|
221
|
+
const region = layout.regions[0];
|
|
222
|
+
|
|
223
|
+
expect(region.exitTransition).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should set exitTransition to null when region options have no exitTransType', () => {
|
|
227
|
+
const xlf = `
|
|
228
|
+
<layout>
|
|
229
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
230
|
+
<options>
|
|
231
|
+
<someOtherOption>value</someOtherOption>
|
|
232
|
+
</options>
|
|
233
|
+
<media id="m1" type="image" duration="10">
|
|
234
|
+
<options><uri>test.png</uri></options>
|
|
235
|
+
</media>
|
|
236
|
+
</region>
|
|
237
|
+
</layout>
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
const layout = renderer.parseXlf(xlf);
|
|
241
|
+
const region = layout.regions[0];
|
|
242
|
+
|
|
243
|
+
expect(region.exitTransition).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should not confuse media options with region options for exit transitions', () => {
|
|
247
|
+
const xlf = `
|
|
248
|
+
<layout>
|
|
249
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
250
|
+
<media id="m1" type="image" duration="10">
|
|
251
|
+
<options>
|
|
252
|
+
<uri>test.png</uri>
|
|
253
|
+
<transOut>fadeOut</transOut>
|
|
254
|
+
<transOutDuration>1500</transOutDuration>
|
|
255
|
+
</options>
|
|
256
|
+
</media>
|
|
257
|
+
</region>
|
|
258
|
+
</layout>
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
const layout = renderer.parseXlf(xlf);
|
|
262
|
+
const region = layout.regions[0];
|
|
263
|
+
|
|
264
|
+
// Region should have no exit transition (only media has transOut)
|
|
265
|
+
expect(region.exitTransition).toBeNull();
|
|
266
|
+
// Widget should have its own out transition
|
|
267
|
+
expect(region.widgets[0].transitions.out).toEqual({
|
|
268
|
+
type: 'fadeOut',
|
|
269
|
+
duration: 1500,
|
|
270
|
+
direction: 'N'
|
|
271
|
+
});
|
|
272
|
+
});
|
|
158
273
|
});
|
|
159
274
|
|
|
160
275
|
describe('enableStat parsing', () => {
|
|
@@ -303,6 +418,136 @@ describe('RendererLite', () => {
|
|
|
303
418
|
expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
|
|
304
419
|
});
|
|
305
420
|
|
|
421
|
+
it('should default to objectFit contain and objectPosition center center', async () => {
|
|
422
|
+
const widget = {
|
|
423
|
+
type: 'image',
|
|
424
|
+
id: 'm1',
|
|
425
|
+
fileId: '1',
|
|
426
|
+
options: { uri: 'test.png' },
|
|
427
|
+
duration: 10,
|
|
428
|
+
transitions: { in: null, out: null }
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const region = { width: 1920, height: 1080 };
|
|
432
|
+
const element = await renderer.renderImage(widget, region);
|
|
433
|
+
|
|
434
|
+
expect(element.style.objectFit).toBe('contain');
|
|
435
|
+
expect(element.style.objectPosition).toBe('center center');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should apply objectFit fill when scaleType is stretch', async () => {
|
|
439
|
+
const widget = {
|
|
440
|
+
type: 'image',
|
|
441
|
+
id: 'm1',
|
|
442
|
+
fileId: '1',
|
|
443
|
+
options: { uri: 'test.png', scaleType: 'stretch' },
|
|
444
|
+
duration: 10,
|
|
445
|
+
transitions: { in: null, out: null }
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const region = { width: 1920, height: 1080 };
|
|
449
|
+
const element = await renderer.renderImage(widget, region);
|
|
450
|
+
|
|
451
|
+
expect(element.style.objectFit).toBe('fill');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should apply objectFit none when scaleType is center (natural size)', async () => {
|
|
455
|
+
const widget = {
|
|
456
|
+
type: 'image',
|
|
457
|
+
id: 'm1',
|
|
458
|
+
fileId: '1',
|
|
459
|
+
options: { uri: 'test.png', scaleType: 'center' },
|
|
460
|
+
duration: 10,
|
|
461
|
+
transitions: { in: null, out: null }
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const region = { width: 1920, height: 1080 };
|
|
465
|
+
const element = await renderer.renderImage(widget, region);
|
|
466
|
+
|
|
467
|
+
expect(element.style.objectFit).toBe('none');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should apply objectFit contain when scaleType is fit', async () => {
|
|
471
|
+
const widget = {
|
|
472
|
+
type: 'image',
|
|
473
|
+
id: 'm1',
|
|
474
|
+
fileId: '1',
|
|
475
|
+
options: { uri: 'test.png', scaleType: 'fit' },
|
|
476
|
+
duration: 10,
|
|
477
|
+
transitions: { in: null, out: null }
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const region = { width: 1920, height: 1080 };
|
|
481
|
+
const element = await renderer.renderImage(widget, region);
|
|
482
|
+
|
|
483
|
+
expect(element.style.objectFit).toBe('contain');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should map align and valign to objectPosition', async () => {
|
|
487
|
+
const widget = {
|
|
488
|
+
type: 'image',
|
|
489
|
+
id: 'm1',
|
|
490
|
+
fileId: '1',
|
|
491
|
+
options: { uri: 'test.png', align: 'left', valign: 'top' },
|
|
492
|
+
duration: 10,
|
|
493
|
+
transitions: { in: null, out: null }
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const region = { width: 1920, height: 1080 };
|
|
497
|
+
const element = await renderer.renderImage(widget, region);
|
|
498
|
+
|
|
499
|
+
expect(element.style.objectPosition).toBe('left top');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should map align right and valign bottom to objectPosition', async () => {
|
|
503
|
+
const widget = {
|
|
504
|
+
type: 'image',
|
|
505
|
+
id: 'm1',
|
|
506
|
+
fileId: '1',
|
|
507
|
+
options: { uri: 'test.png', align: 'right', valign: 'bottom' },
|
|
508
|
+
duration: 10,
|
|
509
|
+
transitions: { in: null, out: null }
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const region = { width: 1920, height: 1080 };
|
|
513
|
+
const element = await renderer.renderImage(widget, region);
|
|
514
|
+
|
|
515
|
+
expect(element.style.objectPosition).toBe('right bottom');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should map valign middle to center in objectPosition', async () => {
|
|
519
|
+
const widget = {
|
|
520
|
+
type: 'image',
|
|
521
|
+
id: 'm1',
|
|
522
|
+
fileId: '1',
|
|
523
|
+
options: { uri: 'test.png', align: 'center', valign: 'middle' },
|
|
524
|
+
duration: 10,
|
|
525
|
+
transitions: { in: null, out: null }
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const region = { width: 1920, height: 1080 };
|
|
529
|
+
const element = await renderer.renderImage(widget, region);
|
|
530
|
+
|
|
531
|
+
expect(element.style.objectPosition).toBe('center center');
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should combine scaleType stretch with alignment options', async () => {
|
|
535
|
+
const widget = {
|
|
536
|
+
type: 'image',
|
|
537
|
+
id: 'm1',
|
|
538
|
+
fileId: '1',
|
|
539
|
+
options: { uri: 'test.png', scaleType: 'stretch', align: 'left', valign: 'bottom' },
|
|
540
|
+
duration: 10,
|
|
541
|
+
transitions: { in: null, out: null }
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const region = { width: 1920, height: 1080 };
|
|
545
|
+
const element = await renderer.renderImage(widget, region);
|
|
546
|
+
|
|
547
|
+
expect(element.style.objectFit).toBe('fill');
|
|
548
|
+
expect(element.style.objectPosition).toBe('left bottom');
|
|
549
|
+
});
|
|
550
|
+
|
|
306
551
|
it('should create video widget element', async () => {
|
|
307
552
|
const widget = {
|
|
308
553
|
type: 'video',
|
|
@@ -978,4 +1223,1028 @@ describe('RendererLite', () => {
|
|
|
978
1223
|
expect(renderer.currentLayout.duration).toBe(45);
|
|
979
1224
|
});
|
|
980
1225
|
});
|
|
1226
|
+
|
|
1227
|
+
describe('Audio Widget Support', () => {
|
|
1228
|
+
it('should parse audio widget with type, loop and volume options', () => {
|
|
1229
|
+
const xlf = `
|
|
1230
|
+
<layout>
|
|
1231
|
+
<region id="r1">
|
|
1232
|
+
<media id="a1" type="audio" duration="30" fileId="10">
|
|
1233
|
+
<options>
|
|
1234
|
+
<uri>song.mp3</uri>
|
|
1235
|
+
<loop>1</loop>
|
|
1236
|
+
<volume>80</volume>
|
|
1237
|
+
</options>
|
|
1238
|
+
</media>
|
|
1239
|
+
</region>
|
|
1240
|
+
</layout>
|
|
1241
|
+
`;
|
|
1242
|
+
|
|
1243
|
+
const layout = renderer.parseXlf(xlf);
|
|
1244
|
+
const widget = layout.regions[0].widgets[0];
|
|
1245
|
+
|
|
1246
|
+
expect(widget.type).toBe('audio');
|
|
1247
|
+
expect(widget.duration).toBe(30);
|
|
1248
|
+
expect(widget.fileId).toBe('10');
|
|
1249
|
+
expect(widget.options.loop).toBe('1');
|
|
1250
|
+
expect(widget.options.volume).toBe('80');
|
|
1251
|
+
expect(widget.options.uri).toBe('song.mp3');
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('should create audio element inside container for audio widget', async () => {
|
|
1255
|
+
const xlf = `
|
|
1256
|
+
<layout>
|
|
1257
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1258
|
+
<media id="a1" type="audio" duration="10" fileId="10">
|
|
1259
|
+
<options>
|
|
1260
|
+
<uri>test.mp3</uri>
|
|
1261
|
+
<loop>0</loop>
|
|
1262
|
+
<volume>50</volume>
|
|
1263
|
+
</options>
|
|
1264
|
+
</media>
|
|
1265
|
+
</region>
|
|
1266
|
+
</layout>
|
|
1267
|
+
`;
|
|
1268
|
+
|
|
1269
|
+
await renderer.renderLayout(xlf, 1);
|
|
1270
|
+
|
|
1271
|
+
const region = renderer.regions.get('r1');
|
|
1272
|
+
expect(region).toBeDefined();
|
|
1273
|
+
expect(region.widgetElements.size).toBe(1);
|
|
1274
|
+
|
|
1275
|
+
const widgetEl = region.widgetElements.get('a1');
|
|
1276
|
+
expect(widgetEl).toBeDefined();
|
|
1277
|
+
expect(widgetEl.classList.contains('audio-widget')).toBe(true);
|
|
1278
|
+
|
|
1279
|
+
const audioEl = widgetEl.querySelector('audio');
|
|
1280
|
+
expect(audioEl).toBeDefined();
|
|
1281
|
+
expect(audioEl.autoplay).toBe(true);
|
|
1282
|
+
expect(audioEl.loop).toBe(false);
|
|
1283
|
+
expect(audioEl.volume).toBe(0.5);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it('should set loop=true on audio element when widget options loop is 1', async () => {
|
|
1287
|
+
const xlf = `
|
|
1288
|
+
<layout>
|
|
1289
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1290
|
+
<media id="a1" type="audio" duration="10" fileId="10">
|
|
1291
|
+
<options>
|
|
1292
|
+
<uri>test.mp3</uri>
|
|
1293
|
+
<loop>1</loop>
|
|
1294
|
+
<volume>100</volume>
|
|
1295
|
+
</options>
|
|
1296
|
+
</media>
|
|
1297
|
+
</region>
|
|
1298
|
+
</layout>
|
|
1299
|
+
`;
|
|
1300
|
+
|
|
1301
|
+
await renderer.renderLayout(xlf, 1);
|
|
1302
|
+
|
|
1303
|
+
const widgetEl = renderer.regions.get('r1').widgetElements.get('a1');
|
|
1304
|
+
const audioEl = widgetEl.querySelector('audio');
|
|
1305
|
+
expect(audioEl.loop).toBe(true);
|
|
1306
|
+
expect(audioEl.volume).toBe(1);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it('should default volume to 100 when not specified', async () => {
|
|
1310
|
+
const xlf = `
|
|
1311
|
+
<layout>
|
|
1312
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1313
|
+
<media id="a1" type="audio" duration="10" fileId="10">
|
|
1314
|
+
<options><uri>test.mp3</uri></options>
|
|
1315
|
+
</media>
|
|
1316
|
+
</region>
|
|
1317
|
+
</layout>
|
|
1318
|
+
`;
|
|
1319
|
+
|
|
1320
|
+
await renderer.renderLayout(xlf, 1);
|
|
1321
|
+
|
|
1322
|
+
const widgetEl = renderer.regions.get('r1').widgetElements.get('a1');
|
|
1323
|
+
const audioEl = widgetEl.querySelector('audio');
|
|
1324
|
+
expect(audioEl.volume).toBe(1);
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
describe('Audio Overlay Support', () => {
|
|
1329
|
+
it('should parse audio overlay nodes from widget XML', () => {
|
|
1330
|
+
const xlf = `
|
|
1331
|
+
<layout>
|
|
1332
|
+
<region id="r1">
|
|
1333
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1334
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="80" loop="1"/>
|
|
1335
|
+
<options><uri>test.png</uri></options>
|
|
1336
|
+
</media>
|
|
1337
|
+
</region>
|
|
1338
|
+
</layout>
|
|
1339
|
+
`;
|
|
1340
|
+
|
|
1341
|
+
const layout = renderer.parseXlf(xlf);
|
|
1342
|
+
const widget = layout.regions[0].widgets[0];
|
|
1343
|
+
|
|
1344
|
+
expect(widget.audioNodes).toBeDefined();
|
|
1345
|
+
expect(widget.audioNodes).toHaveLength(1);
|
|
1346
|
+
expect(widget.audioNodes[0]).toEqual({
|
|
1347
|
+
mediaId: '50',
|
|
1348
|
+
uri: 'bgmusic.mp3',
|
|
1349
|
+
volume: 80,
|
|
1350
|
+
loop: true
|
|
1351
|
+
});
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it('should parse multiple audio overlay nodes', () => {
|
|
1355
|
+
const xlf = `
|
|
1356
|
+
<layout>
|
|
1357
|
+
<region id="r1">
|
|
1358
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1359
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="80" loop="1"/>
|
|
1360
|
+
<audio mediaId="51" uri="sfx.mp3" volume="40" loop="0"/>
|
|
1361
|
+
<options><uri>test.png</uri></options>
|
|
1362
|
+
</media>
|
|
1363
|
+
</region>
|
|
1364
|
+
</layout>
|
|
1365
|
+
`;
|
|
1366
|
+
|
|
1367
|
+
const layout = renderer.parseXlf(xlf);
|
|
1368
|
+
const widget = layout.regions[0].widgets[0];
|
|
1369
|
+
|
|
1370
|
+
expect(widget.audioNodes).toHaveLength(2);
|
|
1371
|
+
expect(widget.audioNodes[0].uri).toBe('bgmusic.mp3');
|
|
1372
|
+
expect(widget.audioNodes[0].loop).toBe(true);
|
|
1373
|
+
expect(widget.audioNodes[1].uri).toBe('sfx.mp3');
|
|
1374
|
+
expect(widget.audioNodes[1].loop).toBe(false);
|
|
1375
|
+
expect(widget.audioNodes[1].volume).toBe(40);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it('should parse spec-format audio nodes with <uri> child element', () => {
|
|
1379
|
+
const xlf = `
|
|
1380
|
+
<layout>
|
|
1381
|
+
<region id="r1">
|
|
1382
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1383
|
+
<audio>
|
|
1384
|
+
<uri volume="80" loop="1" mediaId="50">bgmusic.mp3</uri>
|
|
1385
|
+
</audio>
|
|
1386
|
+
<options><uri>test.png</uri></options>
|
|
1387
|
+
</media>
|
|
1388
|
+
</region>
|
|
1389
|
+
</layout>
|
|
1390
|
+
`;
|
|
1391
|
+
|
|
1392
|
+
const layout = renderer.parseXlf(xlf);
|
|
1393
|
+
const widget = layout.regions[0].widgets[0];
|
|
1394
|
+
|
|
1395
|
+
expect(widget.audioNodes).toHaveLength(1);
|
|
1396
|
+
expect(widget.audioNodes[0]).toEqual({
|
|
1397
|
+
mediaId: '50',
|
|
1398
|
+
uri: 'bgmusic.mp3',
|
|
1399
|
+
volume: 80,
|
|
1400
|
+
loop: true
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
it('should handle mixed audio formats (spec + flat)', () => {
|
|
1405
|
+
const xlf = `
|
|
1406
|
+
<layout>
|
|
1407
|
+
<region id="r1">
|
|
1408
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1409
|
+
<audio>
|
|
1410
|
+
<uri volume="60" loop="0" mediaId="51">track1.mp3</uri>
|
|
1411
|
+
</audio>
|
|
1412
|
+
<audio mediaId="52" uri="track2.mp3" volume="40" loop="1"/>
|
|
1413
|
+
<options><uri>test.png</uri></options>
|
|
1414
|
+
</media>
|
|
1415
|
+
</region>
|
|
1416
|
+
</layout>
|
|
1417
|
+
`;
|
|
1418
|
+
|
|
1419
|
+
const layout = renderer.parseXlf(xlf);
|
|
1420
|
+
const widget = layout.regions[0].widgets[0];
|
|
1421
|
+
|
|
1422
|
+
expect(widget.audioNodes).toHaveLength(2);
|
|
1423
|
+
// Spec format
|
|
1424
|
+
expect(widget.audioNodes[0].uri).toBe('track1.mp3');
|
|
1425
|
+
expect(widget.audioNodes[0].mediaId).toBe('51');
|
|
1426
|
+
expect(widget.audioNodes[0].volume).toBe(60);
|
|
1427
|
+
expect(widget.audioNodes[0].loop).toBe(false);
|
|
1428
|
+
// Flat format
|
|
1429
|
+
expect(widget.audioNodes[1].uri).toBe('track2.mp3');
|
|
1430
|
+
expect(widget.audioNodes[1].mediaId).toBe('52');
|
|
1431
|
+
expect(widget.audioNodes[1].volume).toBe(40);
|
|
1432
|
+
expect(widget.audioNodes[1].loop).toBe(true);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('should have empty audioNodes when widget has no audio children', () => {
|
|
1436
|
+
const xlf = `
|
|
1437
|
+
<layout>
|
|
1438
|
+
<region id="r1">
|
|
1439
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1440
|
+
<options><uri>test.png</uri></options>
|
|
1441
|
+
</media>
|
|
1442
|
+
</region>
|
|
1443
|
+
</layout>
|
|
1444
|
+
`;
|
|
1445
|
+
|
|
1446
|
+
const layout = renderer.parseXlf(xlf);
|
|
1447
|
+
const widget = layout.regions[0].widgets[0];
|
|
1448
|
+
|
|
1449
|
+
expect(widget.audioNodes).toBeDefined();
|
|
1450
|
+
expect(widget.audioNodes).toHaveLength(0);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it('should create audio overlay elements when widget is shown', async () => {
|
|
1454
|
+
const xlf = `
|
|
1455
|
+
<layout>
|
|
1456
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1457
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1458
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="75" loop="1"/>
|
|
1459
|
+
<options><uri>test.png</uri></options>
|
|
1460
|
+
</media>
|
|
1461
|
+
</region>
|
|
1462
|
+
</layout>
|
|
1463
|
+
`;
|
|
1464
|
+
|
|
1465
|
+
await renderer.renderLayout(xlf, 1);
|
|
1466
|
+
// Explicitly show the widget to trigger audio overlay (renderLayout fires it async)
|
|
1467
|
+
const region = renderer.regions.get('r1');
|
|
1468
|
+
await renderer._showWidget(region, 0);
|
|
1469
|
+
|
|
1470
|
+
// Audio overlay should be tracked
|
|
1471
|
+
const overlays = renderer.audioOverlays.get('m1');
|
|
1472
|
+
expect(overlays).toBeDefined();
|
|
1473
|
+
expect(overlays).toHaveLength(1);
|
|
1474
|
+
expect(overlays[0]).toBeInstanceOf(HTMLAudioElement);
|
|
1475
|
+
expect(overlays[0].loop).toBe(true);
|
|
1476
|
+
expect(overlays[0].volume).toBeCloseTo(0.75);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it('should stop audio overlays when widget is hidden', async () => {
|
|
1480
|
+
const xlf = `
|
|
1481
|
+
<layout>
|
|
1482
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1483
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1484
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="80" loop="1"/>
|
|
1485
|
+
<options><uri>test.png</uri></options>
|
|
1486
|
+
</media>
|
|
1487
|
+
<media id="m2" type="image" duration="10" fileId="2">
|
|
1488
|
+
<options><uri>test2.png</uri></options>
|
|
1489
|
+
</media>
|
|
1490
|
+
</region>
|
|
1491
|
+
</layout>
|
|
1492
|
+
`;
|
|
1493
|
+
|
|
1494
|
+
await renderer.renderLayout(xlf, 1);
|
|
1495
|
+
// Explicitly show widget to trigger audio overlay
|
|
1496
|
+
const region = renderer.regions.get('r1');
|
|
1497
|
+
await renderer._showWidget(region, 0);
|
|
1498
|
+
|
|
1499
|
+
// Audio overlay should be active for m1
|
|
1500
|
+
expect(renderer.audioOverlays.has('m1')).toBe(true);
|
|
1501
|
+
|
|
1502
|
+
// Hide widget m1
|
|
1503
|
+
renderer._hideWidget(region, 0);
|
|
1504
|
+
|
|
1505
|
+
// Audio overlay should be cleaned up
|
|
1506
|
+
expect(renderer.audioOverlays.has('m1')).toBe(false);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('should clean up audio overlays on renderer cleanup', async () => {
|
|
1510
|
+
const xlf = `
|
|
1511
|
+
<layout>
|
|
1512
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1513
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1514
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="80" loop="1"/>
|
|
1515
|
+
<options><uri>test.png</uri></options>
|
|
1516
|
+
</media>
|
|
1517
|
+
</region>
|
|
1518
|
+
</layout>
|
|
1519
|
+
`;
|
|
1520
|
+
|
|
1521
|
+
await renderer.renderLayout(xlf, 1);
|
|
1522
|
+
// Explicitly show widget to trigger audio overlay
|
|
1523
|
+
const region = renderer.regions.get('r1');
|
|
1524
|
+
await renderer._showWidget(region, 0);
|
|
1525
|
+
expect(renderer.audioOverlays.size).toBeGreaterThan(0);
|
|
1526
|
+
|
|
1527
|
+
renderer.cleanup();
|
|
1528
|
+
expect(renderer.audioOverlays.size).toBe(0);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it('should clamp volume to valid range', async () => {
|
|
1532
|
+
const xlf = `
|
|
1533
|
+
<layout>
|
|
1534
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1535
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1536
|
+
<audio mediaId="50" uri="bgmusic.mp3" volume="150" loop="0"/>
|
|
1537
|
+
<options><uri>test.png</uri></options>
|
|
1538
|
+
</media>
|
|
1539
|
+
</region>
|
|
1540
|
+
</layout>
|
|
1541
|
+
`;
|
|
1542
|
+
|
|
1543
|
+
await renderer.renderLayout(xlf, 1);
|
|
1544
|
+
// Explicitly show widget to trigger audio overlay
|
|
1545
|
+
const region = renderer.regions.get('r1');
|
|
1546
|
+
await renderer._showWidget(region, 0);
|
|
1547
|
+
|
|
1548
|
+
const overlays = renderer.audioOverlays.get('m1');
|
|
1549
|
+
expect(overlays).toBeDefined();
|
|
1550
|
+
expect(overlays[0].volume).toBeLessThanOrEqual(1);
|
|
1551
|
+
});
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
describe('Widget webhookUrl parsing', () => {
|
|
1556
|
+
it('should parse webhookUrl from widget options', () => {
|
|
1557
|
+
const xlf = `
|
|
1558
|
+
<layout>
|
|
1559
|
+
<region id="r1">
|
|
1560
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1561
|
+
<options>
|
|
1562
|
+
<uri>test.png</uri>
|
|
1563
|
+
<webhookUrl>https://example.com/hook</webhookUrl>
|
|
1564
|
+
</options>
|
|
1565
|
+
</media>
|
|
1566
|
+
</region>
|
|
1567
|
+
</layout>
|
|
1568
|
+
`;
|
|
1569
|
+
|
|
1570
|
+
const layout = renderer.parseXlf(xlf);
|
|
1571
|
+
const widget = layout.regions[0].widgets[0];
|
|
1572
|
+
|
|
1573
|
+
expect(widget.webhookUrl).toBe('https://example.com/hook');
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
it('should set webhookUrl to null when not present in options', () => {
|
|
1577
|
+
const xlf = `
|
|
1578
|
+
<layout>
|
|
1579
|
+
<region id="r1">
|
|
1580
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1581
|
+
<options><uri>test.png</uri></options>
|
|
1582
|
+
</media>
|
|
1583
|
+
</region>
|
|
1584
|
+
</layout>
|
|
1585
|
+
`;
|
|
1586
|
+
|
|
1587
|
+
const layout = renderer.parseXlf(xlf);
|
|
1588
|
+
const widget = layout.regions[0].widgets[0];
|
|
1589
|
+
|
|
1590
|
+
expect(widget.webhookUrl).toBeNull();
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
describe('Widget duration completion webhook', () => {
|
|
1595
|
+
it('should emit widgetAction event when widget with webhookUrl reaches duration end', async () => {
|
|
1596
|
+
vi.useFakeTimers();
|
|
1597
|
+
|
|
1598
|
+
const xlf = `
|
|
1599
|
+
<layout duration="60">
|
|
1600
|
+
<region id="r1">
|
|
1601
|
+
<media id="m1" type="image" duration="5" fileId="1">
|
|
1602
|
+
<options>
|
|
1603
|
+
<uri>1.png</uri>
|
|
1604
|
+
<webhookUrl>https://example.com/hook</webhookUrl>
|
|
1605
|
+
</options>
|
|
1606
|
+
</media>
|
|
1607
|
+
<media id="m2" type="image" duration="5" fileId="2">
|
|
1608
|
+
<options><uri>2.png</uri></options>
|
|
1609
|
+
</media>
|
|
1610
|
+
</region>
|
|
1611
|
+
</layout>
|
|
1612
|
+
`;
|
|
1613
|
+
|
|
1614
|
+
const widgetActionHandler = vi.fn();
|
|
1615
|
+
renderer.on('widgetAction', widgetActionHandler);
|
|
1616
|
+
|
|
1617
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
1618
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
1619
|
+
await renderPromise;
|
|
1620
|
+
|
|
1621
|
+
// Advance past first widget duration (5s) to trigger timer
|
|
1622
|
+
vi.advanceTimersByTime(5000);
|
|
1623
|
+
|
|
1624
|
+
expect(widgetActionHandler).toHaveBeenCalledWith({
|
|
1625
|
+
type: 'durationEnd',
|
|
1626
|
+
widgetId: 'm1',
|
|
1627
|
+
layoutId: 1,
|
|
1628
|
+
regionId: 'r1',
|
|
1629
|
+
url: 'https://example.com/hook'
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
vi.useRealTimers();
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
it('should not emit widgetAction event when widget has no webhookUrl', async () => {
|
|
1636
|
+
vi.useFakeTimers();
|
|
1637
|
+
|
|
1638
|
+
const xlf = `
|
|
1639
|
+
<layout duration="60">
|
|
1640
|
+
<region id="r1">
|
|
1641
|
+
<media id="m1" type="image" duration="5" fileId="1">
|
|
1642
|
+
<options><uri>1.png</uri></options>
|
|
1643
|
+
</media>
|
|
1644
|
+
<media id="m2" type="image" duration="5" fileId="2">
|
|
1645
|
+
<options><uri>2.png</uri></options>
|
|
1646
|
+
</media>
|
|
1647
|
+
</region>
|
|
1648
|
+
</layout>
|
|
1649
|
+
`;
|
|
1650
|
+
|
|
1651
|
+
const widgetActionHandler = vi.fn();
|
|
1652
|
+
renderer.on('widgetAction', widgetActionHandler);
|
|
1653
|
+
|
|
1654
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
1655
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
1656
|
+
await renderPromise;
|
|
1657
|
+
|
|
1658
|
+
// Advance past first widget duration (5s) to trigger timer
|
|
1659
|
+
vi.advanceTimersByTime(5000);
|
|
1660
|
+
|
|
1661
|
+
expect(widgetActionHandler).not.toHaveBeenCalled();
|
|
1662
|
+
|
|
1663
|
+
vi.useRealTimers();
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
describe('Drawer Nodes (#11)', () => {
|
|
1668
|
+
it('should parse drawer elements as regions with isDrawer flag', () => {
|
|
1669
|
+
const xlf = `
|
|
1670
|
+
<layout width="1920" height="1080" duration="60">
|
|
1671
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1672
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1673
|
+
<options><uri>test.png</uri></options>
|
|
1674
|
+
</media>
|
|
1675
|
+
</region>
|
|
1676
|
+
<drawer id="d1" width="400" height="300" top="100" left="100">
|
|
1677
|
+
<media id="dm1" type="image" duration="5" fileId="2">
|
|
1678
|
+
<options><uri>drawer.png</uri></options>
|
|
1679
|
+
</media>
|
|
1680
|
+
</drawer>
|
|
1681
|
+
</layout>
|
|
1682
|
+
`;
|
|
1683
|
+
|
|
1684
|
+
const layout = renderer.parseXlf(xlf);
|
|
1685
|
+
|
|
1686
|
+
expect(layout.regions).toHaveLength(2);
|
|
1687
|
+
expect(layout.regions[0].isDrawer).toBe(false);
|
|
1688
|
+
expect(layout.regions[0].id).toBe('r1');
|
|
1689
|
+
expect(layout.regions[1].isDrawer).toBe(true);
|
|
1690
|
+
expect(layout.regions[1].id).toBe('d1');
|
|
1691
|
+
expect(layout.regions[1].widgets).toHaveLength(1);
|
|
1692
|
+
expect(layout.regions[1].widgets[0].id).toBe('dm1');
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
it('should exclude drawers from layout duration calculation', () => {
|
|
1696
|
+
const xlf = `
|
|
1697
|
+
<layout width="1920" height="1080">
|
|
1698
|
+
<region id="r1" width="960" height="540" top="0" left="0">
|
|
1699
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1700
|
+
<options><uri>test.png</uri></options>
|
|
1701
|
+
</media>
|
|
1702
|
+
</region>
|
|
1703
|
+
<drawer id="d1" width="400" height="300" top="100" left="100">
|
|
1704
|
+
<media id="dm1" type="image" duration="120" fileId="2">
|
|
1705
|
+
<options><uri>drawer.png</uri></options>
|
|
1706
|
+
</media>
|
|
1707
|
+
</drawer>
|
|
1708
|
+
</layout>
|
|
1709
|
+
`;
|
|
1710
|
+
|
|
1711
|
+
const layout = renderer.parseXlf(xlf);
|
|
1712
|
+
|
|
1713
|
+
// Duration should be 10s (from region), not 120s (from drawer)
|
|
1714
|
+
expect(layout.duration).toBe(10);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('should assign high z-index to drawer regions by default', () => {
|
|
1718
|
+
const xlf = `
|
|
1719
|
+
<layout width="1920" height="1080" duration="60">
|
|
1720
|
+
<region id="r1" width="960" height="540" top="0" left="0" zindex="1">
|
|
1721
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1722
|
+
<options><uri>test.png</uri></options>
|
|
1723
|
+
</media>
|
|
1724
|
+
</region>
|
|
1725
|
+
<drawer id="d1">
|
|
1726
|
+
<media id="dm1" type="image" duration="5" fileId="2">
|
|
1727
|
+
<options><uri>drawer.png</uri></options>
|
|
1728
|
+
</media>
|
|
1729
|
+
</drawer>
|
|
1730
|
+
</layout>
|
|
1731
|
+
`;
|
|
1732
|
+
|
|
1733
|
+
const layout = renderer.parseXlf(xlf);
|
|
1734
|
+
|
|
1735
|
+
expect(layout.regions[0].zindex).toBe(1);
|
|
1736
|
+
expect(layout.regions[1].zindex).toBe(2000); // Drawer default z-index
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it('should create drawer regions with display:none', async () => {
|
|
1740
|
+
vi.useFakeTimers();
|
|
1741
|
+
|
|
1742
|
+
const xlf = `
|
|
1743
|
+
<layout width="1920" height="1080" duration="60">
|
|
1744
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1745
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1746
|
+
<options><uri>test.png</uri></options>
|
|
1747
|
+
</media>
|
|
1748
|
+
</region>
|
|
1749
|
+
<drawer id="d1" width="400" height="300" top="100" left="100">
|
|
1750
|
+
<media id="dm1" type="image" duration="5" fileId="2">
|
|
1751
|
+
<options><uri>drawer.png</uri></options>
|
|
1752
|
+
</media>
|
|
1753
|
+
</drawer>
|
|
1754
|
+
</layout>
|
|
1755
|
+
`;
|
|
1756
|
+
|
|
1757
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
1758
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
1759
|
+
await renderPromise;
|
|
1760
|
+
|
|
1761
|
+
const drawerRegion = renderer.regions.get('d1');
|
|
1762
|
+
expect(drawerRegion).toBeDefined();
|
|
1763
|
+
expect(drawerRegion.isDrawer).toBe(true);
|
|
1764
|
+
expect(drawerRegion.element.style.display).toBe('none');
|
|
1765
|
+
|
|
1766
|
+
vi.useRealTimers();
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
it('should reveal drawer region when navigateToWidget targets drawer widget', async () => {
|
|
1770
|
+
vi.useFakeTimers();
|
|
1771
|
+
|
|
1772
|
+
const xlf = `
|
|
1773
|
+
<layout width="1920" height="1080" duration="60">
|
|
1774
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1775
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1776
|
+
<options><uri>test.png</uri></options>
|
|
1777
|
+
</media>
|
|
1778
|
+
</region>
|
|
1779
|
+
<drawer id="d1" width="400" height="300" top="100" left="100">
|
|
1780
|
+
<media id="dm1" type="image" duration="5" fileId="2">
|
|
1781
|
+
<options><uri>drawer.png</uri></options>
|
|
1782
|
+
</media>
|
|
1783
|
+
</drawer>
|
|
1784
|
+
</layout>
|
|
1785
|
+
`;
|
|
1786
|
+
|
|
1787
|
+
const renderPromise = renderer.renderLayout(xlf, 1);
|
|
1788
|
+
await vi.advanceTimersByTimeAsync(10000);
|
|
1789
|
+
await renderPromise;
|
|
1790
|
+
|
|
1791
|
+
// Drawer starts hidden
|
|
1792
|
+
const drawerRegion = renderer.regions.get('d1');
|
|
1793
|
+
expect(drawerRegion.element.style.display).toBe('none');
|
|
1794
|
+
|
|
1795
|
+
// Navigate to drawer widget — should reveal the drawer
|
|
1796
|
+
renderer.navigateToWidget('dm1');
|
|
1797
|
+
expect(drawerRegion.element.style.display).toBe('');
|
|
1798
|
+
|
|
1799
|
+
vi.useRealTimers();
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
describe('Sub-Playlist (#10)', () => {
|
|
1804
|
+
it('should parse sub-playlist attributes from media elements', () => {
|
|
1805
|
+
const xlf = `
|
|
1806
|
+
<layout width="1920" height="1080" duration="60">
|
|
1807
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1808
|
+
<media id="m1" type="image" duration="10" parentWidgetId="sp1"
|
|
1809
|
+
displayOrder="1" cyclePlayback="1" playCount="1" isRandom="0" fileId="1">
|
|
1810
|
+
<options><uri>img1.png</uri></options>
|
|
1811
|
+
</media>
|
|
1812
|
+
<media id="m2" type="image" duration="10" parentWidgetId="sp1"
|
|
1813
|
+
displayOrder="2" cyclePlayback="1" playCount="1" isRandom="0" fileId="2">
|
|
1814
|
+
<options><uri>img2.png</uri></options>
|
|
1815
|
+
</media>
|
|
1816
|
+
</region>
|
|
1817
|
+
</layout>
|
|
1818
|
+
`;
|
|
1819
|
+
|
|
1820
|
+
const layout = renderer.parseXlf(xlf);
|
|
1821
|
+
const w1 = layout.regions[0].widgets[0];
|
|
1822
|
+
const w2 = layout.regions[0].widgets[1];
|
|
1823
|
+
|
|
1824
|
+
expect(w1.parentWidgetId).toBe('sp1');
|
|
1825
|
+
expect(w1.displayOrder).toBe(1);
|
|
1826
|
+
expect(w1.cyclePlayback).toBe(true);
|
|
1827
|
+
expect(w1.playCount).toBe(1);
|
|
1828
|
+
expect(w1.isRandom).toBe(false);
|
|
1829
|
+
|
|
1830
|
+
expect(w2.parentWidgetId).toBe('sp1');
|
|
1831
|
+
expect(w2.displayOrder).toBe(2);
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it('should select one widget per group when cyclePlayback is enabled', () => {
|
|
1835
|
+
const widgets = [
|
|
1836
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1837
|
+
displayOrder: 1, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1838
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1839
|
+
displayOrder: 2, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1840
|
+
{ id: 'm3', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1841
|
+
displayOrder: 3, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1842
|
+
];
|
|
1843
|
+
|
|
1844
|
+
const result = renderer._applyCyclePlayback(widgets);
|
|
1845
|
+
|
|
1846
|
+
// Should select exactly 1 widget from the 3-widget group
|
|
1847
|
+
expect(result).toHaveLength(1);
|
|
1848
|
+
expect(['m1', 'm2', 'm3']).toContain(result[0].id);
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it('should pass through non-grouped widgets unchanged', () => {
|
|
1852
|
+
const widgets = [
|
|
1853
|
+
{ id: 'standalone', type: 'image', duration: 10,
|
|
1854
|
+
parentWidgetId: null, cyclePlayback: false },
|
|
1855
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1856
|
+
displayOrder: 1, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1857
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1858
|
+
displayOrder: 2, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1859
|
+
];
|
|
1860
|
+
|
|
1861
|
+
const result = renderer._applyCyclePlayback(widgets);
|
|
1862
|
+
|
|
1863
|
+
// Standalone + 1 from group = 2
|
|
1864
|
+
expect(result).toHaveLength(2);
|
|
1865
|
+
expect(result[0].id).toBe('standalone');
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
it('should round-robin across cycles for deterministic playback', () => {
|
|
1869
|
+
const widgets = [
|
|
1870
|
+
{ id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1871
|
+
displayOrder: 1, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1872
|
+
{ id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
|
|
1873
|
+
displayOrder: 2, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1874
|
+
];
|
|
1875
|
+
|
|
1876
|
+
// Reset cycle index for clean test
|
|
1877
|
+
renderer._subPlaylistCycleIndex = new Map();
|
|
1878
|
+
|
|
1879
|
+
const result1 = renderer._applyCyclePlayback(widgets);
|
|
1880
|
+
const result2 = renderer._applyCyclePlayback(widgets);
|
|
1881
|
+
|
|
1882
|
+
// First cycle picks widget at index 0, second cycle picks at index 1
|
|
1883
|
+
expect(result1[0].id).toBe('m1');
|
|
1884
|
+
expect(result2[0].id).toBe('m2');
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it('should handle multiple groups independently', () => {
|
|
1888
|
+
const widgets = [
|
|
1889
|
+
{ id: 'a1', type: 'image', duration: 10, parentWidgetId: 'grpA',
|
|
1890
|
+
displayOrder: 1, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1891
|
+
{ id: 'a2', type: 'image', duration: 10, parentWidgetId: 'grpA',
|
|
1892
|
+
displayOrder: 2, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1893
|
+
{ id: 'b1', type: 'image', duration: 10, parentWidgetId: 'grpB',
|
|
1894
|
+
displayOrder: 1, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1895
|
+
{ id: 'b2', type: 'image', duration: 10, parentWidgetId: 'grpB',
|
|
1896
|
+
displayOrder: 2, cyclePlayback: true, playCount: 1, isRandom: false },
|
|
1897
|
+
];
|
|
1898
|
+
|
|
1899
|
+
renderer._subPlaylistCycleIndex = new Map();
|
|
1900
|
+
const result = renderer._applyCyclePlayback(widgets);
|
|
1901
|
+
|
|
1902
|
+
// 1 from each group = 2 total
|
|
1903
|
+
expect(result).toHaveLength(2);
|
|
1904
|
+
const ids = result.map(w => w.id);
|
|
1905
|
+
// Should have one from grpA and one from grpB
|
|
1906
|
+
expect(ids.some(id => id.startsWith('a'))).toBe(true);
|
|
1907
|
+
expect(ids.some(id => id.startsWith('b'))).toBe(true);
|
|
1908
|
+
});
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
// ── Medium-Priority Spec Compliance ────────────────────────────────
|
|
1912
|
+
|
|
1913
|
+
describe('Widget fromDt/toDt Expiry', () => {
|
|
1914
|
+
it('should parse fromDt and toDt attributes on widgets', () => {
|
|
1915
|
+
const xlf = `
|
|
1916
|
+
<layout width="1920" height="1080" duration="60">
|
|
1917
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1918
|
+
<media id="m1" type="image" duration="10" fileId="1"
|
|
1919
|
+
fromDt="2025-01-01 09:00:00" toDt="2025-12-31 17:00:00">
|
|
1920
|
+
<options><uri>test.png</uri></options>
|
|
1921
|
+
</media>
|
|
1922
|
+
</region>
|
|
1923
|
+
</layout>
|
|
1924
|
+
`;
|
|
1925
|
+
const layout = renderer.parseXlf(xlf);
|
|
1926
|
+
expect(layout.regions[0].widgets[0].fromDt).toBe('2025-01-01 09:00:00');
|
|
1927
|
+
expect(layout.regions[0].widgets[0].toDt).toBe('2025-12-31 17:00:00');
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
it('should set fromDt/toDt to null when absent', () => {
|
|
1931
|
+
const xlf = `
|
|
1932
|
+
<layout width="1920" height="1080" duration="60">
|
|
1933
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1934
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1935
|
+
<options><uri>test.png</uri></options>
|
|
1936
|
+
</media>
|
|
1937
|
+
</region>
|
|
1938
|
+
</layout>
|
|
1939
|
+
`;
|
|
1940
|
+
const layout = renderer.parseXlf(xlf);
|
|
1941
|
+
expect(layout.regions[0].widgets[0].fromDt).toBeNull();
|
|
1942
|
+
expect(layout.regions[0].widgets[0].toDt).toBeNull();
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
it('should filter out expired widgets (toDt in the past)', () => {
|
|
1946
|
+
const widget = { id: 'm1', fromDt: null, toDt: '2020-01-01 00:00:00' };
|
|
1947
|
+
expect(renderer._isWidgetActive(widget)).toBe(false);
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
it('should filter out future widgets (fromDt in the future)', () => {
|
|
1951
|
+
const widget = { id: 'm1', fromDt: '2099-12-31 23:59:59', toDt: null };
|
|
1952
|
+
expect(renderer._isWidgetActive(widget)).toBe(false);
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
it('should accept widgets with no date constraints', () => {
|
|
1956
|
+
const widget = { id: 'm1', fromDt: null, toDt: null };
|
|
1957
|
+
expect(renderer._isWidgetActive(widget)).toBe(true);
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
it('should accept widgets within their date range', () => {
|
|
1961
|
+
const widget = { id: 'm1', fromDt: '2020-01-01 00:00:00', toDt: '2099-12-31 23:59:59' };
|
|
1962
|
+
expect(renderer._isWidgetActive(widget)).toBe(true);
|
|
1963
|
+
});
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
describe('Render Attribute', () => {
|
|
1967
|
+
it('should parse render attribute on widgets', () => {
|
|
1968
|
+
const xlf = `
|
|
1969
|
+
<layout width="1920" height="1080" duration="60">
|
|
1970
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1971
|
+
<media id="m1" type="text" duration="10" render="native">
|
|
1972
|
+
<options><uri>test.html</uri></options>
|
|
1973
|
+
</media>
|
|
1974
|
+
</region>
|
|
1975
|
+
</layout>
|
|
1976
|
+
`;
|
|
1977
|
+
const layout = renderer.parseXlf(xlf);
|
|
1978
|
+
expect(layout.regions[0].widgets[0].render).toBe('native');
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
it('should set render to null when absent', () => {
|
|
1982
|
+
const xlf = `
|
|
1983
|
+
<layout width="1920" height="1080" duration="60">
|
|
1984
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
1985
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
1986
|
+
<options><uri>test.png</uri></options>
|
|
1987
|
+
</media>
|
|
1988
|
+
</region>
|
|
1989
|
+
</layout>
|
|
1990
|
+
`;
|
|
1991
|
+
const layout = renderer.parseXlf(xlf);
|
|
1992
|
+
expect(layout.regions[0].widgets[0].render).toBeNull();
|
|
1993
|
+
});
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
describe('NUMITEMS/DURATION HTML Comments', () => {
|
|
1997
|
+
it('should override widget duration with DURATION comment', () => {
|
|
1998
|
+
const widget = { id: 'w1', duration: 10 };
|
|
1999
|
+
const html = '<html><!-- DURATION=45 --><body>content</body></html>';
|
|
2000
|
+
renderer._parseDurationComments(html, widget);
|
|
2001
|
+
expect(widget.duration).toBe(45);
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
it('should multiply duration by NUMITEMS when no DURATION present', () => {
|
|
2005
|
+
const widget = { id: 'w2', duration: 5 };
|
|
2006
|
+
const html = '<html><!-- NUMITEMS=8 --><body>content</body></html>';
|
|
2007
|
+
renderer._parseDurationComments(html, widget);
|
|
2008
|
+
expect(widget.duration).toBe(40); // 8 × 5
|
|
2009
|
+
});
|
|
2010
|
+
|
|
2011
|
+
it('should prefer DURATION over NUMITEMS when both present', () => {
|
|
2012
|
+
const widget = { id: 'w3', duration: 5 };
|
|
2013
|
+
const html = '<html><!-- NUMITEMS=8 --><!-- DURATION=30 --><body>content</body></html>';
|
|
2014
|
+
renderer._parseDurationComments(html, widget);
|
|
2015
|
+
expect(widget.duration).toBe(30); // DURATION takes precedence
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it('should not modify duration when no comments present', () => {
|
|
2019
|
+
const widget = { id: 'w4', duration: 15 };
|
|
2020
|
+
const html = '<html><body>plain content</body></html>';
|
|
2021
|
+
renderer._parseDurationComments(html, widget);
|
|
2022
|
+
expect(widget.duration).toBe(15);
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
it('should handle whitespace variations in comments', () => {
|
|
2026
|
+
const widget = { id: 'w5', duration: 10 };
|
|
2027
|
+
const html = '<html><!-- DURATION=60 --><body>content</body></html>';
|
|
2028
|
+
renderer._parseDurationComments(html, widget);
|
|
2029
|
+
expect(widget.duration).toBe(60);
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
// ── Low-Priority Spec Compliance ─────────────────────────────────
|
|
2034
|
+
|
|
2035
|
+
describe('Layout schemaVersion', () => {
|
|
2036
|
+
it('should parse schemaVersion from layout element', () => {
|
|
2037
|
+
const xlf = `
|
|
2038
|
+
<layout schemaVersion="5" width="1920" height="1080" duration="60">
|
|
2039
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2040
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2041
|
+
<options><uri>test.png</uri></options>
|
|
2042
|
+
</media>
|
|
2043
|
+
</region>
|
|
2044
|
+
</layout>
|
|
2045
|
+
`;
|
|
2046
|
+
const layout = renderer.parseXlf(xlf);
|
|
2047
|
+
expect(layout.schemaVersion).toBe(5);
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it('should default schemaVersion to 1 when absent', () => {
|
|
2051
|
+
const xlf = `
|
|
2052
|
+
<layout width="1920" height="1080" duration="60">
|
|
2053
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2054
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2055
|
+
<options><uri>test.png</uri></options>
|
|
2056
|
+
</media>
|
|
2057
|
+
</region>
|
|
2058
|
+
</layout>
|
|
2059
|
+
`;
|
|
2060
|
+
const layout = renderer.parseXlf(xlf);
|
|
2061
|
+
expect(layout.schemaVersion).toBe(1);
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
describe('Layout backgroundColor', () => {
|
|
2066
|
+
it('should prefer backgroundColor over bgcolor', () => {
|
|
2067
|
+
const xlf = `
|
|
2068
|
+
<layout backgroundColor="#FF0000" bgcolor="#00FF00" width="1920" height="1080" duration="60">
|
|
2069
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2070
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2071
|
+
<options><uri>test.png</uri></options>
|
|
2072
|
+
</media>
|
|
2073
|
+
</region>
|
|
2074
|
+
</layout>
|
|
2075
|
+
`;
|
|
2076
|
+
const layout = renderer.parseXlf(xlf);
|
|
2077
|
+
expect(layout.bgcolor).toBe('#FF0000');
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
it('should fall back to bgcolor when backgroundColor absent', () => {
|
|
2081
|
+
const xlf = `
|
|
2082
|
+
<layout bgcolor="#00FF00" width="1920" height="1080" duration="60">
|
|
2083
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2084
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2085
|
+
<options><uri>test.png</uri></options>
|
|
2086
|
+
</media>
|
|
2087
|
+
</region>
|
|
2088
|
+
</layout>
|
|
2089
|
+
`;
|
|
2090
|
+
const layout = renderer.parseXlf(xlf);
|
|
2091
|
+
expect(layout.bgcolor).toBe('#00FF00');
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
describe('Region enableStat', () => {
|
|
2096
|
+
it('should parse enableStat on region elements', () => {
|
|
2097
|
+
const xlf = `
|
|
2098
|
+
<layout width="1920" height="1080" duration="60">
|
|
2099
|
+
<region id="r1" width="960" height="540" top="0" left="0" enableStat="0">
|
|
2100
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2101
|
+
<options><uri>test.png</uri></options>
|
|
2102
|
+
</media>
|
|
2103
|
+
</region>
|
|
2104
|
+
<region id="r2" width="960" height="540" top="0" left="960" enableStat="1">
|
|
2105
|
+
<media id="m2" type="image" duration="10" fileId="2">
|
|
2106
|
+
<options><uri>test2.png</uri></options>
|
|
2107
|
+
</media>
|
|
2108
|
+
</region>
|
|
2109
|
+
</layout>
|
|
2110
|
+
`;
|
|
2111
|
+
const layout = renderer.parseXlf(xlf);
|
|
2112
|
+
expect(layout.regions[0].enableStat).toBe(false);
|
|
2113
|
+
expect(layout.regions[1].enableStat).toBe(true);
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it('should default enableStat to true when absent', () => {
|
|
2117
|
+
const xlf = `
|
|
2118
|
+
<layout width="1920" height="1080" duration="60">
|
|
2119
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2120
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2121
|
+
<options><uri>test.png</uri></options>
|
|
2122
|
+
</media>
|
|
2123
|
+
</region>
|
|
2124
|
+
</layout>
|
|
2125
|
+
`;
|
|
2126
|
+
const layout = renderer.parseXlf(xlf);
|
|
2127
|
+
expect(layout.regions[0].enableStat).toBe(true);
|
|
2128
|
+
});
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
describe('Layout-level actions', () => {
|
|
2132
|
+
it('should parse action elements at layout level', () => {
|
|
2133
|
+
const xlf = `
|
|
2134
|
+
<layout width="1920" height="1080" duration="60">
|
|
2135
|
+
<action actionType="navLayout" triggerType="webhook" triggerCode="showPromo"
|
|
2136
|
+
layoutCode="promo-1"/>
|
|
2137
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2138
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2139
|
+
<options><uri>test.png</uri></options>
|
|
2140
|
+
</media>
|
|
2141
|
+
</region>
|
|
2142
|
+
</layout>
|
|
2143
|
+
`;
|
|
2144
|
+
const layout = renderer.parseXlf(xlf);
|
|
2145
|
+
expect(layout.actions).toHaveLength(1);
|
|
2146
|
+
expect(layout.actions[0].actionType).toBe('navLayout');
|
|
2147
|
+
expect(layout.actions[0].triggerType).toBe('webhook');
|
|
2148
|
+
expect(layout.actions[0].triggerCode).toBe('showPromo');
|
|
2149
|
+
expect(layout.actions[0].layoutCode).toBe('promo-1');
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
it('should return empty actions array when no layout-level actions', () => {
|
|
2153
|
+
const xlf = `
|
|
2154
|
+
<layout width="1920" height="1080" duration="60">
|
|
2155
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2156
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2157
|
+
<options><uri>test.png</uri></options>
|
|
2158
|
+
</media>
|
|
2159
|
+
</region>
|
|
2160
|
+
</layout>
|
|
2161
|
+
`;
|
|
2162
|
+
const layout = renderer.parseXlf(xlf);
|
|
2163
|
+
expect(layout.actions).toEqual([]);
|
|
2164
|
+
});
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
describe('Region loop option', () => {
|
|
2168
|
+
it('should default loop to true when no loop option present', () => {
|
|
2169
|
+
const xlf = `
|
|
2170
|
+
<layout width="1920" height="1080">
|
|
2171
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2172
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2173
|
+
<options><uri>test.png</uri></options>
|
|
2174
|
+
</media>
|
|
2175
|
+
</region>
|
|
2176
|
+
</layout>
|
|
2177
|
+
`;
|
|
2178
|
+
const layout = renderer.parseXlf(xlf);
|
|
2179
|
+
expect(layout.regions[0].loop).toBe(true);
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
it('should set loop to false when loop option is 0', () => {
|
|
2183
|
+
const xlf = `
|
|
2184
|
+
<layout width="1920" height="1080">
|
|
2185
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2186
|
+
<options><loop>0</loop></options>
|
|
2187
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2188
|
+
<options><uri>test.png</uri></options>
|
|
2189
|
+
</media>
|
|
2190
|
+
</region>
|
|
2191
|
+
</layout>
|
|
2192
|
+
`;
|
|
2193
|
+
const layout = renderer.parseXlf(xlf);
|
|
2194
|
+
expect(layout.regions[0].loop).toBe(false);
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
it('should set loop to true when loop option is 1', () => {
|
|
2198
|
+
const xlf = `
|
|
2199
|
+
<layout width="1920" height="1080">
|
|
2200
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2201
|
+
<options><loop>1</loop></options>
|
|
2202
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2203
|
+
<options><uri>test.png</uri></options>
|
|
2204
|
+
</media>
|
|
2205
|
+
</region>
|
|
2206
|
+
</layout>
|
|
2207
|
+
`;
|
|
2208
|
+
const layout = renderer.parseXlf(xlf);
|
|
2209
|
+
expect(layout.regions[0].loop).toBe(true);
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
describe('Widget commands parsing', () => {
|
|
2214
|
+
it('should parse commands on media elements', () => {
|
|
2215
|
+
const xlf = `
|
|
2216
|
+
<layout width="1920" height="1080">
|
|
2217
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2218
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2219
|
+
<options><uri>test.png</uri></options>
|
|
2220
|
+
<commands>
|
|
2221
|
+
<command commandCode="shellCommand" commandString="echo hello"/>
|
|
2222
|
+
<command commandCode="reboot" commandString=""/>
|
|
2223
|
+
</commands>
|
|
2224
|
+
</media>
|
|
2225
|
+
</region>
|
|
2226
|
+
</layout>
|
|
2227
|
+
`;
|
|
2228
|
+
const layout = renderer.parseXlf(xlf);
|
|
2229
|
+
const widget = layout.regions[0].widgets[0];
|
|
2230
|
+
expect(widget.commands).toHaveLength(2);
|
|
2231
|
+
expect(widget.commands[0].commandCode).toBe('shellCommand');
|
|
2232
|
+
expect(widget.commands[0].commandString).toBe('echo hello');
|
|
2233
|
+
expect(widget.commands[1].commandCode).toBe('reboot');
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
it('should return empty commands array when no commands element', () => {
|
|
2237
|
+
const xlf = `
|
|
2238
|
+
<layout width="1920" height="1080">
|
|
2239
|
+
<region id="r1" width="1920" height="1080" top="0" left="0">
|
|
2240
|
+
<media id="m1" type="image" duration="10" fileId="1">
|
|
2241
|
+
<options><uri>test.png</uri></options>
|
|
2242
|
+
</media>
|
|
2243
|
+
</region>
|
|
2244
|
+
</layout>
|
|
2245
|
+
`;
|
|
2246
|
+
const layout = renderer.parseXlf(xlf);
|
|
2247
|
+
expect(layout.regions[0].widgets[0].commands).toEqual([]);
|
|
2248
|
+
});
|
|
2249
|
+
});
|
|
981
2250
|
});
|