@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,901 @@
1
+ /**
2
+ * RendererLite Test Suite
3
+ *
4
+ * Comprehensive tests for XLF rendering, element reuse, transitions,
5
+ * and memory management.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ import { RendererLite } from './renderer-lite.js';
10
+
11
+ describe('RendererLite', () => {
12
+ let container;
13
+ let renderer;
14
+ let mockGetMediaUrl;
15
+ let mockGetWidgetHtml;
16
+
17
+ beforeEach(() => {
18
+ // Create test container
19
+ container = document.createElement('div');
20
+ container.id = 'test-container';
21
+ document.body.appendChild(container);
22
+
23
+ // Mock URL.createObjectURL and URL.revokeObjectURL (not available in jsdom)
24
+ if (!global.URL.createObjectURL) {
25
+ global.URL.createObjectURL = vi.fn((blob) => `blob:test-${Math.random()}`);
26
+ }
27
+ if (!global.URL.revokeObjectURL) {
28
+ global.URL.revokeObjectURL = vi.fn();
29
+ }
30
+
31
+ // Mock callbacks
32
+ mockGetMediaUrl = vi.fn((fileId) => Promise.resolve(`blob://test-${fileId}`));
33
+ mockGetWidgetHtml = vi.fn((widget) => Promise.resolve(`<html>Widget ${widget.id}</html>`));
34
+
35
+ // Create renderer instance
36
+ renderer = new RendererLite(
37
+ { cmsUrl: 'https://test.com', hardwareKey: 'test-key' },
38
+ container,
39
+ {
40
+ getMediaUrl: mockGetMediaUrl,
41
+ getWidgetHtml: mockGetWidgetHtml
42
+ }
43
+ );
44
+ });
45
+
46
+ afterEach(() => {
47
+ // Cleanup
48
+ renderer.cleanup();
49
+ container.remove();
50
+ });
51
+
52
+ describe('XLF Parsing', () => {
53
+ it('should parse valid XLF with layout attributes', () => {
54
+ const xlf = `
55
+ <layout width="1920" height="1080" duration="60" bgcolor="#000000">
56
+ <region id="r1" width="1920" height="1080" top="0" left="0" zindex="0">
57
+ <media id="m1" type="image" duration="10" fileId="1">
58
+ <options><uri>test.png</uri></options>
59
+ </media>
60
+ </region>
61
+ </layout>
62
+ `;
63
+
64
+ const layout = renderer.parseXlf(xlf);
65
+
66
+ expect(layout.width).toBe(1920);
67
+ expect(layout.height).toBe(1080);
68
+ expect(layout.duration).toBe(60);
69
+ expect(layout.bgcolor).toBe('#000000');
70
+ expect(layout.regions).toHaveLength(1);
71
+ });
72
+
73
+ it('should use defaults when attributes missing', () => {
74
+ const xlf = `<layout><region id="r1"></region></layout>`;
75
+ const layout = renderer.parseXlf(xlf);
76
+
77
+ expect(layout.width).toBe(1920);
78
+ expect(layout.height).toBe(1080);
79
+ expect(layout.duration).toBeGreaterThanOrEqual(0); // Calculated or default
80
+ expect(layout.bgcolor).toBe('#000000');
81
+ });
82
+
83
+ it('should parse multiple regions', () => {
84
+ const xlf = `
85
+ <layout>
86
+ <region id="r1" width="960" height="1080" top="0" left="0"></region>
87
+ <region id="r2" width="960" height="1080" top="0" left="960"></region>
88
+ </layout>
89
+ `;
90
+
91
+ const layout = renderer.parseXlf(xlf);
92
+ expect(layout.regions).toHaveLength(2);
93
+ expect(layout.regions[0].id).toBe('r1');
94
+ expect(layout.regions[1].id).toBe('r2');
95
+ });
96
+
97
+ it('should parse widget with all attributes', () => {
98
+ const xlf = `
99
+ <layout>
100
+ <region id="r1">
101
+ <media id="m1" type="video" duration="30" useDuration="0" fileId="5">
102
+ <options>
103
+ <uri>test.mp4</uri>
104
+ <loop>1</loop>
105
+ <mute>0</mute>
106
+ </options>
107
+ <raw>Some content</raw>
108
+ </media>
109
+ </region>
110
+ </layout>
111
+ `;
112
+
113
+ const layout = renderer.parseXlf(xlf);
114
+ const widget = layout.regions[0].widgets[0];
115
+
116
+ expect(widget.type).toBe('video');
117
+ expect(widget.duration).toBe(30);
118
+ expect(widget.useDuration).toBe(0);
119
+ expect(widget.id).toBe('m1');
120
+ expect(widget.fileId).toBe('5');
121
+ expect(widget.options.uri).toBe('test.mp4');
122
+ expect(widget.options.loop).toBe('1');
123
+ expect(widget.raw).toBe('Some content');
124
+ });
125
+
126
+ it('should parse transitions', () => {
127
+ const xlf = `
128
+ <layout>
129
+ <region id="r1">
130
+ <media id="m1" type="image" duration="10">
131
+ <options>
132
+ <transIn>fadeIn</transIn>
133
+ <transInDuration>2000</transInDuration>
134
+ <transOut>flyOut</transOut>
135
+ <transOutDuration>1500</transOutDuration>
136
+ <transOutDirection>S</transOutDirection>
137
+ </options>
138
+ </media>
139
+ </region>
140
+ </layout>
141
+ `;
142
+
143
+ const layout = renderer.parseXlf(xlf);
144
+ const widget = layout.regions[0].widgets[0];
145
+
146
+ expect(widget.transitions.in).toEqual({
147
+ type: 'fadeIn',
148
+ duration: 2000,
149
+ direction: 'N'
150
+ });
151
+
152
+ expect(widget.transitions.out).toEqual({
153
+ type: 'flyOut',
154
+ duration: 1500,
155
+ direction: 'S'
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('Region Creation', () => {
161
+ it('should create region element with correct positioning', async () => {
162
+ const regionConfig = {
163
+ id: 'r1',
164
+ width: 960,
165
+ height: 540,
166
+ top: 100,
167
+ left: 200,
168
+ zindex: 5,
169
+ widgets: []
170
+ };
171
+
172
+ await renderer.createRegion(regionConfig);
173
+
174
+ const regionEl = container.querySelector('#region_r1');
175
+ expect(regionEl).toBeTruthy();
176
+ expect(regionEl.style.position).toBe('absolute');
177
+ expect(regionEl.style.width).toBe('960px');
178
+ expect(regionEl.style.height).toBe('540px');
179
+ expect(regionEl.style.top).toBe('100px');
180
+ expect(regionEl.style.left).toBe('200px');
181
+ expect(regionEl.style.zIndex).toBe('5');
182
+ });
183
+
184
+ it('should store region state in Map', async () => {
185
+ const regionConfig = {
186
+ id: 'r1',
187
+ width: 1920,
188
+ height: 1080,
189
+ top: 0,
190
+ left: 0,
191
+ zindex: 0,
192
+ widgets: []
193
+ };
194
+
195
+ await renderer.createRegion(regionConfig);
196
+
197
+ const region = renderer.regions.get('r1');
198
+ expect(region).toBeTruthy();
199
+ expect(region.config).toEqual(regionConfig);
200
+ expect(region.currentIndex).toBe(0);
201
+ expect(region.widgetElements).toBeInstanceOf(Map);
202
+ });
203
+ });
204
+
205
+ describe('Widget Element Creation', () => {
206
+ it('should create image widget element', async () => {
207
+ const widget = {
208
+ type: 'image',
209
+ id: 'm1',
210
+ fileId: '1',
211
+ options: { uri: 'test.png' },
212
+ duration: 10,
213
+ transitions: { in: null, out: null }
214
+ };
215
+
216
+ const region = { width: 1920, height: 1080 };
217
+ const element = await renderer.renderImage(widget, region);
218
+
219
+ expect(element.tagName).toBe('IMG');
220
+ expect(element.className).toBe('renderer-lite-widget');
221
+ expect(element.style.width).toBe('100%');
222
+ expect(element.style.height).toBe('100%');
223
+ expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
224
+ });
225
+
226
+ it('should create video widget element', async () => {
227
+ const widget = {
228
+ type: 'video',
229
+ id: 'm2',
230
+ fileId: '5',
231
+ options: { uri: '5.mp4', loop: '1', mute: '1' },
232
+ duration: 30,
233
+ transitions: { in: null, out: null }
234
+ };
235
+
236
+ const region = { width: 1920, height: 1080 };
237
+ const element = await renderer.renderVideo(widget, region);
238
+
239
+ expect(element.tagName).toBe('VIDEO');
240
+ expect(element.autoplay).toBe(true);
241
+ expect(element.muted).toBe(true);
242
+ // loop is intentionally false - handled manually via 'ended' event to avoid black frames
243
+ expect(element.loop).toBe(false);
244
+ expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
245
+ });
246
+
247
+ it('should create text widget with iframe (blob fallback)', async () => {
248
+ const widget = {
249
+ type: 'text',
250
+ id: 'm3',
251
+ layoutId: 1,
252
+ regionId: 'r1',
253
+ options: {},
254
+ raw: '<h1>Test</h1>',
255
+ duration: 15,
256
+ transitions: { in: null, out: null }
257
+ };
258
+
259
+ const region = { width: 1920, height: 1080 };
260
+ const element = await renderer.renderTextWidget(widget, region);
261
+
262
+ expect(element.tagName).toBe('IFRAME');
263
+ expect(element.src).toContain('blob:');
264
+ expect(mockGetWidgetHtml).toHaveBeenCalledWith(widget);
265
+ });
266
+
267
+ it('should use cache URL when getWidgetHtml returns { url }', async () => {
268
+ // Override mock to return { url } object (cache path)
269
+ mockGetWidgetHtml.mockResolvedValueOnce({ url: '/player/pwa/cache/widget/1/r1/m4' });
270
+
271
+ const widget = {
272
+ type: 'text',
273
+ id: 'm4',
274
+ layoutId: 1,
275
+ regionId: 'r1',
276
+ options: {},
277
+ raw: '<h1>Test</h1>',
278
+ duration: 15,
279
+ transitions: { in: null, out: null }
280
+ };
281
+
282
+ const region = { width: 1920, height: 1080 };
283
+ const element = await renderer.renderTextWidget(widget, region);
284
+
285
+ expect(element.tagName).toBe('IFRAME');
286
+ // Should use cache URL directly, NOT blob URL
287
+ expect(element.src).toContain('/player/pwa/cache/widget/1/r1/m4');
288
+ expect(element.src).not.toContain('blob:');
289
+ expect(mockGetWidgetHtml).toHaveBeenCalledWith(widget);
290
+ });
291
+
292
+ it('should use cache URL for generic widget when getWidgetHtml returns { url }', async () => {
293
+ mockGetWidgetHtml.mockResolvedValueOnce({ url: '/player/pwa/cache/widget/1/r1/m5' });
294
+
295
+ const widget = {
296
+ type: 'clock',
297
+ id: 'm5',
298
+ layoutId: 1,
299
+ regionId: 'r1',
300
+ options: {},
301
+ raw: '<div>Clock</div>',
302
+ duration: 10,
303
+ transitions: { in: null, out: null }
304
+ };
305
+
306
+ const region = { width: 1920, height: 1080 };
307
+ const element = await renderer.renderGenericWidget(widget, region);
308
+
309
+ expect(element.tagName).toBe('IFRAME');
310
+ expect(element.src).toContain('/player/pwa/cache/widget/1/r1/m5');
311
+ expect(element.src).not.toContain('blob:');
312
+ });
313
+ });
314
+
315
+ describe('Element Reuse Pattern', () => {
316
+ it('should pre-create all widget elements on layout load', async () => {
317
+ const xlf = `
318
+ <layout>
319
+ <region id="r1">
320
+ <media id="m1" type="image" duration="10" fileId="1">
321
+ <options><uri>1.png</uri></options>
322
+ </media>
323
+ <media id="m2" type="image" duration="10" fileId="2">
324
+ <options><uri>2.png</uri></options>
325
+ </media>
326
+ </region>
327
+ </layout>
328
+ `;
329
+
330
+ await renderer.renderLayout(xlf, 1);
331
+
332
+ const region = renderer.regions.get('r1');
333
+ expect(region.widgetElements.size).toBe(2);
334
+ expect(region.widgetElements.has('m1')).toBe(true);
335
+ expect(region.widgetElements.has('m2')).toBe(true);
336
+ });
337
+
338
+ it('should reuse elements on widget cycling', async () => {
339
+ const xlf = `
340
+ <layout>
341
+ <region id="r1">
342
+ <media id="m1" type="image" duration="1" fileId="1">
343
+ <options><uri>1.png</uri></options>
344
+ </media>
345
+ </region>
346
+ </layout>
347
+ `;
348
+
349
+ await renderer.renderLayout(xlf, 1);
350
+
351
+ const region = renderer.regions.get('r1');
352
+ const firstElement = region.widgetElements.get('m1');
353
+
354
+ // Render widget again
355
+ await renderer.renderWidget('r1', 0);
356
+
357
+ const secondElement = region.widgetElements.get('m1');
358
+
359
+ // Should be SAME element reference (reused)
360
+ expect(secondElement).toBe(firstElement);
361
+ });
362
+
363
+ it('should reuse elements on layout replay', async () => {
364
+ const xlf = `
365
+ <layout>
366
+ <region id="r1">
367
+ <media id="m1" type="video" duration="5" fileId="5">
368
+ <options><uri>5.mp4</uri></options>
369
+ </media>
370
+ </region>
371
+ </layout>
372
+ `;
373
+
374
+ // First render
375
+ await renderer.renderLayout(xlf, 1);
376
+ const region1 = renderer.regions.get('r1');
377
+ const element1 = region1.widgetElements.get('m1');
378
+
379
+ // Replay same layout (simulating layoutEnd → collect → renderLayout)
380
+ renderer.stopCurrentLayout = vi.fn(); // Mock to verify it's NOT called
381
+ await renderer.renderLayout(xlf, 1);
382
+
383
+ const region2 = renderer.regions.get('r1');
384
+ const element2 = region2.widgetElements.get('m1');
385
+
386
+ // stopCurrentLayout should NOT be called (elements reused)
387
+ expect(renderer.stopCurrentLayout).not.toHaveBeenCalled();
388
+
389
+ // Elements should be reused
390
+ expect(element2).toBe(element1);
391
+ });
392
+
393
+ it('should NOT reuse elements on layout switch', async () => {
394
+ const xlf1 = `
395
+ <layout>
396
+ <region id="r1">
397
+ <media id="m1" type="image" duration="10" fileId="1">
398
+ <options><uri>1.png</uri></options>
399
+ </media>
400
+ </region>
401
+ </layout>
402
+ `;
403
+
404
+ const xlf2 = `
405
+ <layout>
406
+ <region id="r1">
407
+ <media id="m2" type="image" duration="10" fileId="2">
408
+ <options><uri>2.png</uri></options>
409
+ </media>
410
+ </region>
411
+ </layout>
412
+ `;
413
+
414
+ // Render layout 1
415
+ await renderer.renderLayout(xlf1, 1);
416
+ const region1 = renderer.regions.get('r1');
417
+ const element1 = region1?.widgetElements.get('m1');
418
+
419
+ // Switch to layout 2
420
+ await renderer.renderLayout(xlf2, 2);
421
+ const region2 = renderer.regions.get('r1');
422
+ const element2 = region2?.widgetElements.get('m2');
423
+
424
+ // Elements should be different (new layout, new elements)
425
+ expect(element1).toBeTruthy();
426
+ expect(element2).toBeTruthy();
427
+ expect(element1).not.toBe(element2);
428
+
429
+ // Old region should be cleared
430
+ expect(region1).not.toBe(region2);
431
+ });
432
+ });
433
+
434
+ describe('Video Duration Detection', () => {
435
+ // Skip: jsdom doesn't support real video element properties
436
+ it.skip('should detect video duration from metadata', async () => {
437
+ const xlf = `
438
+ <layout>
439
+ <region id="r1">
440
+ <media id="m1" type="video" duration="0" useDuration="0" fileId="5">
441
+ <options><uri>5.mp4</uri></options>
442
+ </media>
443
+ </region>
444
+ </layout>
445
+ `;
446
+
447
+ await renderer.renderLayout(xlf, 1);
448
+
449
+ // Mock video element with duration
450
+ const region = renderer.regions.get('r1');
451
+ const videoElement = region.widgetElements.get('m1');
452
+ const video = videoElement.querySelector('video');
453
+
454
+ // Simulate loadedmetadata event
455
+ Object.defineProperty(video, 'duration', { value: 45.5, writable: false });
456
+ video.dispatchEvent(new Event('loadedmetadata'));
457
+
458
+ // Wait for async handler
459
+ await new Promise(resolve => setTimeout(resolve, 10));
460
+
461
+ // Widget duration should be updated
462
+ const widget = region.widgets[0];
463
+ expect(widget.duration).toBe(45); // Floor of 45.5
464
+ });
465
+
466
+ // Skip: jsdom doesn't support real video element properties
467
+ it.skip('should update layout duration when video metadata loads', async () => {
468
+ const xlf = `
469
+ <layout>
470
+ <region id="r1">
471
+ <media id="m1" type="video" duration="0" useDuration="0" fileId="5">
472
+ <options><uri>5.mp4</uri></options>
473
+ </media>
474
+ </region>
475
+ </layout>
476
+ `;
477
+
478
+ await renderer.renderLayout(xlf, 1);
479
+
480
+ const region = renderer.regions.get('r1');
481
+ const videoElement = region.widgetElements.get('m1');
482
+ const video = videoElement.querySelector('video');
483
+
484
+ // Simulate video with 45s duration
485
+ Object.defineProperty(video, 'duration', { value: 45, writable: false });
486
+ video.dispatchEvent(new Event('loadedmetadata'));
487
+
488
+ await new Promise(resolve => setTimeout(resolve, 10));
489
+
490
+ // Layout duration should be updated
491
+ expect(renderer.currentLayout.duration).toBe(45);
492
+ });
493
+
494
+ // Skip: jsdom doesn't support real video element properties
495
+ it.skip('should NOT update duration when useDuration=1', async () => {
496
+ const xlf = `
497
+ <layout>
498
+ <region id="r1">
499
+ <media id="m1" type="video" duration="30" useDuration="1" fileId="5">
500
+ <options><uri>5.mp4</uri></options>
501
+ </media>
502
+ </region>
503
+ </layout>
504
+ `;
505
+
506
+ await renderer.renderLayout(xlf, 1);
507
+
508
+ const region = renderer.regions.get('r1');
509
+ const videoElement = region.widgetElements.get('m1');
510
+ const video = videoElement.querySelector('video');
511
+
512
+ // Simulate video with 45s duration
513
+ Object.defineProperty(video, 'duration', { value: 45, writable: false });
514
+ video.dispatchEvent(new Event('loadedmetadata'));
515
+
516
+ await new Promise(resolve => setTimeout(resolve, 10));
517
+
518
+ // Widget duration should stay 30 (useDuration=1 overrides)
519
+ const widget = region.widgets[0];
520
+ expect(widget.duration).toBe(30);
521
+ });
522
+ });
523
+
524
+ describe('Media Element Restart', () => {
525
+ // Skip: jsdom video elements don't support currentTime properly
526
+ it.skip('should restart video on updateMediaElement()', async () => {
527
+ const widget = {
528
+ type: 'video',
529
+ id: 'm1',
530
+ fileId: '5',
531
+ options: { loop: '0', mute: '1' },
532
+ duration: 30
533
+ };
534
+
535
+ const region = { width: 1920, height: 1080 };
536
+ const element = await renderer.renderVideo(widget, region);
537
+ const video = element.querySelector('video');
538
+
539
+ // Mock video methods
540
+ video.currentTime = 25.5;
541
+ video.play = vi.fn(() => Promise.resolve());
542
+
543
+ // Call updateMediaElement
544
+ renderer.updateMediaElement(element, widget);
545
+
546
+ // Should restart from beginning
547
+ expect(video.currentTime).toBe(0);
548
+ expect(video.play).toHaveBeenCalled();
549
+ });
550
+
551
+ // Skip: jsdom video elements don't support currentTime properly
552
+ it.skip('should restart looping videos too', async () => {
553
+ const widget = {
554
+ type: 'video',
555
+ id: 'm1',
556
+ fileId: '5',
557
+ options: { loop: '1', mute: '1' }, // Looping video
558
+ duration: 30
559
+ };
560
+
561
+ const region = { width: 1920, height: 1080 };
562
+ const element = await renderer.renderVideo(widget, region);
563
+ const video = element.querySelector('video');
564
+
565
+ video.currentTime = 10;
566
+ video.play = vi.fn(() => Promise.resolve());
567
+
568
+ renderer.updateMediaElement(element, widget);
569
+
570
+ // Should STILL restart (even when looping)
571
+ expect(video.currentTime).toBe(0);
572
+ expect(video.play).toHaveBeenCalled();
573
+ });
574
+ });
575
+
576
+ describe('Layout Lifecycle', () => {
577
+ it('should emit layoutStart event', async () => {
578
+ const xlf = `<layout><region id="r1"></region></layout>`;
579
+ const layoutStartHandler = vi.fn();
580
+
581
+ renderer.on('layoutStart', layoutStartHandler);
582
+ await renderer.renderLayout(xlf, 1);
583
+
584
+ expect(layoutStartHandler).toHaveBeenCalledWith(1, expect.any(Object));
585
+ });
586
+
587
+ it('should emit layoutEnd event after duration expires', async () => {
588
+ vi.useFakeTimers();
589
+
590
+ const xlf = `
591
+ <layout duration="2">
592
+ <region id="r1">
593
+ <media id="m1" type="image" duration="10" fileId="1">
594
+ <options><uri>1.png</uri></options>
595
+ </media>
596
+ </region>
597
+ </layout>
598
+ `;
599
+
600
+ const layoutEndHandler = vi.fn();
601
+ renderer.on('layoutEnd', layoutEndHandler);
602
+
603
+ // Don't await directly — renderLayout waits for widget readiness (image load
604
+ // or 10s timeout). With fake timers we must advance time to unblock it.
605
+ const renderPromise = renderer.renderLayout(xlf, 1);
606
+
607
+ // Advance past the 10s image-ready timeout, flushing microtasks
608
+ await vi.advanceTimersByTimeAsync(10000);
609
+ await renderPromise;
610
+
611
+ // Now advance 2s to trigger the layout duration timer
612
+ vi.advanceTimersByTime(2000);
613
+
614
+ expect(layoutEndHandler).toHaveBeenCalledWith(1);
615
+
616
+ vi.useRealTimers();
617
+ });
618
+
619
+ it('should emit widgetStart event', async () => {
620
+ const xlf = `
621
+ <layout>
622
+ <region id="r1">
623
+ <media id="m1" type="image" duration="10" fileId="1">
624
+ <options><uri>1.png</uri></options>
625
+ </media>
626
+ </region>
627
+ </layout>
628
+ `;
629
+
630
+ const widgetStartHandler = vi.fn();
631
+ renderer.on('widgetStart', widgetStartHandler);
632
+
633
+ await renderer.renderLayout(xlf, 1);
634
+
635
+ expect(widgetStartHandler).toHaveBeenCalledWith(
636
+ expect.objectContaining({
637
+ widgetId: 'm1',
638
+ regionId: 'r1',
639
+ type: 'image'
640
+ })
641
+ );
642
+ });
643
+ });
644
+
645
+ describe('Transitions', () => {
646
+ // Skip: jsdom doesn't support Web Animations API
647
+ it.skip('should apply fade in transition', async () => {
648
+ const element = document.createElement('div');
649
+ element.style.opacity = '0';
650
+
651
+ const transition = {
652
+ type: 'fadeIn',
653
+ duration: 1000,
654
+ direction: 'N'
655
+ };
656
+
657
+ // Import Transitions utility
658
+ const { Transitions } = await import('./renderer-lite.js');
659
+ const animation = Transitions.apply(element, transition, true, 1920, 1080);
660
+
661
+ expect(animation).toBeTruthy();
662
+ expect(animation.effect.getKeyframes()).toEqual(
663
+ expect.arrayContaining([
664
+ expect.objectContaining({ opacity: '0' }),
665
+ expect.objectContaining({ opacity: '1' })
666
+ ])
667
+ );
668
+ });
669
+
670
+ // Skip: jsdom doesn't support Web Animations API
671
+ it.skip('should apply fly out transition with direction', async () => {
672
+ const element = document.createElement('div');
673
+
674
+ const transition = {
675
+ type: 'flyOut',
676
+ duration: 1500,
677
+ direction: 'S' // South
678
+ };
679
+
680
+ const { Transitions } = await import('./renderer-lite.js');
681
+ const animation = Transitions.apply(element, transition, false, 1920, 1080);
682
+
683
+ expect(animation).toBeTruthy();
684
+ const keyframes = animation.effect.getKeyframes();
685
+
686
+ // Should translate to south (positive Y)
687
+ expect(keyframes[1].transform).toContain('1080px'); // Height offset
688
+ });
689
+ });
690
+
691
+ describe('Memory Management', () => {
692
+ it('should clear mediaUrlCache on layout switch', async () => {
693
+ const xlf1 = `<layout><region id="r1"></region></layout>`;
694
+ const xlf2 = `<layout><region id="r2"></region></layout>`;
695
+
696
+ await renderer.renderLayout(xlf1, 1);
697
+ renderer.mediaUrlCache.set(1, 'blob://test-1');
698
+
699
+ // Switch to different layout
700
+ await renderer.renderLayout(xlf2, 2);
701
+
702
+ // Cache should be cleared
703
+ expect(renderer.mediaUrlCache.size).toBe(0);
704
+ });
705
+
706
+ it('should clear regions on stopCurrentLayout', async () => {
707
+ const xlf = `
708
+ <layout>
709
+ <region id="r1">
710
+ <media id="m1" type="image" duration="10" fileId="1">
711
+ <options><uri>1.png</uri></options>
712
+ </media>
713
+ </region>
714
+ </layout>
715
+ `;
716
+
717
+ await renderer.renderLayout(xlf, 1);
718
+ expect(renderer.regions.size).toBe(1);
719
+
720
+ renderer.stopCurrentLayout();
721
+
722
+ expect(renderer.regions.size).toBe(0);
723
+ expect(renderer.currentLayout).toBeNull();
724
+ expect(renderer.currentLayoutId).toBeNull();
725
+ });
726
+
727
+ it('should clear timers on cleanup', async () => {
728
+ vi.useFakeTimers();
729
+
730
+ const xlf = `
731
+ <layout duration="60">
732
+ <region id="r1">
733
+ <media id="m1" type="image" duration="10" fileId="1">
734
+ <options><uri>1.png</uri></options>
735
+ </media>
736
+ </region>
737
+ </layout>
738
+ `;
739
+
740
+ // renderLayout waits for widget readiness — advance past image timeout
741
+ const renderPromise = renderer.renderLayout(xlf, 1);
742
+ await vi.advanceTimersByTimeAsync(10000);
743
+ await renderPromise;
744
+
745
+ const layoutTimerId = renderer.layoutTimer;
746
+ expect(layoutTimerId).toBeTruthy();
747
+
748
+ renderer.stopCurrentLayout();
749
+
750
+ expect(renderer.layoutTimer).toBeNull();
751
+
752
+ vi.useRealTimers();
753
+ });
754
+ });
755
+
756
+ describe('Layout Replay Optimization', () => {
757
+ it('should detect same layout and reuse elements', async () => {
758
+ const xlf = `
759
+ <layout>
760
+ <region id="r1">
761
+ <media id="m1" type="image" duration="10" fileId="1">
762
+ <options><uri>1.png</uri></options>
763
+ </media>
764
+ </region>
765
+ </layout>
766
+ `;
767
+
768
+ // First render
769
+ await renderer.renderLayout(xlf, 1);
770
+ const region1 = renderer.regions.get('r1');
771
+ const element1 = region1.widgetElements.get('m1');
772
+
773
+ // Spy on stopCurrentLayout
774
+ const stopSpy = vi.spyOn(renderer, 'stopCurrentLayout');
775
+
776
+ // Replay same layout
777
+ await renderer.renderLayout(xlf, 1);
778
+
779
+ // stopCurrentLayout should NOT be called
780
+ expect(stopSpy).not.toHaveBeenCalled();
781
+
782
+ // Should reuse same elements
783
+ const region2 = renderer.regions.get('r1');
784
+ const element2 = region2.widgetElements.get('m1');
785
+ expect(element2).toBe(element1);
786
+ });
787
+ });
788
+
789
+ describe('Parallel Media Pre-fetch', () => {
790
+ it('should pre-fetch all media URLs in parallel', async () => {
791
+ const xlf = `
792
+ <layout>
793
+ <region id="r1">
794
+ <media id="m1" type="image" duration="5" fileId="1">
795
+ <options><uri>1.png</uri></options>
796
+ </media>
797
+ <media id="m2" type="video" duration="10" fileId="5">
798
+ <options><uri>5.mp4</uri></options>
799
+ </media>
800
+ <media id="m3" type="image" duration="5" fileId="7">
801
+ <options><uri>7.png</uri></options>
802
+ </media>
803
+ </region>
804
+ </layout>
805
+ `;
806
+
807
+ await renderer.renderLayout(xlf, 1);
808
+
809
+ // All media URLs should have been fetched
810
+ expect(mockGetMediaUrl).toHaveBeenCalledTimes(3);
811
+ expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
812
+ expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
813
+ expect(mockGetMediaUrl).toHaveBeenCalledWith(7);
814
+
815
+ // All should be in cache
816
+ expect(renderer.mediaUrlCache.size).toBe(3);
817
+ });
818
+ });
819
+
820
+ describe('Error Handling', () => {
821
+ it('should emit error event on widget render failure', async () => {
822
+ const xlf = `
823
+ <layout>
824
+ <region id="r1">
825
+ <media id="m1" type="invalid" duration="10">
826
+ <options></options>
827
+ </media>
828
+ </region>
829
+ </layout>
830
+ `;
831
+
832
+ const errorHandler = vi.fn();
833
+ renderer.on('error', errorHandler);
834
+
835
+ await renderer.renderLayout(xlf, 1);
836
+
837
+ // Should handle unknown widget type gracefully
838
+ // (renderGenericWidget fallback)
839
+ expect(errorHandler).not.toHaveBeenCalled();
840
+ });
841
+
842
+ it('should handle missing fileId gracefully', async () => {
843
+ const xlf = `
844
+ <layout>
845
+ <region id="r1">
846
+ <media id="m1" type="image" duration="10">
847
+ <options><uri>missing.png</uri></options>
848
+ </media>
849
+ </region>
850
+ </layout>
851
+ `;
852
+
853
+ // Should not throw
854
+ await expect(renderer.renderLayout(xlf, 1)).resolves.not.toThrow();
855
+ });
856
+ });
857
+
858
+ describe('Duration Calculation', () => {
859
+ it('should calculate layout duration from widgets when not specified', async () => {
860
+ const xlf = `
861
+ <layout>
862
+ <region id="r1">
863
+ <media id="m1" type="image" duration="10" fileId="1">
864
+ <options><uri>1.png</uri></options>
865
+ </media>
866
+ <media id="m2" type="image" duration="20" fileId="2">
867
+ <options><uri>2.png</uri></options>
868
+ </media>
869
+ </region>
870
+ </layout>
871
+ `;
872
+
873
+ await renderer.renderLayout(xlf, 1);
874
+
875
+ // Duration should be sum of widgets in region: 10 + 20 = 30
876
+ expect(renderer.currentLayout.duration).toBe(30);
877
+ });
878
+
879
+ it('should use max region duration for layout', async () => {
880
+ const xlf = `
881
+ <layout>
882
+ <region id="r1">
883
+ <media id="m1" type="image" duration="10" fileId="1">
884
+ <options><uri>1.png</uri></options>
885
+ </media>
886
+ </region>
887
+ <region id="r2">
888
+ <media id="m2" type="video" duration="45" fileId="5">
889
+ <options><uri>5.mp4</uri></options>
890
+ </media>
891
+ </region>
892
+ </layout>
893
+ `;
894
+
895
+ await renderer.renderLayout(xlf, 1);
896
+
897
+ // Duration should be max(10, 45) = 45
898
+ expect(renderer.currentLayout.duration).toBe(45);
899
+ });
900
+ });
901
+ });