@xiboplayer/renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * Tests for RendererLite overlay rendering
3
+ *
4
+ * Tests overlay layout rendering on top of main layouts
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { RendererLite } from './renderer-lite.js';
9
+
10
+ // Mock logger
11
+ vi.mock('@xiboplayer/utils', () => ({
12
+ createLogger: () => ({
13
+ info: vi.fn(),
14
+ warn: vi.fn(),
15
+ error: vi.fn(),
16
+ debug: vi.fn()
17
+ })
18
+ }));
19
+
20
+ describe('RendererLite - Overlay Rendering', () => {
21
+ let renderer;
22
+ let container;
23
+ let blobCounter;
24
+
25
+ // Sample XLF for overlay
26
+ const overlayXLF = `<?xml version="1.0"?>
27
+ <layout width="1920" height="1080" bgcolor="#00000080">
28
+ <region id="overlay-1" width="400" height="200" top="50" left="50" zindex="10">
29
+ <media id="1" type="text" duration="10">
30
+ <raw><![CDATA[<p>Overlay Content</p>]]></raw>
31
+ </media>
32
+ </region>
33
+ </layout>`;
34
+
35
+ // Sample XLF for main layout
36
+ const mainLayoutXLF = `<?xml version="1.0"?>
37
+ <layout width="1920" height="1080" bgcolor="#000000">
38
+ <region id="main-1" width="1920" height="1080" top="0" left="0" zindex="0">
39
+ <media id="1" type="text" duration="30">
40
+ <raw><![CDATA[<p>Main Content</p>]]></raw>
41
+ </media>
42
+ </region>
43
+ </layout>`;
44
+
45
+ beforeEach(() => {
46
+ // Mock URL.createObjectURL and URL.revokeObjectURL (not available in jsdom)
47
+ blobCounter = 0;
48
+ if (!URL.createObjectURL) {
49
+ URL.createObjectURL = vi.fn(() => `blob:test/${++blobCounter}`);
50
+ }
51
+ if (!URL.revokeObjectURL) {
52
+ URL.revokeObjectURL = vi.fn();
53
+ }
54
+
55
+ // Create container
56
+ container = document.createElement('div');
57
+ container.id = 'test-container';
58
+ document.body.appendChild(container);
59
+
60
+ // Create renderer
61
+ renderer = new RendererLite(
62
+ {
63
+ cmsUrl: 'http://test.local',
64
+ hardwareKey: 'test-key'
65
+ },
66
+ container,
67
+ {
68
+ getMediaUrl: async (fileId) => `http://test.local/media/${fileId}`,
69
+ getWidgetHtml: async (widget) => widget.raw || '<p>Widget HTML</p>'
70
+ }
71
+ );
72
+ });
73
+
74
+ afterEach(() => {
75
+ renderer.cleanup();
76
+ // Container may have been removed from DOM by layoutPool.evict() during cleanup
77
+ if (container.parentNode) {
78
+ container.parentNode.removeChild(container);
79
+ }
80
+ });
81
+
82
+ describe('Overlay Container Setup', () => {
83
+ it('should create overlay container on init', () => {
84
+ const overlayContainer = container.querySelector('#overlay-container');
85
+
86
+ expect(overlayContainer).toBeDefined();
87
+ expect(overlayContainer).not.toBeNull();
88
+ });
89
+
90
+ it('should set correct z-index for overlay container', () => {
91
+ const overlayContainer = container.querySelector('#overlay-container');
92
+
93
+ expect(overlayContainer.style.zIndex).toBe('1000');
94
+ });
95
+
96
+ it('should set overlay container to full size', () => {
97
+ const overlayContainer = container.querySelector('#overlay-container');
98
+
99
+ expect(overlayContainer.style.width).toBe('100%');
100
+ expect(overlayContainer.style.height).toBe('100%');
101
+ });
102
+
103
+ it('should set pointer-events to none on overlay container', () => {
104
+ const overlayContainer = container.querySelector('#overlay-container');
105
+
106
+ expect(overlayContainer.style.pointerEvents).toBe('none');
107
+ });
108
+ });
109
+
110
+ describe('renderOverlay()', () => {
111
+ it('should render overlay on top of main layout', async () => {
112
+ // Render main layout first
113
+ await renderer.renderLayout(mainLayoutXLF, 100);
114
+
115
+ // Render overlay
116
+ await renderer.renderOverlay(overlayXLF, 200, 10);
117
+
118
+ // Check overlay exists
119
+ const overlayDiv = container.querySelector('#overlay_200');
120
+ expect(overlayDiv).toBeDefined();
121
+ expect(overlayDiv).not.toBeNull();
122
+ });
123
+
124
+ it('should set correct z-index based on priority', async () => {
125
+ await renderer.renderOverlay(overlayXLF, 200, 15);
126
+
127
+ const overlayDiv = container.querySelector('#overlay_200');
128
+ expect(overlayDiv.style.zIndex).toBe('1015'); // 1000 + priority
129
+ });
130
+
131
+ it('should create regions for overlay', async () => {
132
+ await renderer.renderOverlay(overlayXLF, 200, 10);
133
+
134
+ const region = container.querySelector('#overlay_200_region_overlay-1');
135
+ expect(region).toBeDefined();
136
+ expect(region).not.toBeNull();
137
+ });
138
+
139
+ it('should emit overlayStart event', async () => {
140
+ const startListener = vi.fn();
141
+ renderer.on('overlayStart', startListener);
142
+
143
+ await renderer.renderOverlay(overlayXLF, 200, 10);
144
+
145
+ expect(startListener).toHaveBeenCalledWith(200, expect.any(Object));
146
+ });
147
+
148
+ it('should store overlay in activeOverlays', async () => {
149
+ await renderer.renderOverlay(overlayXLF, 200, 10);
150
+
151
+ expect(renderer.activeOverlays.has(200)).toBe(true);
152
+ const overlayState = renderer.activeOverlays.get(200);
153
+ expect(overlayState.priority).toBe(10);
154
+ });
155
+
156
+ it('should skip if overlay already active', async () => {
157
+ await renderer.renderOverlay(overlayXLF, 200, 10);
158
+ const firstOverlayDiv = container.querySelector('#overlay_200');
159
+
160
+ // Try to render same overlay again
161
+ await renderer.renderOverlay(overlayXLF, 200, 10);
162
+
163
+ // Should still be the same element
164
+ const secondOverlayDiv = container.querySelector('#overlay_200');
165
+ expect(secondOverlayDiv).toBe(firstOverlayDiv);
166
+ });
167
+
168
+ it('should pre-fetch media URLs for overlay widgets', async () => {
169
+ const getMediaUrl = vi.fn(async (fileId) => `http://test.local/media/${fileId}`);
170
+
171
+ const customRenderer = new RendererLite(
172
+ { cmsUrl: 'http://test.local', hardwareKey: 'test-key' },
173
+ container,
174
+ {
175
+ getMediaUrl,
176
+ getWidgetHtml: async (widget) => widget.raw
177
+ }
178
+ );
179
+
180
+ const xlfWithMedia = `<?xml version="1.0"?>
181
+ <layout width="1920" height="1080" bgcolor="#000000">
182
+ <region id="1" width="400" height="200" top="0" left="0" zindex="0">
183
+ <media id="10" fileId="555" type="image" duration="10">
184
+ <options><uri>test.jpg</uri></options>
185
+ </media>
186
+ </region>
187
+ </layout>`;
188
+
189
+ await customRenderer.renderOverlay(xlfWithMedia, 300, 10);
190
+
191
+ expect(getMediaUrl).toHaveBeenCalledWith(555);
192
+ });
193
+
194
+ it('should set overlay timer based on duration', async () => {
195
+ vi.useFakeTimers();
196
+
197
+ const endListener = vi.fn();
198
+ renderer.on('overlayEnd', endListener);
199
+
200
+ const xlfWith60s = `<?xml version="1.0"?>
201
+ <layout width="1920" height="1080" bgcolor="#000000" duration="60">
202
+ <region id="1" width="400" height="200" top="0" left="0" zindex="0">
203
+ <media id="1" type="text" duration="30">
204
+ <raw><![CDATA[<p>Test</p>]]></raw>
205
+ </media>
206
+ </region>
207
+ </layout>`;
208
+
209
+ await renderer.renderOverlay(xlfWith60s, 400, 10);
210
+
211
+ // Fast forward 60 seconds
212
+ vi.advanceTimersByTime(60000);
213
+
214
+ expect(endListener).toHaveBeenCalledWith(400);
215
+
216
+ vi.useRealTimers();
217
+ });
218
+ });
219
+
220
+ describe('Multiple overlays', () => {
221
+ it('should render multiple overlays simultaneously', async () => {
222
+ await renderer.renderOverlay(overlayXLF, 201, 10);
223
+ await renderer.renderOverlay(overlayXLF, 202, 20);
224
+ await renderer.renderOverlay(overlayXLF, 203, 5);
225
+
226
+ expect(renderer.activeOverlays.size).toBe(3);
227
+ expect(container.querySelector('#overlay_201')).not.toBeNull();
228
+ expect(container.querySelector('#overlay_202')).not.toBeNull();
229
+ expect(container.querySelector('#overlay_203')).not.toBeNull();
230
+ });
231
+
232
+ it('should set correct z-index for multiple overlays by priority', async () => {
233
+ await renderer.renderOverlay(overlayXLF, 201, 10);
234
+ await renderer.renderOverlay(overlayXLF, 202, 20);
235
+ await renderer.renderOverlay(overlayXLF, 203, 5);
236
+
237
+ const overlay1 = container.querySelector('#overlay_201');
238
+ const overlay2 = container.querySelector('#overlay_202');
239
+ const overlay3 = container.querySelector('#overlay_203');
240
+
241
+ expect(overlay1.style.zIndex).toBe('1010'); // 1000 + 10
242
+ expect(overlay2.style.zIndex).toBe('1020'); // 1000 + 20 (highest)
243
+ expect(overlay3.style.zIndex).toBe('1005'); // 1000 + 5 (lowest)
244
+ });
245
+
246
+ it('should return all active overlay IDs', async () => {
247
+ await renderer.renderOverlay(overlayXLF, 201, 10);
248
+ await renderer.renderOverlay(overlayXLF, 202, 20);
249
+
250
+ const activeIds = renderer.getActiveOverlays();
251
+
252
+ expect(activeIds).toContain(201);
253
+ expect(activeIds).toContain(202);
254
+ expect(activeIds.length).toBe(2);
255
+ });
256
+ });
257
+
258
+ describe('stopOverlay()', () => {
259
+ it('should remove overlay from DOM', async () => {
260
+ await renderer.renderOverlay(overlayXLF, 200, 10);
261
+ expect(container.querySelector('#overlay_200')).not.toBeNull();
262
+
263
+ renderer.stopOverlay(200);
264
+
265
+ expect(container.querySelector('#overlay_200')).toBeNull();
266
+ });
267
+
268
+ it('should remove overlay from activeOverlays', async () => {
269
+ await renderer.renderOverlay(overlayXLF, 200, 10);
270
+ expect(renderer.activeOverlays.has(200)).toBe(true);
271
+
272
+ renderer.stopOverlay(200);
273
+
274
+ expect(renderer.activeOverlays.has(200)).toBe(false);
275
+ });
276
+
277
+ it('should clear overlay timer', async () => {
278
+ vi.useFakeTimers();
279
+
280
+ await renderer.renderOverlay(overlayXLF, 200, 10);
281
+ const overlayState = renderer.activeOverlays.get(200);
282
+ expect(overlayState.timer).toBeDefined();
283
+
284
+ renderer.stopOverlay(200);
285
+
286
+ vi.advanceTimersByTime(100000); // Timer should not fire
287
+
288
+ vi.useRealTimers();
289
+ });
290
+
291
+ it('should emit overlayEnd event', async () => {
292
+ const endListener = vi.fn();
293
+ renderer.on('overlayEnd', endListener);
294
+
295
+ await renderer.renderOverlay(overlayXLF, 200, 10);
296
+ renderer.stopOverlay(200);
297
+
298
+ expect(endListener).toHaveBeenCalledWith(200);
299
+ });
300
+
301
+ it('should warn if overlay not active', () => {
302
+ const warnSpy = vi.spyOn(renderer.log, 'warn');
303
+
304
+ renderer.stopOverlay(999);
305
+
306
+ expect(warnSpy).toHaveBeenCalled();
307
+ });
308
+
309
+ it('should stop only specified overlay, leaving others active', async () => {
310
+ await renderer.renderOverlay(overlayXLF, 201, 10);
311
+ await renderer.renderOverlay(overlayXLF, 202, 20);
312
+
313
+ renderer.stopOverlay(201);
314
+
315
+ expect(renderer.activeOverlays.has(201)).toBe(false);
316
+ expect(renderer.activeOverlays.has(202)).toBe(true);
317
+ expect(container.querySelector('#overlay_201')).toBeNull();
318
+ expect(container.querySelector('#overlay_202')).not.toBeNull();
319
+ });
320
+ });
321
+
322
+ describe('stopAllOverlays()', () => {
323
+ it('should stop all active overlays', async () => {
324
+ await renderer.renderOverlay(overlayXLF, 201, 10);
325
+ await renderer.renderOverlay(overlayXLF, 202, 20);
326
+ await renderer.renderOverlay(overlayXLF, 203, 5);
327
+
328
+ expect(renderer.activeOverlays.size).toBe(3);
329
+
330
+ renderer.stopAllOverlays();
331
+
332
+ expect(renderer.activeOverlays.size).toBe(0);
333
+ expect(container.querySelector('#overlay_201')).toBeNull();
334
+ expect(container.querySelector('#overlay_202')).toBeNull();
335
+ expect(container.querySelector('#overlay_203')).toBeNull();
336
+ });
337
+
338
+ it('should emit overlayEnd for each overlay', async () => {
339
+ const endListener = vi.fn();
340
+ renderer.on('overlayEnd', endListener);
341
+
342
+ await renderer.renderOverlay(overlayXLF, 201, 10);
343
+ await renderer.renderOverlay(overlayXLF, 202, 20);
344
+
345
+ renderer.stopAllOverlays();
346
+
347
+ expect(endListener).toHaveBeenCalledTimes(2);
348
+ });
349
+ });
350
+
351
+ describe('Overlay with main layout', () => {
352
+ it('should render overlay on top of active main layout', async () => {
353
+ // Render main layout
354
+ await renderer.renderLayout(mainLayoutXLF, 100);
355
+
356
+ // Render overlay
357
+ await renderer.renderOverlay(overlayXLF, 200, 10);
358
+
359
+ // Both should exist
360
+ expect(renderer.currentLayoutId).toBe(100);
361
+ expect(renderer.activeOverlays.has(200)).toBe(true);
362
+
363
+ // Overlay should be in overlay container
364
+ const overlayDiv = container.querySelector('#overlay_200');
365
+ const overlayContainer = container.querySelector('#overlay-container');
366
+ expect(overlayContainer.contains(overlayDiv)).toBe(true);
367
+ });
368
+
369
+ it('should keep overlay active when main layout changes', async () => {
370
+ // Render main layout
371
+ await renderer.renderLayout(mainLayoutXLF, 100);
372
+
373
+ // Render overlay
374
+ await renderer.renderOverlay(overlayXLF, 200, 10);
375
+
376
+ // Change main layout
377
+ await renderer.renderLayout(mainLayoutXLF, 101);
378
+
379
+ // Overlay should still be active
380
+ expect(renderer.activeOverlays.has(200)).toBe(true);
381
+ expect(container.querySelector('#overlay_200')).not.toBeNull();
382
+ });
383
+
384
+ it('should allow removing overlay while main layout is active', async () => {
385
+ // Render main layout
386
+ await renderer.renderLayout(mainLayoutXLF, 100);
387
+
388
+ // Render overlay
389
+ await renderer.renderOverlay(overlayXLF, 200, 10);
390
+
391
+ // Stop overlay
392
+ renderer.stopOverlay(200);
393
+
394
+ // Main layout should still be active
395
+ expect(renderer.currentLayoutId).toBe(100);
396
+ expect(renderer.activeOverlays.has(200)).toBe(false);
397
+ });
398
+ });
399
+
400
+ describe('Overlay region and widget rendering', () => {
401
+ it('should start rendering overlay widgets', async () => {
402
+ const widgetStartListener = vi.fn();
403
+ renderer.on('overlayWidgetStart', widgetStartListener);
404
+
405
+ await renderer.renderOverlay(overlayXLF, 200, 10);
406
+
407
+ expect(widgetStartListener).toHaveBeenCalled();
408
+ });
409
+
410
+ it('should create widget elements for overlay regions', async () => {
411
+ await renderer.renderOverlay(overlayXLF, 200, 10);
412
+
413
+ const overlayState = renderer.activeOverlays.get(200);
414
+ const region = overlayState.regions.get('overlay-1');
415
+
416
+ expect(region.widgetElements.size).toBeGreaterThan(0);
417
+ });
418
+
419
+ it('should emit overlayWidgetEnd when widget stops', async () => {
420
+ vi.useFakeTimers();
421
+
422
+ const widgetEndListener = vi.fn();
423
+ renderer.on('overlayWidgetEnd', widgetEndListener);
424
+
425
+ // Need 2+ widgets for cycling (single widget has no timer/cycling)
426
+ const multiWidgetOverlayXLF = `<?xml version="1.0"?>
427
+ <layout width="1920" height="1080" bgcolor="#00000080">
428
+ <region id="overlay-1" width="400" height="200" top="50" left="50" zindex="10">
429
+ <media id="1" type="text" duration="10">
430
+ <raw><![CDATA[<p>Widget 1</p>]]></raw>
431
+ </media>
432
+ <media id="2" type="text" duration="10">
433
+ <raw><![CDATA[<p>Widget 2</p>]]></raw>
434
+ </media>
435
+ </region>
436
+ </layout>`;
437
+
438
+ await renderer.renderOverlay(multiWidgetOverlayXLF, 200, 10);
439
+
440
+ // Fast forward past first widget duration (10s) to trigger cycling
441
+ vi.advanceTimersByTime(11000);
442
+
443
+ // Widget should have cycled, emitting end event
444
+ expect(widgetEndListener).toHaveBeenCalled();
445
+
446
+ vi.useRealTimers();
447
+ });
448
+ });
449
+
450
+ describe('cleanup()', () => {
451
+ it('should stop all overlays on cleanup', async () => {
452
+ await renderer.renderOverlay(overlayXLF, 201, 10);
453
+ await renderer.renderOverlay(overlayXLF, 202, 20);
454
+
455
+ renderer.cleanup();
456
+
457
+ expect(renderer.activeOverlays.size).toBe(0);
458
+ });
459
+
460
+ it('should remove overlay container on cleanup', async () => {
461
+ await renderer.renderOverlay(overlayXLF, 200, 10);
462
+
463
+ renderer.cleanup();
464
+
465
+ expect(container.innerHTML).toBe('');
466
+ });
467
+ });
468
+
469
+ describe('Memory management', () => {
470
+ it('should revoke blob URLs when stopping overlay', async () => {
471
+ const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL');
472
+
473
+ await renderer.renderOverlay(overlayXLF, 200, 10);
474
+
475
+ // Manually track blob URLs for the overlay layout ID, since trackBlobUrl()
476
+ // is scoped to currentLayoutId (main layout) and doesn't track overlay blobs
477
+ renderer.layoutBlobUrls.set(200, new Set(['blob:test/overlay-1', 'blob:test/overlay-2']));
478
+
479
+ renderer.stopOverlay(200);
480
+
481
+ // stopOverlay calls revokeBlobUrlsForLayout(layoutId) which revokes tracked URLs
482
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:test/overlay-1');
483
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:test/overlay-2');
484
+ });
485
+
486
+ it('should track blob URLs per overlay layout', async () => {
487
+ await renderer.renderOverlay(overlayXLF, 200, 10);
488
+
489
+ // Blob URLs should be tracked
490
+ expect(renderer.layoutBlobUrls.has(200) || renderer.layoutBlobUrls.size === 0).toBe(true);
491
+ });
492
+ });
493
+ });