@xiboplayer/renderer 0.6.2 → 0.6.4
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/README.md +240 -25
- package/docs/RENDERER_COMPARISON.md +1 -1
- package/package.json +4 -4
- package/src/layout.js +6 -0
- package/src/renderer-lite.ic.test.js +203 -0
- package/src/renderer-lite.js +213 -12
- package/src/renderer-lite.test.js +493 -33
- package/vitest.config.js +3 -1
package/README.md
CHANGED
|
@@ -1,19 +1,53 @@
|
|
|
1
1
|
# @xiboplayer/renderer
|
|
2
2
|
|
|
3
|
-
**XLF layout rendering engine for
|
|
3
|
+
**Fast, memory-efficient XLF layout rendering engine for digital signage.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Parses Xibo Layout Format (XLF) files and builds a live DOM with element reuse, instant transitions, and a 2-layout preload pool:
|
|
8
8
|
|
|
9
|
-
- **Rich media**
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
9
|
+
- **Rich media** -- video (MP4/HLS), images (scaleType, align/valign), audio (with visualization), PDF (PDF.js), text/ticker, web pages, clock, calendar, weather, and all CMS widget types
|
|
10
|
+
- **Instant layout transitions** -- 2-layout preload pool keeps the next layout DOM-ready for zero-gap swap
|
|
11
|
+
- **Element reuse** -- pre-creates all widget elements upfront; cycling reuses them instead of destroying/rebuilding
|
|
12
|
+
- **Transitions** -- fade and fly animations with 8 compass directions via Web Animations API
|
|
13
|
+
- **Canvas regions** -- simultaneous multi-widget rendering (stacked layers)
|
|
14
|
+
- **Drawer regions** -- hidden action-triggered areas for interactive controls
|
|
15
|
+
- **Overlays** -- multiple floating layouts with priority z-indexing
|
|
16
|
+
- **Interactive actions** -- touch/click and keyboard triggers for widget navigation, layout jumps, and command execution
|
|
17
|
+
- **Shell commands** -- native command execution via Electron IPC and Chromium HTTP endpoint
|
|
18
|
+
- **Sub-playlist cycling** -- round-robin or random selection from widget groups
|
|
19
|
+
- **Dynamic duration** -- video/audio metadata overrides for accurate timing
|
|
20
|
+
- **Scale-to-fit** -- responsive scaling for any screen size via ResizeObserver
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
XLF XML -> RendererLite
|
|
26
|
+
+- parseXlf() -> Layout object {width, height, regions: []}
|
|
27
|
+
+- renderLayout()
|
|
28
|
+
+- createRegion() for each region
|
|
29
|
+
| +- createWidgetElement() for each widget
|
|
30
|
+
| +- renderImage() [<img>]
|
|
31
|
+
| +- renderVideo() [<video>] (HLS/DASH)
|
|
32
|
+
| +- renderAudio() [<audio> + visual]
|
|
33
|
+
| +- renderTextWidget() [<iframe>] (GetResource)
|
|
34
|
+
| +- renderPdf() [<canvas>] (multi-page)
|
|
35
|
+
| +- renderWebpage() [<iframe>]
|
|
36
|
+
| +- renderVideoIn() [<video>] (webcam)
|
|
37
|
+
| +- renderGenericWidget() [<iframe>] (clock, etc.)
|
|
38
|
+
|
|
|
39
|
+
+- attachActionListeners() for touch/click/keyboard
|
|
40
|
+
+- startRegion() -> _startRegionCycle()
|
|
41
|
+
| +- renderWidget() / stopWidget() cycling
|
|
42
|
+
|
|
|
43
|
+
+- startLayoutTimerWhenReady()
|
|
44
|
+
+- Waits for all initial widgets to load
|
|
45
|
+
+- Starts layout duration timer
|
|
46
|
+
|
|
47
|
+
Layout Pool (2 entries max):
|
|
48
|
+
+- Hot entry (visible, currently playing)
|
|
49
|
+
+- Warm entry (preloaded, hidden, ready for instant swap)
|
|
50
|
+
```
|
|
17
51
|
|
|
18
52
|
## Installation
|
|
19
53
|
|
|
@@ -23,33 +57,214 @@ npm install @xiboplayer/renderer
|
|
|
23
57
|
|
|
24
58
|
## Usage
|
|
25
59
|
|
|
60
|
+
### Basic rendering
|
|
61
|
+
|
|
26
62
|
```javascript
|
|
27
63
|
import { RendererLite } from '@xiboplayer/renderer';
|
|
28
64
|
|
|
29
|
-
const renderer = new RendererLite(
|
|
30
|
-
|
|
65
|
+
const renderer = new RendererLite(
|
|
66
|
+
{ cmsUrl: 'https://cms.example.com', hardwareKey: 'DISPLAY-001' },
|
|
67
|
+
document.getElementById('player')
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
renderer.on('layoutStart', (layoutId, layout) => {
|
|
71
|
+
console.log(`Layout ${layoutId} started (${layout.duration}s)`);
|
|
31
72
|
});
|
|
32
73
|
|
|
33
|
-
|
|
34
|
-
|
|
74
|
+
renderer.on('layoutEnd', (layoutId) => {
|
|
75
|
+
console.log(`Layout ${layoutId} ended`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await renderer.renderLayout(xlfXmlContent, 42);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Preloading for instant transitions
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
// At 75% of current layout duration, renderer emits:
|
|
85
|
+
renderer.on('request-next-layout-preload', async () => {
|
|
86
|
+
const nextLayout = await getNextLayoutFromSchedule();
|
|
87
|
+
if (nextLayout && !renderer.hasPreloadedLayout(nextLayout.id)) {
|
|
88
|
+
await renderer.preloadLayout(nextLayout.xlf, nextLayout.id);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// When it's time to show next layout, swap is INSTANT if preloaded
|
|
93
|
+
await renderer.renderLayout(nextXlfXml, nextLayoutId);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Overlays
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
// Render an overlay on top of the main layout
|
|
100
|
+
await renderer.renderOverlay(alertXlfXml, 101, 10);
|
|
101
|
+
|
|
102
|
+
renderer.on('overlayEnd', (overlayId) => console.log('Overlay done'));
|
|
103
|
+
|
|
104
|
+
// Stop overlay
|
|
105
|
+
renderer.stopOverlay(101);
|
|
35
106
|
```
|
|
36
107
|
|
|
37
108
|
## Widget Types
|
|
38
109
|
|
|
39
|
-
|
|
|
40
|
-
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
110
|
+
| Type | Element | Source | Notes |
|
|
111
|
+
|------|---------|--------|-------|
|
|
112
|
+
| **image** | `<img>` | Media file | Object-fit: center/stretch/fit. objectPosition: top/middle/bottom, left/center/right |
|
|
113
|
+
| **video** | `<video>` | Media file | HLS via native (Safari) + hls.js fallback. Pause-on-last-frame. Duration detection |
|
|
114
|
+
| **audio** | `<audio>` + visual | Media file | Gradient visualization + music note icon. Volume control. Loop option |
|
|
115
|
+
| **videoin** | `<video>` | getUserMedia() | Webcam/mic capture with mirror mode |
|
|
116
|
+
| **text** | `<iframe>` | GetResource | CMS-rendered HTML. Parses NUMITEMS/DURATION comments |
|
|
117
|
+
| **ticker** | `<iframe>` | GetResource | Data feeds with dynamic cycling |
|
|
118
|
+
| **pdf** | `<canvas>` | Media file | PDF.js multi-page cycling. Page indicator. Time-per-page = duration / pages |
|
|
119
|
+
| **webpage** | `<iframe>` | Direct URL | modeId=1 (URL), modeId=0 (GetResource) |
|
|
120
|
+
| **clock** | `<iframe>` | GetResource | Digital/analogue variants |
|
|
121
|
+
| **calendar** | `<iframe>` | GetResource | Calendar widget HTML |
|
|
122
|
+
| **weather** | `<iframe>` | GetResource | Weather service HTML |
|
|
123
|
+
| **global** | Stacked iframes | GetResource | Canvas region auto-detect for multi-layer content |
|
|
124
|
+
| **all others** | `<iframe>` | GetResource | Generic CMS widget HTML |
|
|
125
|
+
|
|
126
|
+
## Layout Lifecycle
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
XLF XML
|
|
130
|
+
|
|
|
131
|
+
+- parseXlf() -> {width, height, regions: [{widgets: [...]}]}
|
|
132
|
+
|
|
|
133
|
+
+- calculateScale() -> fit layout to screen
|
|
134
|
+
|
|
|
135
|
+
+- createRegion() for each region
|
|
136
|
+
| +- Filter widgets (fromDt/toDt time-gating)
|
|
137
|
+
| +- Apply sub-playlist cycling
|
|
138
|
+
|
|
|
139
|
+
+- createWidgetElement() for each widget (pre-creation)
|
|
140
|
+
| +- Create DOM element, position absolute, hidden
|
|
141
|
+
| +- Track blob URLs for cleanup
|
|
142
|
+
|
|
|
143
|
+
+- startRegion() for each region
|
|
144
|
+
| +- Canvas: show ALL widgets at once, timer = max duration
|
|
145
|
+
| +- Normal: cycle widgets with transitions
|
|
146
|
+
|
|
|
147
|
+
+- startLayoutTimerWhenReady()
|
|
148
|
+
| +- Wait for video.playing / img.load (or 10s timeout)
|
|
149
|
+
| +- Start layout duration timer
|
|
150
|
+
|
|
|
151
|
+
+- At 75%: emit 'request-next-layout-preload'
|
|
152
|
+
|
|
|
153
|
+
+- Layout duration expires
|
|
154
|
+
+- emit('layoutEnd', layoutId)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Element reuse flow
|
|
158
|
+
|
|
159
|
+
1. All widget elements pre-created during `renderLayout()` -- hidden, opacity 0
|
|
160
|
+
2. Cycling: `renderWidget()` shows current, `stopWidget()` hides (same elements)
|
|
161
|
+
3. No DOM destruction/recreation -- smooth transitions, instant replay
|
|
162
|
+
4. When layout evicted from pool: blob URLs revoked, elements removed
|
|
163
|
+
|
|
164
|
+
### Ready-wait gating
|
|
165
|
+
|
|
166
|
+
Layout timer starts only when all initial widgets are loaded:
|
|
167
|
+
- Video: waits for `playing` event
|
|
168
|
+
- Image: waits for `load` event
|
|
169
|
+
- Text/embedded: ready immediately
|
|
170
|
+
- Timeout: 10s per widget (don't block on broken media)
|
|
171
|
+
|
|
172
|
+
## Transitions
|
|
173
|
+
|
|
174
|
+
Defined per-widget in XLF options:
|
|
175
|
+
|
|
176
|
+
**Fade:** `fade`, `fadein`, `fadeout` -- opacity animation
|
|
177
|
+
|
|
178
|
+
**Fly:** `fly`, `flyin`, `flyout` -- translate animation with compass direction:
|
|
179
|
+
- `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW`
|
|
180
|
+
|
|
181
|
+
```xml
|
|
182
|
+
<media duration="5">
|
|
183
|
+
<options>
|
|
184
|
+
<transIn>fly</transIn>
|
|
185
|
+
<transInDuration>1000</transInDuration>
|
|
186
|
+
<transInDirection>NE</transInDirection>
|
|
187
|
+
<transOut>fade</transOut>
|
|
188
|
+
<transOutDuration>500</transOutDuration>
|
|
189
|
+
</options>
|
|
190
|
+
</media>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Interactive Actions
|
|
194
|
+
|
|
195
|
+
Actions defined in XLF at layout, region, or widget level:
|
|
196
|
+
|
|
197
|
+
**Trigger types:** `touch` (click), `keyboard:KEY` (e.g., `keyboard:n`, `keyboard:Enter`)
|
|
198
|
+
|
|
199
|
+
**Action types:** `navWidget` (jump to widget), `nextWidget`, `previousWidget`, `shellCommand`
|
|
200
|
+
|
|
201
|
+
```javascript
|
|
202
|
+
renderer.on('action-trigger', (actionData) => {
|
|
203
|
+
console.log(`Action: ${actionData.actionType}`, actionData);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Programmatic navigation
|
|
207
|
+
renderer.navigateToWidget('target-widget-id');
|
|
208
|
+
renderer.nextWidget('region-id');
|
|
209
|
+
renderer.previousWidget('region-id');
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Events
|
|
213
|
+
|
|
214
|
+
| Event | Args | Description |
|
|
215
|
+
|-------|------|-------------|
|
|
216
|
+
| `layoutStart` | `(layoutId, layout)` | Layout DOM built and playback started |
|
|
217
|
+
| `layoutEnd` | `(layoutId)` | Layout duration expired |
|
|
218
|
+
| `layoutDurationUpdated` | `(layoutId, newDuration)` | Video metadata revealed actual duration |
|
|
219
|
+
| `widgetStart` | `({ widgetId, regionId, layoutId, type, duration, enableStat })` | Widget now visible |
|
|
220
|
+
| `widgetEnd` | `({ widgetId, regionId, layoutId, type, enableStat })` | Widget hidden/cycled |
|
|
221
|
+
| `widgetCommand` | `({ commandCode, commandString, widgetId, regionId, layoutId })` | Shell command triggered |
|
|
222
|
+
| `widgetAction` | `({ type, widgetId, layoutId, regionId, url })` | Widget webhook URL |
|
|
223
|
+
| `videoError` | `({ storedAs, fileId, errorCode, errorMessage })` | Video playback error |
|
|
224
|
+
| `overlayStart` | `(overlayId, layout)` | Overlay rendered |
|
|
225
|
+
| `overlayEnd` | `(overlayId)` | Overlay finished |
|
|
226
|
+
| `action-trigger` | `({ actionType, triggerType, targetId, commandCode })` | Touch/keyboard action fired |
|
|
227
|
+
| `request-next-layout-preload` | `()` | 75% through layout -- preload next |
|
|
228
|
+
| `error` | `({ type, error, layoutId, widgetId })` | Generic error |
|
|
229
|
+
|
|
230
|
+
## API Reference
|
|
231
|
+
|
|
232
|
+
### Constructor
|
|
233
|
+
|
|
234
|
+
```javascript
|
|
235
|
+
new RendererLite(config, container, options?)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
| Parameter | Type | Description |
|
|
239
|
+
|-----------|------|-------------|
|
|
240
|
+
| `config` | Object | `{ cmsUrl, hardwareKey }` |
|
|
241
|
+
| `container` | HTMLElement | DOM element to render into |
|
|
242
|
+
| `options.getWidgetHtml` | Function? | `(widget) => htmlString` -- fetch widget HTML from cache |
|
|
243
|
+
| `options.fileIdToSaveAs` | Map? | Map of fileId to storedAs filename |
|
|
244
|
+
|
|
245
|
+
### Methods
|
|
246
|
+
|
|
247
|
+
| Method | Returns | Description |
|
|
248
|
+
|--------|---------|-------------|
|
|
249
|
+
| `renderLayout(xlfXml, layoutId)` | Promise | Parse, build, and start layout. Instant swap if preloaded. |
|
|
250
|
+
| `preloadLayout(xlfXml, layoutId)` | Promise<bool> | Pre-build layout as hidden warm entry |
|
|
251
|
+
| `hasPreloadedLayout(layoutId)` | boolean | Check if layout is in preload pool |
|
|
252
|
+
| `renderOverlay(xlfXml, layoutId, priority)` | Promise | Render overlay on top |
|
|
253
|
+
| `stopCurrentLayout()` | void | Stop main layout |
|
|
254
|
+
| `stopOverlay(layoutId)` | void | Stop and remove overlay |
|
|
255
|
+
| `stopAllOverlays()` | void | Stop all active overlays |
|
|
256
|
+
| `navigateToWidget(widgetId)` | void | Jump to specific widget |
|
|
257
|
+
| `nextWidget(regionId?)` | void | Advance to next widget |
|
|
258
|
+
| `previousWidget(regionId?)` | void | Go back to previous widget |
|
|
259
|
+
| `pause()` | void | Pause media and widget cycling |
|
|
260
|
+
| `resume()` | void | Resume playback |
|
|
261
|
+
| `cleanup()` | void | Stop all, clear pool, revoke blob URLs |
|
|
48
262
|
|
|
49
263
|
## Dependencies
|
|
50
264
|
|
|
51
|
-
- `@xiboplayer/utils`
|
|
52
|
-
- `pdfjs-dist`
|
|
265
|
+
- `@xiboplayer/utils` -- logger, events
|
|
266
|
+
- `pdfjs-dist` -- PDF rendering (dynamic import)
|
|
267
|
+
- `hls.js` -- HLS streaming (dynamic import)
|
|
53
268
|
|
|
54
269
|
---
|
|
55
270
|
|
|
@@ -464,7 +464,7 @@ XLF → Parse → Pre-create Elements → Toggle Visibility → Transitions
|
|
|
464
464
|
|
|
465
465
|
**RendererLite successfully implements the Arexibo pattern** and adds significant performance improvements through parallelization. The implementation is production-ready with minor improvements needed for blob URL lifecycle management.
|
|
466
466
|
|
|
467
|
-
**Feature Parity**:
|
|
467
|
+
**Feature Parity**: 100% (all widget types, transitions, interactive control, shell commands)
|
|
468
468
|
**Performance**: Exceeds XLR and Arexibo benchmarks
|
|
469
469
|
**Memory**: Stable with Arexibo pattern correctly implemented
|
|
470
470
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "RendererLite - Fast, efficient XLF layout rendering engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"nanoevents": "^9.1.0",
|
|
15
15
|
"pdfjs-dist": "^4.10.38",
|
|
16
|
-
"@xiboplayer/cache": "0.6.
|
|
17
|
-
"@xiboplayer/
|
|
18
|
-
"@xiboplayer/
|
|
16
|
+
"@xiboplayer/cache": "0.6.4",
|
|
17
|
+
"@xiboplayer/utils": "0.6.4",
|
|
18
|
+
"@xiboplayer/schedule": "0.6.4"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"vitest": "^2.0.0",
|
package/src/layout.js
CHANGED
|
@@ -303,10 +303,16 @@ window.Transitions = {
|
|
|
303
303
|
const direction = transitionConfig.direction || 'N';
|
|
304
304
|
|
|
305
305
|
switch (type) {
|
|
306
|
+
case 'fade':
|
|
307
|
+
return isIn ? this.fadeIn(element, duration) : this.fadeOut(element, duration);
|
|
306
308
|
case 'fadein':
|
|
307
309
|
return isIn ? this.fadeIn(element, duration) : null;
|
|
308
310
|
case 'fadeout':
|
|
309
311
|
return isIn ? null : this.fadeOut(element, duration);
|
|
312
|
+
case 'fly':
|
|
313
|
+
return isIn
|
|
314
|
+
? this.flyIn(element, duration, direction, regionWidth, regionHeight)
|
|
315
|
+
: this.flyOut(element, duration, direction, regionWidth, regionHeight);
|
|
310
316
|
case 'flyin':
|
|
311
317
|
return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
|
|
312
318
|
case 'flyout':
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RendererLite Interactive Control (XIC) Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the XIC event handlers: interactiveTrigger, widgetExpire,
|
|
5
|
+
* widgetExtendDuration, widgetSetDuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
|
+
import { RendererLite } from './renderer-lite.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a minimal RendererLite instance with stubbed DOM and methods.
|
|
13
|
+
*/
|
|
14
|
+
function createRenderer() {
|
|
15
|
+
const container = document.createElement('div');
|
|
16
|
+
const renderer = new RendererLite(
|
|
17
|
+
{ cmsUrl: 'http://localhost', hardwareKey: 'test' },
|
|
18
|
+
container,
|
|
19
|
+
{ logLevel: 'silent' }
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Stub methods that touch DOM or async operations
|
|
23
|
+
renderer.renderWidget = vi.fn();
|
|
24
|
+
renderer.stopWidget = vi.fn();
|
|
25
|
+
renderer.checkLayoutComplete = vi.fn();
|
|
26
|
+
renderer._startRegionCycle = vi.fn();
|
|
27
|
+
renderer.navigateToWidget = vi.fn();
|
|
28
|
+
|
|
29
|
+
return renderer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Populate renderer with a fake region containing widgets.
|
|
34
|
+
*/
|
|
35
|
+
function addRegion(renderer, regionId, widgets) {
|
|
36
|
+
renderer.regions.set(regionId, {
|
|
37
|
+
element: document.createElement('div'),
|
|
38
|
+
config: { id: regionId },
|
|
39
|
+
widgets,
|
|
40
|
+
currentIndex: 0,
|
|
41
|
+
timer: null,
|
|
42
|
+
width: 100,
|
|
43
|
+
height: 100,
|
|
44
|
+
complete: false,
|
|
45
|
+
isDrawer: false,
|
|
46
|
+
widgetElements: new Map()
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('RendererLite XIC', () => {
|
|
51
|
+
let renderer;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
renderer = createRenderer();
|
|
55
|
+
addRegion(renderer, 'region-1', [
|
|
56
|
+
{ id: 'w1', type: 'text', duration: 10, options: {} },
|
|
57
|
+
{ id: 'w2', type: 'image', duration: 20, options: {} },
|
|
58
|
+
{ id: 'w3', type: 'video', duration: 30, options: {} }
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('_findRegionByWidgetId', () => {
|
|
63
|
+
it('should find a widget in main regions', () => {
|
|
64
|
+
const result = renderer._findRegionByWidgetId('w2');
|
|
65
|
+
expect(result).not.toBeNull();
|
|
66
|
+
expect(result.regionId).toBe('region-1');
|
|
67
|
+
expect(result.widgetIndex).toBe(1);
|
|
68
|
+
expect(result.widget.id).toBe('w2');
|
|
69
|
+
expect(result.regionMap).toBe(renderer.regions);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return null for unknown widget', () => {
|
|
73
|
+
const result = renderer._findRegionByWidgetId('w-unknown');
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should find a widget in overlay regions', () => {
|
|
78
|
+
const overlayRegions = new Map();
|
|
79
|
+
overlayRegions.set('overlay-r1', {
|
|
80
|
+
element: document.createElement('div'),
|
|
81
|
+
config: { id: 'overlay-r1' },
|
|
82
|
+
widgets: [{ id: 'ow1', type: 'text', duration: 5, options: {} }],
|
|
83
|
+
currentIndex: 0,
|
|
84
|
+
timer: null,
|
|
85
|
+
width: 50,
|
|
86
|
+
height: 50,
|
|
87
|
+
complete: false,
|
|
88
|
+
isDrawer: false,
|
|
89
|
+
widgetElements: new Map()
|
|
90
|
+
});
|
|
91
|
+
renderer.activeOverlays.set(100, { regions: overlayRegions });
|
|
92
|
+
|
|
93
|
+
const result = renderer._findRegionByWidgetId('ow1');
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result.regionId).toBe('overlay-r1');
|
|
96
|
+
expect(result.widgetIndex).toBe(0);
|
|
97
|
+
expect(result.regionMap).toBe(overlayRegions);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('_handleInteractiveTrigger', () => {
|
|
102
|
+
it('should call navigateToWidget when target exists', () => {
|
|
103
|
+
renderer.emit('interactiveTrigger', { targetId: 'w2', triggerCode: 'btn1' });
|
|
104
|
+
expect(renderer.navigateToWidget).toHaveBeenCalledWith('w2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should not call navigateToWidget when target is unknown', () => {
|
|
108
|
+
renderer.emit('interactiveTrigger', { targetId: 'w-missing', triggerCode: 'btn1' });
|
|
109
|
+
expect(renderer.navigateToWidget).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('_handleWidgetExpire', () => {
|
|
114
|
+
it('should clear timer, stop widget, and advance region', () => {
|
|
115
|
+
const region = renderer.regions.get('region-1');
|
|
116
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
117
|
+
region.currentIndex = 0;
|
|
118
|
+
|
|
119
|
+
renderer.emit('widgetExpire', { widgetId: 'w1' });
|
|
120
|
+
|
|
121
|
+
expect(region.timer).toBeNull();
|
|
122
|
+
expect(renderer.stopWidget).toHaveBeenCalledWith('region-1', 0);
|
|
123
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should do nothing for unknown widget', () => {
|
|
127
|
+
renderer.emit('widgetExpire', { widgetId: 'w-missing' });
|
|
128
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('_handleWidgetExtendDuration', () => {
|
|
133
|
+
it('should clear existing timer and re-arm with extended duration', () => {
|
|
134
|
+
vi.useFakeTimers();
|
|
135
|
+
const region = renderer.regions.get('region-1');
|
|
136
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
137
|
+
|
|
138
|
+
renderer.emit('widgetExtendDuration', { widgetId: 'w1', duration: 15 });
|
|
139
|
+
|
|
140
|
+
// Timer should be re-armed (not null)
|
|
141
|
+
expect(region.timer).not.toBeNull();
|
|
142
|
+
// stopWidget should NOT have been called yet (timer hasn't fired)
|
|
143
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
// Advance time to fire the new timer
|
|
146
|
+
vi.advanceTimersByTime(15000);
|
|
147
|
+
expect(renderer.stopWidget).toHaveBeenCalledWith('region-1', 0);
|
|
148
|
+
|
|
149
|
+
vi.useRealTimers();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should do nothing for unknown widget', () => {
|
|
153
|
+
renderer.emit('widgetExtendDuration', { widgetId: 'w-missing', duration: 10 });
|
|
154
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('_handleWidgetSetDuration', () => {
|
|
159
|
+
it('should clear existing timer and set absolute duration', () => {
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
const region = renderer.regions.get('region-1');
|
|
162
|
+
region.timer = setTimeout(() => {}, 99999);
|
|
163
|
+
|
|
164
|
+
renderer.emit('widgetSetDuration', { widgetId: 'w2', duration: 5 });
|
|
165
|
+
|
|
166
|
+
expect(region.timer).not.toBeNull();
|
|
167
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
vi.advanceTimersByTime(5000);
|
|
170
|
+
// w2 is at index 1, but the region's currentIndex determines what gets stopped
|
|
171
|
+
expect(renderer.stopWidget).toHaveBeenCalled();
|
|
172
|
+
|
|
173
|
+
vi.useRealTimers();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should do nothing for unknown widget', () => {
|
|
177
|
+
renderer.emit('widgetSetDuration', { widgetId: 'w-missing', duration: 10 });
|
|
178
|
+
expect(renderer.stopWidget).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('_advanceRegion', () => {
|
|
183
|
+
it('should increment currentIndex and call _startRegionCycle', () => {
|
|
184
|
+
const region = renderer.regions.get('region-1');
|
|
185
|
+
region.currentIndex = 0;
|
|
186
|
+
|
|
187
|
+
renderer._advanceRegion('region-1', renderer.regions);
|
|
188
|
+
|
|
189
|
+
expect(region.currentIndex).toBe(1);
|
|
190
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should wrap around at end of widget list', () => {
|
|
194
|
+
const region = renderer.regions.get('region-1');
|
|
195
|
+
region.currentIndex = 2; // last widget
|
|
196
|
+
|
|
197
|
+
renderer._advanceRegion('region-1', renderer.regions);
|
|
198
|
+
|
|
199
|
+
expect(region.currentIndex).toBe(0);
|
|
200
|
+
expect(renderer._startRegionCycle).toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|