@xiboplayer/renderer 0.6.1 → 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.
@@ -6,12 +6,11 @@
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;
13
13
  let renderer;
14
- let mockGetMediaUrl;
15
14
  let mockGetWidgetHtml;
16
15
 
17
16
  beforeEach(() => {
@@ -29,7 +28,6 @@ describe('RendererLite', () => {
29
28
  }
30
29
 
31
30
  // Mock callbacks
32
- mockGetMediaUrl = vi.fn((fileId) => Promise.resolve(`blob://test-${fileId}`));
33
31
  mockGetWidgetHtml = vi.fn((widget) => Promise.resolve(`<html>Widget ${widget.id}</html>`));
34
32
 
35
33
  // Create renderer instance
@@ -37,7 +35,7 @@ describe('RendererLite', () => {
37
35
  { cmsUrl: 'https://test.com', hardwareKey: 'test-key' },
38
36
  container,
39
37
  {
40
- getMediaUrl: mockGetMediaUrl,
38
+ fileIdToSaveAs: new Map(),
41
39
  getWidgetHtml: mockGetWidgetHtml
42
40
  }
43
41
  );
@@ -415,7 +413,6 @@ describe('RendererLite', () => {
415
413
  expect(element.className).toBe('renderer-lite-widget');
416
414
  expect(element.style.width).toBe('100%');
417
415
  expect(element.style.height).toBe('100%');
418
- expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
419
416
  });
420
417
 
421
418
  it('should default to objectFit contain and objectPosition center center', async () => {
@@ -566,7 +563,6 @@ describe('RendererLite', () => {
566
563
  expect(element.muted).toBe(true);
567
564
  // loop is intentionally false - handled manually via 'ended' event to avoid black frames
568
565
  expect(element.loop).toBe(false);
569
- expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
570
566
  });
571
567
 
572
568
  it('should create text widget with iframe (blob fallback)', async () => {
@@ -968,66 +964,222 @@ describe('RendererLite', () => {
968
964
  });
969
965
 
970
966
  describe('Transitions', () => {
971
- // Skip: jsdom doesn't support Web Animations API
972
- it.skip('should apply fade in transition', async () => {
973
- const element = document.createElement('div');
974
- 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
+ });
975
983
 
976
- const transition = {
977
- type: 'fadeIn',
978
- duration: 1000,
979
- direction: 'N'
980
- };
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);
981
995
 
982
- // Import Transitions utility
983
- const { Transitions } = await import('./renderer-lite.js');
984
- const animation = Transitions.apply(element, transition, true, 1920, 1080);
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);
985
1010
 
986
- expect(animation).toBeTruthy();
987
- expect(animation.effect.getKeyframes()).toEqual(
988
- expect.arrayContaining([
989
- expect.objectContaining({ opacity: '0' }),
990
- expect.objectContaining({ opacity: '1' })
991
- ])
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
992
1018
  );
1019
+
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');
993
1024
  });
994
1025
 
995
- // Skip: jsdom doesn't support Web Animations API
996
- it.skip('should apply fly out transition with direction', async () => {
997
- const element = document.createElement('div');
1026
+ it('should apply fly in from East', () => {
1027
+ Transitions.apply(
1028
+ element, { type: 'flyIn', duration: 500, direction: 'E' }, true, 1920, 1080
1029
+ );
1030
+
1031
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, 0px)');
1032
+ expect(capturedKeyframes[1].transform).toBe('translate(0, 0)');
1033
+ });
998
1034
 
999
- const transition = {
1000
- type: 'flyOut',
1001
- duration: 1500,
1002
- direction: 'S' // South
1003
- };
1035
+ it('should apply fly in from South', () => {
1036
+ Transitions.apply(
1037
+ element, { type: 'flyIn', duration: 500, direction: 'S' }, true, 1920, 1080
1038
+ );
1004
1039
 
1005
- const { Transitions } = await import('./renderer-lite.js');
1006
- const animation = Transitions.apply(element, transition, false, 1920, 1080);
1040
+ expect(capturedKeyframes[0].transform).toBe('translate(0px, 1080px)');
1041
+ });
1007
1042
 
1008
- expect(animation).toBeTruthy();
1009
- const keyframes = animation.effect.getKeyframes();
1043
+ it('should apply fly in from West', () => {
1044
+ Transitions.apply(
1045
+ element, { type: 'flyIn', duration: 500, direction: 'W' }, true, 1920, 1080
1046
+ );
1010
1047
 
1011
- // Should translate to south (positive Y)
1012
- expect(keyframes[1].transform).toContain('1080px'); // Height offset
1048
+ expect(capturedKeyframes[0].transform).toBe('translate(-1920px, 0px)');
1013
1049
  });
1014
- });
1015
1050
 
1016
- describe('Memory Management', () => {
1017
- it('should clear mediaUrlCache on layout switch', async () => {
1018
- const xlf1 = `<layout><region id="r1"></region></layout>`;
1019
- const xlf2 = `<layout><region id="r2"></region></layout>`;
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)');
1020
1055
 
1021
- await renderer.renderLayout(xlf1, 1);
1022
- renderer.mediaUrlCache.set(1, 'blob://test-1');
1056
+ // SE
1057
+ Transitions.apply(element, { type: 'flyIn', duration: 500, direction: 'SE' }, true, 1920, 1080);
1058
+ expect(capturedKeyframes[0].transform).toBe('translate(1920px, 1080px)');
1023
1059
 
1024
- // Switch to different layout
1025
- await renderer.renderLayout(xlf2, 2);
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
+ `;
1026
1165
 
1027
- // Cache should be cleared
1028
- expect(renderer.mediaUrlCache.size).toBe(0);
1166
+ const layout = renderer.parseXlf(xlf);
1167
+ const widget = layout.regions[0].widgets[0];
1168
+
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
+ });
1029
1179
  });
1180
+ });
1030
1181
 
1182
+ describe('Memory Management', () => {
1031
1183
  it('should clear regions on stopCurrentLayout', async () => {
1032
1184
  const xlf = `
1033
1185
  <layout>
@@ -1111,8 +1263,22 @@ describe('RendererLite', () => {
1111
1263
  });
1112
1264
  });
1113
1265
 
1114
- describe('Parallel Media Pre-fetch', () => {
1115
- it('should pre-fetch all media URLs in parallel', async () => {
1266
+ describe('Media URL construction via fileIdToSaveAs', () => {
1267
+ it('should construct media URLs using fileIdToSaveAs map', async () => {
1268
+ const fileIdToSaveAs = new Map([
1269
+ ['1', '1.png'],
1270
+ ['5', '5.mp4'],
1271
+ ['7', '7.png']
1272
+ ]);
1273
+ const r = new RendererLite(
1274
+ { cmsUrl: 'https://test.com', hardwareKey: 'test-key' },
1275
+ container,
1276
+ {
1277
+ fileIdToSaveAs,
1278
+ getWidgetHtml: mockGetWidgetHtml
1279
+ }
1280
+ );
1281
+
1116
1282
  const xlf = `
1117
1283
  <layout>
1118
1284
  <region id="r1">
@@ -1129,16 +1295,11 @@ describe('RendererLite', () => {
1129
1295
  </layout>
1130
1296
  `;
1131
1297
 
1132
- await renderer.renderLayout(xlf, 1);
1133
-
1134
- // All media URLs should have been fetched
1135
- expect(mockGetMediaUrl).toHaveBeenCalledTimes(3);
1136
- expect(mockGetMediaUrl).toHaveBeenCalledWith(1);
1137
- expect(mockGetMediaUrl).toHaveBeenCalledWith(5);
1138
- expect(mockGetMediaUrl).toHaveBeenCalledWith(7);
1298
+ await r.renderLayout(xlf, 1);
1139
1299
 
1140
- // All should be in cache
1141
- expect(renderer.mediaUrlCache.size).toBe(3);
1300
+ // fileIdToSaveAs should have all 3 entries
1301
+ expect(fileIdToSaveAs.size).toBe(3);
1302
+ r.cleanup();
1142
1303
  });
1143
1304
  });
1144
1305
 
@@ -1798,6 +1959,57 @@ describe('RendererLite', () => {
1798
1959
 
1799
1960
  vi.useRealTimers();
1800
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
+ });
1801
2013
  });
1802
2014
 
1803
2015
  describe('Sub-Playlist (#10)', () => {
@@ -1906,6 +2118,65 @@ describe('RendererLite', () => {
1906
2118
  expect(ids.some(id => id.startsWith('a'))).toBe(true);
1907
2119
  expect(ids.some(id => id.startsWith('b'))).toBe(true);
1908
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
+ });
1909
2180
  });
1910
2181
 
1911
2182
  // ── Medium-Priority Spec Compliance ────────────────────────────────
@@ -2247,4 +2518,184 @@ describe('RendererLite', () => {
2247
2518
  expect(layout.regions[0].widgets[0].commands).toEqual([]);
2248
2519
  });
2249
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
+ });
2250
2701
  });
package/vitest.config.js CHANGED
@@ -4,5 +4,14 @@ export default defineConfig({
4
4
  test: {
5
5
  environment: 'jsdom',
6
6
  globals: true
7
+ },
8
+ resolve: {
9
+ alias: {
10
+ // hls.js is an optional runtime dependency (dynamic import in renderVideo).
11
+ // Alias to the monorepo mock so renderer tests work standalone.
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
15
+ }
7
16
  }
8
17
  });