@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.
- package/docs/PERFORMANCE_OPTIMIZATIONS.md +451 -0
- package/docs/README.md +98 -0
- package/docs/RENDERER_COMPARISON.md +483 -0
- package/docs/TRANSITIONS.md +180 -0
- package/package.json +40 -0
- package/src/index.js +4 -0
- package/src/layout-pool.js +245 -0
- package/src/layout-pool.test.js +373 -0
- package/src/layout.js +1073 -0
- package/src/renderer-lite.js +2637 -0
- package/src/renderer-lite.overlays.test.js +493 -0
- package/src/renderer-lite.test.js +901 -0
- package/vitest.config.js +8 -0
|
@@ -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
|
+
});
|