@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 CHANGED
@@ -1,19 +1,53 @@
1
1
  # @xiboplayer/renderer
2
2
 
3
- **XLF layout rendering engine for Xibo digital signage.**
3
+ **Fast, memory-efficient XLF layout rendering engine for digital signage.**
4
4
 
5
5
  ## Overview
6
6
 
7
- RendererLite parses Xibo Layout Format (XLF) files and builds a live DOM with:
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** video (MP4/HLS), images, PDF (via PDF.js), text/ticker, web pages, clock, calendar, weather
10
- - **Transitions** fade and fly (8-direction compass) via Web Animations API
11
- - **Interactive actions** touch/click and keyboard triggers for widget navigation, layout jumps, and commands
12
- - **Layout preloading** 2-layout pool pre-builds upcoming layouts at 75% of current duration for zero-gap transitions
13
- - **Proportional scaling** ResizeObserver-based scaling to fit any screen resolution
14
- - **Overlay support** multiple simultaneous overlay layouts with independent z-index (1000+)
15
- - **Absolute widget positioning** widget elements use `position: absolute` within regions to layer correctly in multi-widget regions
16
- - **Animation cleanup** `fill: forwards` animations cancelled between widgets to prevent stale visual state (e.g. video hidden after PDF)
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
- container: document.getElementById('player'),
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
- // Render a layout from parsed XLF
34
- await renderer.renderLayout(xlf, { mediaBaseUrl: '/cache/' });
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
- | Widget | Implementation |
40
- |--------|---------------|
41
- | Video | `<video>` with native HLS (Safari) + hls.js fallback, pause-on-last-frame |
42
- | Image | `<img>` with CMS scaleType mapping (center->contain, stretch->fill, fit->cover), blob URL from cache |
43
- | PDF | PDF.js canvas rendering (dynamically imported) |
44
- | Text / Ticker | iframe with CMS-rendered HTML via GetResource |
45
- | Web page | bare `<iframe src="...">` |
46
- | Clock, Calendar, Weather | iframe via GetResource (server-rendered) |
47
- | All other CMS widgets | Generic iframe via GetResource |
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` logger, events
52
- - `pdfjs-dist` PDF rendering
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**: ~98% (missing only widget action event propagation)
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.2",
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.2",
17
- "@xiboplayer/schedule": "0.6.2",
18
- "@xiboplayer/utils": "0.6.2"
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
+ });