@xiboplayer/renderer 0.6.2 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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/utils": "0.6.3",
17
+ "@xiboplayer/cache": "0.6.3",
18
+ "@xiboplayer/schedule": "0.6.3"
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
+ });
@@ -47,7 +47,7 @@ import { LayoutPool } from './layout-pool.js';
47
47
  /**
48
48
  * Transition utilities for widget animations
49
49
  */
50
- const Transitions = {
50
+ export const Transitions = {
51
51
  /**
52
52
  * Apply fade in transition
53
53
  */
@@ -162,11 +162,15 @@ const Transitions = {
162
162
 
163
163
  switch (type) {
164
164
  case 'fade':
165
+ return isIn ? this.fadeIn(element, duration) : this.fadeOut(element, duration);
165
166
  case 'fadein':
166
167
  return isIn ? this.fadeIn(element, duration) : null;
167
168
  case 'fadeout':
168
169
  return isIn ? null : this.fadeOut(element, duration);
169
170
  case 'fly':
171
+ return isIn
172
+ ? this.flyIn(element, duration, direction, regionWidth, regionHeight)
173
+ : this.flyOut(element, duration, direction, regionWidth, regionHeight);
170
174
  case 'flyin':
171
175
  return isIn ? this.flyIn(element, duration, direction, regionWidth, regionHeight) : null;
172
176
  case 'flyout':
@@ -239,6 +243,12 @@ export class RendererLite {
239
243
  // Setup container styles
240
244
  this.setupContainer();
241
245
 
246
+ // Interactive Control (XIC) event handlers
247
+ this.emitter.on('interactiveTrigger', (data) => this._handleInteractiveTrigger(data));
248
+ this.emitter.on('widgetExpire', (data) => this._handleWidgetExpire(data));
249
+ this.emitter.on('widgetExtendDuration', (data) => this._handleWidgetExtendDuration(data));
250
+ this.emitter.on('widgetSetDuration', (data) => this._handleWidgetSetDuration(data));
251
+
242
252
  this.log.info('Initialized');
243
253
  }
244
254
 
@@ -411,6 +421,7 @@ export class RendererLite {
411
421
  const regionAndDrawerEls = layoutEl.querySelectorAll(':scope > region, :scope > drawer');
412
422
  for (const regionEl of regionAndDrawerEls) {
413
423
  const isDrawer = regionEl.tagName === 'drawer';
424
+ const regionType = regionEl.getAttribute('type') || null;
414
425
  const region = {
415
426
  id: regionEl.getAttribute('id'),
416
427
  width: parseInt(regionEl.getAttribute('width') || '0'),
@@ -426,6 +437,7 @@ export class RendererLite {
426
437
  transitionDirection: null,
427
438
  loop: true, // Default: cycle widgets. Spec: loop=0 means single media stays visible
428
439
  isDrawer,
440
+ isCanvas: regionType === 'canvas', // Canvas regions render all widgets simultaneously
429
441
  widgets: []
430
442
  };
431
443
 
@@ -468,11 +480,21 @@ export class RendererLite {
468
480
  region.widgets.push(widget);
469
481
  }
470
482
 
483
+ // Auto-detect canvas from CMS "global" widget (CMS bundles canvas sub-widgets
484
+ // into a single type="global" media element in the XLF)
485
+ if (!region.isCanvas && region.widgets.some(w => w.type === 'global')) {
486
+ region.isCanvas = true;
487
+ }
488
+
471
489
  layout.regions.push(region);
472
490
 
473
491
  if (isDrawer) {
474
492
  this.log.info(`Parsed drawer: id=${region.id} with ${region.widgets.length} widgets`);
475
493
  }
494
+
495
+ if (region.isCanvas) {
496
+ this.log.info(`Parsed canvas region: id=${region.id} with ${region.widgets.length} widgets (all render simultaneously)`);
497
+ }
476
498
  }
477
499
 
478
500
  // Calculate layout duration if not specified (duration=0)
@@ -858,6 +880,133 @@ export class RendererLite {
858
880
  }
859
881
  }
860
882
 
883
+ // ── Interactive Control (XIC) ─────────────────────────────────────
884
+
885
+ /**
886
+ * Find a region containing a widget by widget ID.
887
+ * Searches main regions first, then overlay regions.
888
+ * @param {string} widgetId
889
+ * @returns {{ regionId: string, region: Object, widget: Object, widgetIndex: number, regionMap: Map }|null}
890
+ */
891
+ _findRegionByWidgetId(widgetId) {
892
+ // Search main regions
893
+ for (const [regionId, region] of this.regions) {
894
+ const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
895
+ if (widgetIndex !== -1) {
896
+ return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: this.regions };
897
+ }
898
+ }
899
+ // Search overlay regions
900
+ for (const overlay of this.activeOverlays.values()) {
901
+ if (!overlay.regions) continue;
902
+ for (const [regionId, region] of overlay.regions) {
903
+ const widgetIndex = region.widgets.findIndex(w => w.id === widgetId);
904
+ if (widgetIndex !== -1) {
905
+ return { regionId, region, widget: region.widgets[widgetIndex], widgetIndex, regionMap: overlay.regions };
906
+ }
907
+ }
908
+ }
909
+ return null;
910
+ }
911
+
912
+ /**
913
+ * Advance a region to its next widget using the standard cycle.
914
+ * @param {string} regionId
915
+ * @param {Map} regionMap - The Map containing this region (main or overlay)
916
+ */
917
+ _advanceRegion(regionId, regionMap) {
918
+ const region = regionMap.get(regionId);
919
+ if (!region) return;
920
+ region.currentIndex = (region.currentIndex + 1) % region.widgets.length;
921
+ const isMain = regionMap === this.regions;
922
+ this._startRegionCycle(
923
+ region, regionId,
924
+ isMain ? (rid, idx) => this.renderWidget(rid, idx) : (rid, idx) => this.renderWidget(rid, idx),
925
+ isMain ? (rid, idx) => this.stopWidget(rid, idx) : (rid, idx) => this.stopWidget(rid, idx),
926
+ isMain ? () => this.checkLayoutComplete() : undefined
927
+ );
928
+ }
929
+
930
+ /**
931
+ * Handle interactiveTrigger XIC event — navigate to a target widget.
932
+ * @param {{ targetId: string, triggerCode: string }} data
933
+ */
934
+ _handleInteractiveTrigger({ targetId, triggerCode }) {
935
+ this.log.info(`XIC interactiveTrigger: target=${targetId} code=${triggerCode}`);
936
+ const found = this._findRegionByWidgetId(targetId);
937
+ if (found) {
938
+ this.navigateToWidget(targetId);
939
+ } else {
940
+ this.log.warn(`XIC interactiveTrigger: widget ${targetId} not found`);
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Handle widgetExpire XIC event — immediately expire a widget and advance.
946
+ * @param {{ widgetId: string }} data
947
+ */
948
+ _handleWidgetExpire({ widgetId }) {
949
+ const found = this._findRegionByWidgetId(widgetId);
950
+ if (!found) {
951
+ this.log.warn(`XIC widgetExpire: widget ${widgetId} not found`);
952
+ return;
953
+ }
954
+ const { regionId, region, widgetIndex, regionMap } = found;
955
+ this.log.info(`XIC widgetExpire: widget=${widgetId} region=${regionId}`);
956
+ if (region.timer) {
957
+ clearTimeout(region.timer);
958
+ region.timer = null;
959
+ }
960
+ this.stopWidget(regionId, widgetIndex);
961
+ this._advanceRegion(regionId, regionMap);
962
+ }
963
+
964
+ /**
965
+ * Handle widgetExtendDuration XIC event — extend the current widget timer.
966
+ * @param {{ widgetId: string, duration: number }} data - duration in seconds (added to remaining)
967
+ */
968
+ _handleWidgetExtendDuration({ widgetId, duration }) {
969
+ const found = this._findRegionByWidgetId(widgetId);
970
+ if (!found) {
971
+ this.log.warn(`XIC widgetExtendDuration: widget ${widgetId} not found`);
972
+ return;
973
+ }
974
+ const { regionId, region } = found;
975
+ this.log.info(`XIC widgetExtendDuration: widget=${widgetId} +${duration}s`);
976
+ if (region.timer) {
977
+ clearTimeout(region.timer);
978
+ region.timer = null;
979
+ }
980
+ // Re-arm timer with the extended duration
981
+ region.timer = setTimeout(() => {
982
+ this.stopWidget(regionId, region.currentIndex);
983
+ this._advanceRegion(regionId, found.regionMap);
984
+ }, duration * 1000);
985
+ }
986
+
987
+ /**
988
+ * Handle widgetSetDuration XIC event — replace the widget timer with an absolute duration.
989
+ * @param {{ widgetId: string, duration: number }} data - duration in seconds (absolute)
990
+ */
991
+ _handleWidgetSetDuration({ widgetId, duration }) {
992
+ const found = this._findRegionByWidgetId(widgetId);
993
+ if (!found) {
994
+ this.log.warn(`XIC widgetSetDuration: widget ${widgetId} not found`);
995
+ return;
996
+ }
997
+ const { regionId, region } = found;
998
+ this.log.info(`XIC widgetSetDuration: widget=${widgetId} ${duration}s`);
999
+ if (region.timer) {
1000
+ clearTimeout(region.timer);
1001
+ region.timer = null;
1002
+ }
1003
+ // Set timer with the absolute duration
1004
+ region.timer = setTimeout(() => {
1005
+ this.stopWidget(regionId, region.currentIndex);
1006
+ this._advanceRegion(regionId, found.regionMap);
1007
+ }, duration * 1000);
1008
+ }
1009
+
861
1010
  /**
862
1011
  * Navigate to a specific widget within a region (for navWidget actions)
863
1012
  */
@@ -894,6 +1043,9 @@ export class RendererLite {
894
1043
  if (region.isDrawer && nextIndex === 0) {
895
1044
  region.element.style.display = 'none';
896
1045
  this.log.info(`Drawer region ${regionId} hidden (cycle complete)`);
1046
+ } else if (region.isDrawer) {
1047
+ // Continue cycling through remaining drawer widgets (will hide on wrap to 0)
1048
+ this.navigateToWidget(region.widgets[nextIndex].id);
897
1049
  } else {
898
1050
  this.startRegion(regionId);
899
1051
  }
@@ -1176,6 +1328,7 @@ export class RendererLite {
1176
1328
  height: regionConfig.height * sf,
1177
1329
  complete: false, // Track if region has played all widgets once
1178
1330
  isDrawer: regionConfig.isDrawer || false,
1331
+ isCanvas: regionConfig.isCanvas || false, // Canvas regions render all widgets simultaneously
1179
1332
  widgetElements: new Map() // widgetId -> DOM element (for element reuse)
1180
1333
  });
1181
1334
  }
@@ -1449,13 +1602,15 @@ export class RendererLite {
1449
1602
  region.element.appendChild(element);
1450
1603
  }
1451
1604
 
1452
- // Hide all other widgets in region
1605
+ // Hide all other widgets in region (skip for canvas — all widgets stay visible)
1453
1606
  // Cancel fill:forwards animations first — they override inline styles
1454
- for (const [widgetId, widgetEl] of region.widgetElements) {
1455
- if (widgetId !== widget.id) {
1456
- widgetEl.getAnimations?.().forEach(a => a.cancel());
1457
- widgetEl.style.visibility = 'hidden';
1458
- widgetEl.style.opacity = '0';
1607
+ if (!region.isCanvas) {
1608
+ for (const [widgetId, widgetEl] of region.widgetElements) {
1609
+ if (widgetId !== widget.id) {
1610
+ widgetEl.getAnimations?.().forEach(a => a.cancel());
1611
+ widgetEl.style.visibility = 'hidden';
1612
+ widgetEl.style.opacity = '0';
1613
+ }
1459
1614
  }
1460
1615
  }
1461
1616
 
@@ -1698,10 +1853,17 @@ export class RendererLite {
1698
1853
  const idx = Math.floor(Math.random() * groupWidgets.length);
1699
1854
  selectedWidget = groupWidgets[idx];
1700
1855
  } else {
1701
- // Round-robin based on cycle index
1702
- const cycleIdx = this._subPlaylistCycleIndex.get(groupId) || 0;
1703
- selectedWidget = groupWidgets[cycleIdx % groupWidgets.length];
1704
- this._subPlaylistCycleIndex.set(groupId, cycleIdx + 1);
1856
+ // Round-robin based on cycle index, respecting playCount
1857
+ const state = this._subPlaylistCycleIndex.get(groupId) || { widgetIdx: 0, playsDone: 0 };
1858
+ selectedWidget = groupWidgets[state.widgetIdx % groupWidgets.length];
1859
+ const effectivePlayCount = selectedWidget.playCount || 1;
1860
+
1861
+ state.playsDone++;
1862
+ if (state.playsDone >= effectivePlayCount) {
1863
+ state.widgetIdx++;
1864
+ state.playsDone = 0;
1865
+ }
1866
+ this._subPlaylistCycleIndex.set(groupId, state);
1705
1867
  }
1706
1868
 
1707
1869
  this.log.info(`Sub-playlist cycle: group ${groupId} selected widget ${selectedWidget.id} (${groupWidgets.length} in group)`);
@@ -1722,6 +1884,13 @@ export class RendererLite {
1722
1884
  _startRegionCycle(region, regionId, showFn, hideFn, onCycleComplete) {
1723
1885
  if (!region || region.widgets.length === 0) return;
1724
1886
 
1887
+ // Canvas regions: render ALL widgets simultaneously (stacked), no cycling.
1888
+ // Duration = max widget duration; region completes when the longest widget expires.
1889
+ if (region.isCanvas) {
1890
+ this._startCanvasRegion(region, regionId, showFn, onCycleComplete);
1891
+ return;
1892
+ }
1893
+
1725
1894
  // Non-looping region with a single widget: show it and stay (spec: loop=0)
1726
1895
  if (region.widgets.length === 1) {
1727
1896
  showFn(regionId, 0);
@@ -1744,6 +1913,37 @@ export class RendererLite {
1744
1913
  playNext();
1745
1914
  }
1746
1915
 
1916
+ /**
1917
+ * Start a canvas region — render all widgets simultaneously (stacked).
1918
+ * Canvas regions show every widget at once rather than cycling through them.
1919
+ * The region duration is the maximum widget duration.
1920
+ * @param {Object} region - Region state
1921
+ * @param {string} regionId - Region ID
1922
+ * @param {Function} showFn - Show widget function (regionId, widgetIndex)
1923
+ * @param {Function} onCycleComplete - Callback when region completes
1924
+ */
1925
+ _startCanvasRegion(region, regionId, showFn, onCycleComplete) {
1926
+ // Show all widgets at once
1927
+ for (let i = 0; i < region.widgets.length; i++) {
1928
+ showFn(regionId, i);
1929
+ }
1930
+
1931
+ // Mark region as complete after max widget duration
1932
+ const maxDuration = Math.max(...region.widgets.map(w => w.duration)) * 1000;
1933
+ if (maxDuration > 0) {
1934
+ region.timer = setTimeout(() => {
1935
+ if (!region.complete) {
1936
+ region.complete = true;
1937
+ onCycleComplete?.();
1938
+ }
1939
+ }, maxDuration);
1940
+ } else {
1941
+ // No duration — immediately complete
1942
+ region.complete = true;
1943
+ onCycleComplete?.();
1944
+ }
1945
+ }
1946
+
1747
1947
  /**
1748
1948
  * Handle widget cycle end — shared logic for timer-based and event-based cycling
1749
1949
  */
@@ -2947,6 +3147,7 @@ export class RendererLite {
2947
3147
  width: regionConfig.width * sf,
2948
3148
  height: regionConfig.height * sf,
2949
3149
  complete: false,
3150
+ isCanvas: regionConfig.isCanvas || false,
2950
3151
  widgetElements: new Map()
2951
3152
  });
2952
3153
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
- import { RendererLite } from './renderer-lite.js';
9
+ import { RendererLite, Transitions } from './renderer-lite.js';
10
10
 
11
11
  describe('RendererLite', () => {
12
12
  let container;
@@ -964,48 +964,218 @@ describe('RendererLite', () => {
964
964
  });
965
965
 
966
966
  describe('Transitions', () => {
967
- // Skip: jsdom doesn't support Web Animations API
968
- it.skip('should apply fade in transition', async () => {
969
- const element = document.createElement('div');
970
- element.style.opacity = '0';
967
+ let element;
968
+ let mockAnimate;
969
+ let capturedKeyframes;
970
+ let capturedTiming;
971
+
972
+ beforeEach(() => {
973
+ element = document.createElement('div');
974
+ capturedKeyframes = null;
975
+ capturedTiming = null;
976
+ mockAnimate = vi.fn((keyframes, timing) => {
977
+ capturedKeyframes = keyframes;
978
+ capturedTiming = timing;
979
+ return { onfinish: null, cancel: vi.fn() };
980
+ });
981
+ element.animate = mockAnimate;
982
+ });
971
983
 
972
- const transition = {
973
- type: 'fadeIn',
974
- duration: 1000,
975
- direction: 'N'
976
- };
984
+ it('should apply fade in transition', () => {
985
+ const result = Transitions.apply(element, { type: 'fadeIn', duration: 1000 }, true, 1920, 1080);
986
+
987
+ expect(result).toBeTruthy();
988
+ expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
989
+ expect(capturedTiming.duration).toBe(1000);
990
+ expect(capturedTiming.easing).toBe('linear');
991
+ });
992
+
993
+ it('should apply fade out transition', () => {
994
+ const result = Transitions.apply(element, { type: 'fadeOut', duration: 800 }, false, 1920, 1080);
995
+
996
+ expect(result).toBeTruthy();
997
+ expect(capturedKeyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]);
998
+ expect(capturedTiming.duration).toBe(800);
999
+ });
1000
+
1001
+ it('should apply generic "fade" as fadeIn when isIn=true', () => {
1002
+ const result = Transitions.apply(element, { type: 'fade', duration: 500 }, true, 1920, 1080);
1003
+
1004
+ expect(result).toBeTruthy();
1005
+ expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
1006
+ });
1007
+
1008
+ it('should apply generic "fade" as fadeOut when isIn=false', () => {
1009
+ const result = Transitions.apply(element, { type: 'fade', duration: 500 }, false, 1920, 1080);
1010
+
1011
+ expect(result).toBeTruthy();
1012
+ expect(capturedKeyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]);
1013
+ });
1014
+
1015
+ it('should apply fly in from North', () => {
1016
+ const result = Transitions.apply(
1017
+ element, { type: 'flyIn', duration: 500, direction: 'N' }, true, 1920, 1080
1018
+ );
977
1019
 
978
- // Import Transitions utility
979
- const { Transitions } = await import('./renderer-lite.js');
980
- const animation = Transitions.apply(element, transition, true, 1920, 1080);
1020
+ expect(result).toBeTruthy();
1021
+ expect(capturedKeyframes[0].transform).toBe('translate(0px, -1080px)');
1022
+ expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
1023
+ expect(capturedTiming.easing).toBe('ease-out');
1024
+ });
981
1025
 
982
- expect(animation).toBeTruthy();
983
- expect(animation.effect.getKeyframes()).toEqual(
984
- expect.arrayContaining([
985
- expect.objectContaining({ opacity: '0' }),
986
- expect.objectContaining({ opacity: '1' })
987
- ])
1026
+ it('should apply fly in from East', () => {
1027
+ Transitions.apply(
1028
+ element, { type: 'flyIn', duration: 500, direction: 'E' }, true, 1920, 1080
988
1029
  );
1030
+
1031
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, 0px)');
1032
+ expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
989
1033
  });
990
1034
 
991
- // Skip: jsdom doesn't support Web Animations API
992
- it.skip('should apply fly out transition with direction', async () => {
993
- const element = document.createElement('div');
1035
+ it('should apply fly in from South', () => {
1036
+ Transitions.apply(
1037
+ element, { type: 'flyIn', duration: 500, direction: 'S' }, true, 1920, 1080
1038
+ );
994
1039
 
995
- const transition = {
996
- type: 'flyOut',
997
- duration: 1500,
998
- direction: 'S' // South
999
- };
1040
+ expect(capturedKeyframes[0].transform).toBe('translate(0px, 1080px)');
1041
+ });
1042
+
1043
+ it('should apply fly in from West', () => {
1044
+ Transitions.apply(
1045
+ element, { type: 'flyIn', duration: 500, direction: 'W' }, true, 1920, 1080
1046
+ );
1047
+
1048
+ expect(capturedKeyframes[0].transform).toBe('translate(-1920px, 0px)');
1049
+ });
1050
+
1051
+ it('should apply fly in from diagonal directions (NE, SE, SW, NW)', () => {
1052
+ // NE
1053
+ Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'NE' }, true, 1920, 1080);
1054
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, -1080px)');
1055
+
1056
+ // SE
1057
+ Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'SE' }, true, 1920, 1080);
1058
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, 1080px)');
1000
1059
 
1001
- const { Transitions } = await import('./renderer-lite.js');
1002
- const animation = Transitions.apply(element, transition, false, 1920, 1080);
1060
+ // SW
1061
+ Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'SW' }, true, 1920, 1080);
1062
+ expect(capturedKeyframes[0].transform).toBe('translate(-1920px, 1080px)');
1063
+
1064
+ // NW
1065
+ Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'NW' }, true, 1920, 1080);
1066
+ expect(capturedKeyframes[0].transform).toBe('translate(-1920px, -1080px)');
1067
+ });
1068
+
1069
+ it('should apply fly out with direction S', () => {
1070
+ const result = Transitions.apply(
1071
+ element, { type: 'flyOut', duration: 1500, direction: 'S' }, false, 1920, 1080
1072
+ );
1073
+
1074
+ expect(result).toBeTruthy();
1075
+ expect(capturedKeyframes[0].transform).toBe('translate(0, 0)');
1076
+ expect(capturedKeyframes[1].transform).toBe('translate(0px, -1080px)');
1077
+ expect(capturedTiming.easing).toBe('ease-in');
1078
+ });
1079
+
1080
+ it('should apply generic "fly" as flyIn when isIn=true', () => {
1081
+ const result = Transitions.apply(
1082
+ element, { type: 'fly', duration: 500, direction: 'E' }, true, 1920, 1080
1083
+ );
1084
+
1085
+ expect(result).toBeTruthy();
1086
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, 0px)');
1087
+ expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
1088
+ expect(capturedTiming.easing).toBe('ease-out');
1089
+ });
1090
+
1091
+ it('should apply generic "fly" as flyOut when isIn=false', () => {
1092
+ const result = Transitions.apply(
1093
+ element, { type: 'fly', duration: 500, direction: 'W' }, false, 1920, 1080
1094
+ );
1095
+
1096
+ expect(result).toBeTruthy();
1097
+ expect(capturedKeyframes[0].transform).toBe('translate(0, 0)');
1098
+ expect(capturedKeyframes[1].transform).toContain('px');
1099
+ expect(capturedTiming.easing).toBe('ease-in');
1100
+ });
1101
+
1102
+ it('should not apply flyIn when isIn=false', () => {
1103
+ const result = Transitions.apply(
1104
+ element, { type: 'flyIn', duration: 500, direction: 'N' }, false, 1920, 1080
1105
+ );
1106
+ expect(result).toBeNull();
1107
+ });
1108
+
1109
+ it('should not apply flyOut when isIn=true', () => {
1110
+ const result = Transitions.apply(
1111
+ element, { type: 'flyOut', duration: 500, direction: 'N' }, true, 1920, 1080
1112
+ );
1113
+ expect(result).toBeNull();
1114
+ });
1115
+
1116
+ it('should default direction to N when missing', () => {
1117
+ Transitions.apply(element, { type: 'flyIn', duration: 500 }, true, 1920, 1080);
1118
+
1119
+ // N direction: translateY(-height)
1120
+ expect(capturedKeyframes[0].transform).toBe('translate(0px, -1080px)');
1121
+ });
1122
+
1123
+ it('should default duration to 1000 when missing', () => {
1124
+ Transitions.apply(element, { type: 'fadeIn' }, true, 1920, 1080);
1125
+
1126
+ expect(capturedTiming.duration).toBe(1000);
1127
+ });
1128
+
1129
+ it('should return null for unknown transition type', () => {
1130
+ const result = Transitions.apply(element, { type: 'slide' }, true, 1920, 1080);
1131
+ expect(result).toBeNull();
1132
+ });
1133
+
1134
+ it('should return null when config is null', () => {
1135
+ expect(Transitions.apply(element, null, true, 1920, 1080)).toBeNull();
1136
+ });
1137
+
1138
+ it('should return null when config has no type', () => {
1139
+ expect(Transitions.apply(element, { duration: 500 }, true, 1920, 1080)).toBeNull();
1140
+ });
1141
+
1142
+ it('should be case-insensitive for type matching', () => {
1143
+ const result = Transitions.apply(element, { type: 'FadeIn', duration: 500 }, true, 1920, 1080);
1144
+ expect(result).toBeTruthy();
1145
+ expect(capturedKeyframes).toEqual([{ opacity: 0 }, { opacity: 1 }]);
1146
+ });
1147
+
1148
+ it('should parse fly transitions from XLF with generic "fly" type', () => {
1149
+ const xlf = `
1150
+ <layout>
1151
+ <region id="r1">
1152
+ <media id="m1" type="image" duration="10">
1153
+ <options>
1154
+ <transIn>fly</transIn>
1155
+ <transInDuration>500</transInDuration>
1156
+ <transInDirection>E</transInDirection>
1157
+ <transOut>fly</transOut>
1158
+ <transOutDuration>500</transOutDuration>
1159
+ <transOutDirection>NW</transOutDirection>
1160
+ </options>
1161
+ </media>
1162
+ </region>
1163
+ </layout>
1164
+ `;
1003
1165
 
1004
- expect(animation).toBeTruthy();
1005
- const keyframes = animation.effect.getKeyframes();
1166
+ const layout = renderer.parseXlf(xlf);
1167
+ const widget = layout.regions[0].widgets[0];
1006
1168
 
1007
- // Should translate to south (positive Y)
1008
- expect(keyframes[1].transform).toContain('1080px'); // Height offset
1169
+ expect(widget.transitions.in).toEqual({
1170
+ type: 'fly',
1171
+ duration: 500,
1172
+ direction: 'E'
1173
+ });
1174
+ expect(widget.transitions.out).toEqual({
1175
+ type: 'fly',
1176
+ duration: 500,
1177
+ direction: 'NW'
1178
+ });
1009
1179
  });
1010
1180
  });
1011
1181
 
@@ -1789,6 +1959,57 @@ describe('RendererLite', () => {
1789
1959
 
1790
1960
  vi.useRealTimers();
1791
1961
  });
1962
+
1963
+ it('should hide multi-widget drawer after cycling through all widgets', async () => {
1964
+ vi.useFakeTimers();
1965
+
1966
+ const xlf = `
1967
+ <layout width="1920" height="1080" duration="60">
1968
+ <region id="r1" width="1920" height="1080" top="0" left="0">
1969
+ <media id="m1" type="image" duration="60" fileId="1">
1970
+ <options><uri>test.png</uri></options>
1971
+ </media>
1972
+ </region>
1973
+ <drawer id="d1" width="400" height="300" top="100" left="100">
1974
+ <media id="dm1" type="image" duration="3" fileId="2">
1975
+ <options><uri>d1.png</uri></options>
1976
+ </media>
1977
+ <media id="dm2" type="image" duration="3" fileId="3">
1978
+ <options><uri>d2.png</uri></options>
1979
+ </media>
1980
+ <media id="dm3" type="image" duration="3" fileId="4">
1981
+ <options><uri>d3.png</uri></options>
1982
+ </media>
1983
+ </drawer>
1984
+ </layout>
1985
+ `;
1986
+
1987
+ const renderPromise = renderer.renderLayout(xlf, 1);
1988
+ await vi.advanceTimersByTimeAsync(100);
1989
+ await renderPromise;
1990
+
1991
+ const drawerRegion = renderer.regions.get('d1');
1992
+ expect(drawerRegion.element.style.display).toBe('none');
1993
+
1994
+ // Navigate to first drawer widget
1995
+ renderer.navigateToWidget('dm1');
1996
+ expect(drawerRegion.element.style.display).toBe('');
1997
+ expect(drawerRegion.currentIndex).toBe(0);
1998
+
1999
+ // After dm1 duration → advances to dm2, still visible
2000
+ await vi.advanceTimersByTimeAsync(3100);
2001
+ expect(drawerRegion.element.style.display).toBe('');
2002
+
2003
+ // After dm2 duration → advances to dm3, still visible
2004
+ await vi.advanceTimersByTimeAsync(3100);
2005
+ expect(drawerRegion.element.style.display).toBe('');
2006
+
2007
+ // After dm3 duration → wraps to 0, drawer hidden
2008
+ await vi.advanceTimersByTimeAsync(3100);
2009
+ expect(drawerRegion.element.style.display).toBe('none');
2010
+
2011
+ vi.useRealTimers();
2012
+ });
1792
2013
  });
1793
2014
 
1794
2015
  describe('Sub-Playlist (#10)', () => {
@@ -1897,6 +2118,65 @@ describe('RendererLite', () => {
1897
2118
  expect(ids.some(id => id.startsWith('a'))).toBe(true);
1898
2119
  expect(ids.some(id => id.startsWith('b'))).toBe(true);
1899
2120
  });
2121
+
2122
+ it('should repeat widget playCount times before advancing (#188)', () => {
2123
+ const widgets = [
2124
+ { id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
2125
+ displayOrder: 1, cyclePlayback: true, playCount: 2, isRandom: false },
2126
+ { id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
2127
+ displayOrder: 2, cyclePlayback: true, playCount: 2, isRandom: false },
2128
+ ];
2129
+
2130
+ renderer._subPlaylistCycleIndex = new Map();
2131
+
2132
+ const r1 = renderer._applyCyclePlayback(widgets);
2133
+ const r2 = renderer._applyCyclePlayback(widgets);
2134
+ const r3 = renderer._applyCyclePlayback(widgets);
2135
+ const r4 = renderer._applyCyclePlayback(widgets);
2136
+
2137
+ // m1 plays twice, then m2 plays twice
2138
+ expect(r1[0].id).toBe('m1');
2139
+ expect(r2[0].id).toBe('m1');
2140
+ expect(r3[0].id).toBe('m2');
2141
+ expect(r4[0].id).toBe('m2');
2142
+ });
2143
+
2144
+ it('should treat playCount=0 or missing as 1 (#188)', () => {
2145
+ const widgets = [
2146
+ { id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
2147
+ displayOrder: 1, cyclePlayback: true, playCount: 0, isRandom: false },
2148
+ { id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
2149
+ displayOrder: 2, cyclePlayback: true, isRandom: false },
2150
+ ];
2151
+
2152
+ renderer._subPlaylistCycleIndex = new Map();
2153
+
2154
+ const r1 = renderer._applyCyclePlayback(widgets);
2155
+ const r2 = renderer._applyCyclePlayback(widgets);
2156
+
2157
+ // Should advance every cycle (playCount defaults to 1)
2158
+ expect(r1[0].id).toBe('m1');
2159
+ expect(r2[0].id).toBe('m2');
2160
+ });
2161
+
2162
+ it('should repeat playCount=3 times before advancing (#188)', () => {
2163
+ const widgets = [
2164
+ { id: 'm1', type: 'image', duration: 10, parentWidgetId: 'sp1',
2165
+ displayOrder: 1, cyclePlayback: true, playCount: 3, isRandom: false },
2166
+ { id: 'm2', type: 'image', duration: 10, parentWidgetId: 'sp1',
2167
+ displayOrder: 2, cyclePlayback: true, playCount: 3, isRandom: false },
2168
+ ];
2169
+
2170
+ renderer._subPlaylistCycleIndex = new Map();
2171
+
2172
+ const results = [];
2173
+ for (let i = 0; i < 6; i++) {
2174
+ results.push(renderer._applyCyclePlayback(widgets)[0].id);
2175
+ }
2176
+
2177
+ // m1 x3, m2 x3
2178
+ expect(results).toEqual(['m1', 'm1', 'm1', 'm2', 'm2', 'm2']);
2179
+ });
1900
2180
  });
1901
2181
 
1902
2182
  // ── Medium-Priority Spec Compliance ────────────────────────────────
@@ -2238,4 +2518,184 @@ describe('RendererLite', () => {
2238
2518
  expect(layout.regions[0].widgets[0].commands).toEqual([]);
2239
2519
  });
2240
2520
  });
2521
+
2522
+ describe('Canvas Regions (#186)', () => {
2523
+ it('should parse region with type="canvas" as isCanvas', () => {
2524
+ const xlf = `
2525
+ <layout width="1920" height="1080" duration="60">
2526
+ <region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
2527
+ <media id="m1" type="image" duration="10" fileId="1">
2528
+ <options><uri>img1.png</uri></options>
2529
+ </media>
2530
+ <media id="m2" type="image" duration="15" fileId="2">
2531
+ <options><uri>img2.png</uri></options>
2532
+ </media>
2533
+ </region>
2534
+ </layout>
2535
+ `;
2536
+
2537
+ const layout = renderer.parseXlf(xlf);
2538
+
2539
+ expect(layout.regions).toHaveLength(1);
2540
+ expect(layout.regions[0].isCanvas).toBe(true);
2541
+ expect(layout.regions[0].widgets).toHaveLength(2);
2542
+ });
2543
+
2544
+ it('should auto-detect canvas from type="global" widget', () => {
2545
+ const xlf = `
2546
+ <layout width="1920" height="1080" duration="60">
2547
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2548
+ <media id="m1" type="global" duration="30" fileId="1">
2549
+ <options></options>
2550
+ </media>
2551
+ </region>
2552
+ </layout>
2553
+ `;
2554
+
2555
+ const layout = renderer.parseXlf(xlf);
2556
+
2557
+ expect(layout.regions[0].isCanvas).toBe(true);
2558
+ });
2559
+
2560
+ it('should NOT mark normal regions as canvas', () => {
2561
+ const xlf = `
2562
+ <layout width="1920" height="1080" duration="60">
2563
+ <region id="r1" width="1920" height="1080" top="0" left="0">
2564
+ <media id="m1" type="image" duration="10" fileId="1">
2565
+ <options><uri>test.png</uri></options>
2566
+ </media>
2567
+ </region>
2568
+ </layout>
2569
+ `;
2570
+
2571
+ const layout = renderer.parseXlf(xlf);
2572
+
2573
+ expect(layout.regions[0].isCanvas).toBe(false);
2574
+ });
2575
+
2576
+ it('should store isCanvas flag in region state after createRegion', async () => {
2577
+ const regionConfig = {
2578
+ id: 'r1',
2579
+ width: 1920,
2580
+ height: 1080,
2581
+ top: 0,
2582
+ left: 0,
2583
+ zindex: 0,
2584
+ isCanvas: true,
2585
+ widgets: []
2586
+ };
2587
+
2588
+ await renderer.createRegion(regionConfig);
2589
+
2590
+ const region = renderer.regions.get('r1');
2591
+ expect(region.isCanvas).toBe(true);
2592
+ });
2593
+
2594
+ it('should render all canvas widgets simultaneously', async () => {
2595
+ vi.useFakeTimers();
2596
+
2597
+ const xlf = `
2598
+ <layout width="1920" height="1080" duration="60">
2599
+ <region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
2600
+ <media id="m1" type="image" duration="10" fileId="1">
2601
+ <options><uri>img1.png</uri></options>
2602
+ </media>
2603
+ <media id="m2" type="image" duration="15" fileId="2">
2604
+ <options><uri>img2.png</uri></options>
2605
+ </media>
2606
+ <media id="m3" type="image" duration="20" fileId="3">
2607
+ <options><uri>img3.png</uri></options>
2608
+ </media>
2609
+ </region>
2610
+ </layout>
2611
+ `;
2612
+
2613
+ const renderPromise = renderer.renderLayout(xlf, 1);
2614
+ await vi.advanceTimersByTimeAsync(5000);
2615
+
2616
+ const region = renderer.regions.get('r1');
2617
+ expect(region).toBeDefined();
2618
+ expect(region.isCanvas).toBe(true);
2619
+
2620
+ // All 3 widgets should be visible simultaneously
2621
+ let visibleCount = 0;
2622
+ for (const [, el] of region.widgetElements) {
2623
+ if (el.style.visibility === 'visible') visibleCount++;
2624
+ }
2625
+ expect(visibleCount).toBe(3);
2626
+
2627
+ // Clean up
2628
+ await vi.advanceTimersByTimeAsync(60000);
2629
+ await renderPromise;
2630
+ vi.useRealTimers();
2631
+ });
2632
+
2633
+ it('should not cycle canvas region widgets', async () => {
2634
+ vi.useFakeTimers();
2635
+
2636
+ const xlf = `
2637
+ <layout width="1920" height="1080" duration="60">
2638
+ <region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
2639
+ <media id="m1" type="image" duration="5" fileId="1">
2640
+ <options><uri>img1.png</uri></options>
2641
+ </media>
2642
+ <media id="m2" type="image" duration="5" fileId="2">
2643
+ <options><uri>img2.png</uri></options>
2644
+ </media>
2645
+ </region>
2646
+ </layout>
2647
+ `;
2648
+
2649
+ const renderPromise = renderer.renderLayout(xlf, 1);
2650
+ await vi.advanceTimersByTimeAsync(2000);
2651
+
2652
+ const region = renderer.regions.get('r1');
2653
+
2654
+ // After widget durations expire, both should still be visible (no cycling)
2655
+ await vi.advanceTimersByTimeAsync(10000);
2656
+
2657
+ let visibleCount = 0;
2658
+ for (const [, el] of region.widgetElements) {
2659
+ if (el.style.visibility === 'visible') visibleCount++;
2660
+ }
2661
+ expect(visibleCount).toBe(2);
2662
+
2663
+ // Clean up
2664
+ await vi.advanceTimersByTimeAsync(60000);
2665
+ await renderPromise;
2666
+ vi.useRealTimers();
2667
+ });
2668
+
2669
+ it('should mark canvas region complete after max widget duration', async () => {
2670
+ vi.useFakeTimers();
2671
+
2672
+ const xlf = `
2673
+ <layout width="1920" height="1080">
2674
+ <region id="r1" type="canvas" width="1920" height="1080" top="0" left="0">
2675
+ <media id="m1" type="image" duration="5" fileId="1">
2676
+ <options><uri>img1.png</uri></options>
2677
+ </media>
2678
+ <media id="m2" type="image" duration="10" fileId="2">
2679
+ <options><uri>img2.png</uri></options>
2680
+ </media>
2681
+ </region>
2682
+ </layout>
2683
+ `;
2684
+
2685
+ const renderPromise = renderer.renderLayout(xlf, 1);
2686
+ await vi.advanceTimersByTimeAsync(2000);
2687
+
2688
+ const region = renderer.regions.get('r1');
2689
+ expect(region.complete).toBe(false);
2690
+
2691
+ // Advance past max widget duration (10s)
2692
+ await vi.advanceTimersByTimeAsync(9000);
2693
+ expect(region.complete).toBe(true);
2694
+
2695
+ // Clean up
2696
+ await vi.advanceTimersByTimeAsync(60000);
2697
+ await renderPromise;
2698
+ vi.useRealTimers();
2699
+ });
2700
+ });
2241
2701
  });
package/vitest.config.js CHANGED
@@ -9,7 +9,9 @@ export default defineConfig({
9
9
  alias: {
10
10
  // hls.js is an optional runtime dependency (dynamic import in renderVideo).
11
11
  // Alias to the monorepo mock so renderer tests work standalone.
12
- 'hls.js': new URL('../../vitest.hls-mock.js', import.meta.url).pathname
12
+ 'hls.js': new URL('../../vitest.hls-mock.js', import.meta.url).pathname,
13
+ '@xiboplayer/schedule': new URL('../schedule/src/index.js', import.meta.url).pathname,
14
+ '@xiboplayer/utils': new URL('../utils/src/index.js', import.meta.url).pathname
13
15
  }
14
16
  }
15
17
  });