@xiboplayer/schedule 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/schedule",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  "./overlays": "./src/overlays.js"
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/utils": "0.6.2"
15
+ "@xiboplayer/utils": "0.6.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
package/src/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export class ScheduleManager {
14
14
 
15
15
  export const scheduleManager: ScheduleManager;
16
16
 
17
- export function calculateTimeline(layouts: any[], durations: Map<string, number>, options?: any): any[];
17
+ export function calculateTimeline(queue: Array<{layoutId: string, duration: number}>, queuePosition: number, options?: any): any[];
18
18
  export function parseLayoutDuration(xlf: string, videoDurations?: Map<string, number> | null): { duration: number; isDynamic: boolean };
19
19
  export function buildScheduleQueue(schedule: any, durations: Map<string, number>): any[];
@@ -35,13 +35,8 @@ function getDifferentDay() {
35
35
 
36
36
  // Helper to mock Date at specific time
37
37
  function mockTimeAt(targetDate) {
38
- const RealDate = Date;
39
- vi.spyOn(global, 'Date').mockImplementation((...args) => {
40
- if (args.length === 0) {
41
- return new RealDate(targetDate);
42
- }
43
- return new RealDate(...args);
44
- });
38
+ vi.useFakeTimers({ shouldAdvanceTime: false });
39
+ vi.setSystemTime(new Date(targetDate));
45
40
  }
46
41
 
47
42
  describe('ScheduleManager - Dayparting', () => {
@@ -50,14 +45,10 @@ describe('ScheduleManager - Dayparting', () => {
50
45
 
51
46
  beforeEach(() => {
52
47
  manager = new ScheduleManager();
53
- originalDate = global.Date;
54
48
  });
55
49
 
56
50
  afterEach(() => {
57
- // Restore Date
58
- if (vi.isMockFunction(global.Date)) {
59
- global.Date = originalDate;
60
- }
51
+ vi.useRealTimers();
61
52
  });
62
53
 
63
54
  describe('Weekday Schedules', () => {
package/src/timeline.js CHANGED
@@ -38,7 +38,9 @@ export function parseLayoutDuration(xlfXml, videoDurations = null) {
38
38
  let maxDuration = 0;
39
39
  let isDynamic = false;
40
40
  for (const regionEl of layoutEl.querySelectorAll('region')) {
41
- if (regionEl.getAttribute('type') === 'drawer') continue; // Drawers are action-triggered, not timed
41
+ const regionType = regionEl.getAttribute('type');
42
+ if (regionType === 'drawer') continue; // Drawers are action-triggered, not timed
43
+ const isCanvas = regionType === 'canvas';
42
44
  let regionDuration = 0;
43
45
  for (const mediaEl of regionEl.querySelectorAll('media')) {
44
46
  const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
@@ -46,16 +48,24 @@ export function parseLayoutDuration(xlfXml, videoDurations = null) {
46
48
  const fileId = mediaEl.getAttribute('fileId') || '';
47
49
  const probed = videoDurations?.get(fileId);
48
50
 
51
+ let widgetDuration;
49
52
  if (probed !== undefined) {
50
- regionDuration += probed; // Phase 2: probed video duration
53
+ widgetDuration = probed; // Phase 2: probed video duration
51
54
  } else if (dur > 0 && useDuration !== 0) {
52
- regionDuration += dur; // Explicit CMS duration
55
+ widgetDuration = dur; // Explicit CMS duration
53
56
  } else {
54
57
  // Video with useDuration=0 means "play to end" — estimate 60s,
55
58
  // corrected later via recordLayoutDuration() when video metadata loads
56
- regionDuration += 60;
59
+ widgetDuration = 60;
57
60
  isDynamic = true;
58
61
  }
62
+
63
+ if (isCanvas) {
64
+ // Canvas regions play all widgets simultaneously — duration is max, not sum
65
+ regionDuration = Math.max(regionDuration, widgetDuration);
66
+ } else {
67
+ regionDuration += widgetDuration;
68
+ }
59
69
  }
60
70
  maxDuration = Math.max(maxDuration, regionDuration);
61
71
  }
@@ -110,23 +120,6 @@ function canSimulatedPlay(history, maxPlaysPerHour, timeMs) {
110
120
  return true;
111
121
  }
112
122
 
113
- /**
114
- * Seed simulated play history from real play history.
115
- * Maps layoutId-based history to layoutFile-based history.
116
- * @param {Map<string, number[]>} realHistory - schedule.playHistory (layoutId → [timestamps])
117
- * @returns {Map<string, number[]>} layoutFile → [timestamps]
118
- */
119
- function seedPlayHistory(realHistory) {
120
- const simulated = new Map();
121
- if (!realHistory) return simulated;
122
-
123
- for (const [layoutId, timestamps] of realHistory) {
124
- const file = `${layoutId}.xlf`;
125
- simulated.set(file, [...timestamps]);
126
- }
127
- return simulated;
128
- }
129
-
130
123
  /**
131
124
  * From a list of layout metadata, apply simulated rate limiting and priority
132
125
  * filtering to determine which layouts can actually play at the given time.
@@ -156,129 +149,63 @@ function getPlayableLayouts(allLayouts, simPlays, timeMs) {
156
149
  }
157
150
 
158
151
  /**
159
- * Calculate a deterministic playback timeline by simulating round-robin scheduling
160
- * with rate limiting (maxPlaysPerHour) and priority fallback. Produces a real
161
- * schedule prediction that matches actual player behavior.
152
+ * Calculate a deterministic playback timeline by walking the pre-built schedule queue.
162
153
  *
163
- * When high-priority layouts hit their maxPlaysPerHour limit, the simulation
164
- * falls back to lower-priority scheduled layouts before using the CMS default.
154
+ * The queue already has all constraints baked in (maxPlaysPerHour, priorities,
155
+ * dayparting, default layout fills). This function simply cycles through it from
156
+ * the current position, generating time-stamped entries for the overlay.
165
157
  *
166
- * @param {Object} schedule - ScheduleManager instance (needs getAllLayoutsAtTime(), schedule.default, playHistory)
167
- * @param {Map<string, number>} durations - Map of layoutFile duration in seconds
158
+ * @param {Array<{layoutId: string, duration: number}>} queue - Pre-built schedule queue from buildScheduleQueue()
159
+ * @param {number} queuePosition - Current position in the queue (from schedule._queuePosition)
168
160
  * @param {Object} [options]
169
161
  * @param {Date} [options.from] - Start time (default: now)
170
- * @param {number} [options.hours] - Hours to simulate (default: 2)
171
- * @param {number} [options.defaultDuration] - Fallback duration in seconds (default: 60)
162
+ * @param {number} [options.hours] - Hours to project (default: 2)
163
+ * @param {string} [options.defaultLayout] - Default layout file (to tag isDefault entries)
164
+ * @param {Map<string, number>} [options.durations] - Live durations map (overrides queue entry durations with corrected values)
172
165
  * @param {Date} [options.currentLayoutStartedAt] - When current layout started (adjusts first entry to remaining time)
173
166
  * @returns {Array<{layoutFile: string, startTime: Date, endTime: Date, duration: number, isDefault: boolean}>}
174
167
  */
175
- export function calculateTimeline(schedule, durations, options = {}) {
168
+ export function calculateTimeline(queue, queuePosition, options = {}) {
176
169
  const from = options.from || new Date();
177
170
  const hours = options.hours || 2;
178
171
  const to = new Date(from.getTime() + hours * 3600000);
179
- const defaultDuration = options.defaultDuration || 60;
180
172
  const currentLayoutStartedAt = options.currentLayoutStartedAt || null;
173
+ const defaultLayout = options.defaultLayout || null;
174
+ const durations = options.durations || null;
175
+
176
+ if (!queue || queue.length === 0) return [];
177
+
181
178
  const timeline = [];
182
179
  let currentTime = new Date(from);
180
+ let pos = queuePosition % queue.length;
183
181
  let isFirstEntry = true;
184
-
185
- // Use getAllLayoutsAtTime if available (new API), fall back to getLayoutsAtTime (old API)
186
- const hasFullApi = typeof schedule.getAllLayoutsAtTime === 'function';
187
-
188
- // Seed simulated play history from real plays
189
- const simPlays = seedPlayHistory(schedule.playHistory);
190
-
191
182
  const maxEntries = 500;
192
183
 
193
184
  while (currentTime < to && timeline.length < maxEntries) {
194
- const timeMs = currentTime.getTime();
195
- let playable;
196
-
197
- let hiddenLayouts = null;
198
-
199
- if (hasFullApi) {
200
- // Full simulation: get ALL active layouts, apply rate limiting + priority
201
- const allLayouts = schedule.getAllLayoutsAtTime(currentTime);
202
- playable = allLayouts.length > 0
203
- ? getPlayableLayouts(allLayouts, simPlays, timeMs)
204
- : [];
205
- // Detect hidden layouts (lower priority, not playing)
206
- if (allLayouts.length > playable.length) {
207
- hiddenLayouts = allLayouts
208
- .filter(l => !playable.includes(l.file))
209
- .map(l => ({ file: l.file, priority: l.priority }));
210
- }
211
- } else {
212
- // Legacy fallback: no rate limiting simulation
213
- playable = schedule.getLayoutsAtTime(currentTime);
185
+ const entry = queue[pos];
186
+ // Use live-corrected duration (from video metadata, etc.) if available,
187
+ // otherwise fall back to the queue's baked-in duration
188
+ let dur = (durations && durations.get(entry.layoutId)) || entry.duration;
189
+
190
+ // First entry: use remaining duration if we know when the current layout started
191
+ if (isFirstEntry && currentLayoutStartedAt) {
192
+ const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
193
+ dur = Math.max(1, Math.round(dur - elapsedSec));
194
+ isFirstEntry = false;
214
195
  }
215
196
 
216
- if (playable.length === 0) {
217
- // No playable layouts — use CMS default or skip ahead
218
- const defaultFile = schedule.schedule?.default;
219
- if (defaultFile) {
220
- const dur = durations.get(defaultFile) || defaultDuration;
221
- timeline.push({
222
- layoutFile: defaultFile,
223
- startTime: new Date(currentTime),
224
- endTime: new Date(timeMs + dur * 1000),
225
- duration: dur,
226
- isDefault: true,
227
- });
228
- currentTime = new Date(timeMs + dur * 1000);
229
- } else {
230
- currentTime = new Date(timeMs + 60000);
231
- }
232
- continue;
233
- }
197
+ const endMs = currentTime.getTime() + dur * 1000;
234
198
 
235
- // Round-robin through playable layouts
236
- for (let i = 0; i < playable.length && currentTime < to && timeline.length < maxEntries; i++) {
237
- const file = playable[i];
238
- let dur = durations.get(file) || defaultDuration;
239
-
240
- // First entry: use remaining duration if we know when the current layout started
241
- if (isFirstEntry && currentLayoutStartedAt) {
242
- const elapsedSec = (from.getTime() - currentLayoutStartedAt.getTime()) / 1000;
243
- const remaining = Math.max(1, Math.round(dur - elapsedSec));
244
- dur = remaining;
245
- isFirstEntry = false;
246
- }
199
+ timeline.push({
200
+ layoutFile: entry.layoutId,
201
+ startTime: new Date(currentTime),
202
+ endTime: new Date(endMs),
203
+ duration: dur,
204
+ isDefault: defaultLayout ? entry.layoutId === defaultLayout : false,
205
+ });
247
206
 
248
- const endMs = currentTime.getTime() + dur * 1000;
249
-
250
- const entry = {
251
- layoutFile: file,
252
- startTime: new Date(currentTime),
253
- endTime: new Date(endMs),
254
- duration: dur,
255
- isDefault: false,
256
- };
257
- if (hiddenLayouts && hiddenLayouts.length > 0) {
258
- entry.hidden = hiddenLayouts;
259
- }
260
- timeline.push(entry);
261
-
262
- // Record simulated play
263
- if (hasFullApi) {
264
- if (!simPlays.has(file)) simPlays.set(file, []);
265
- simPlays.get(file).push(currentTime.getTime());
266
- }
267
-
268
- currentTime = new Date(endMs);
269
-
270
- // Re-evaluate: if playable set changed, re-enter outer loop
271
- if (hasFullApi) {
272
- const nextAll = schedule.getAllLayoutsAtTime(currentTime);
273
- const nextPlayable = nextAll.length > 0
274
- ? getPlayableLayouts(nextAll, simPlays, currentTime.getTime())
275
- : [];
276
- if (!arraysEqual(playable, nextPlayable)) break;
277
- } else {
278
- const next = schedule.getLayoutsAtTime(currentTime);
279
- if (!arraysEqual(playable, next)) break;
280
- }
281
- }
207
+ currentTime = new Date(endMs);
208
+ pos = (pos + 1) % queue.length;
282
209
  }
283
210
 
284
211
  return timeline;
@@ -1,60 +1,25 @@
1
1
  /**
2
2
  * Timeline Calculator Tests
3
3
  *
4
- * Tests for calculateTimeline() — the pure simulation function that produces
5
- * deterministic playback predictions from schedule + durations.
4
+ * Tests for calculateTimeline() — walks a pre-built queue to produce
5
+ * time-stamped playback predictions for the overlay.
6
6
  */
7
7
 
8
8
  import { describe, it, expect } from 'vitest';
9
9
  import { calculateTimeline, parseLayoutDuration } from './timeline.js';
10
10
 
11
- // ── Helpers ──────────────────────────────────────────────────────
12
-
13
- /** Create a mock schedule with getAllLayoutsAtTime() support */
14
- function createMockSchedule({ layouts = [], defaultLayout = null, playHistory = null } = {}) {
15
- return {
16
- schedule: { default: defaultLayout },
17
- playHistory: playHistory || new Map(),
18
- getAllLayoutsAtTime(time) {
19
- const t = time.getTime();
20
- return layouts.filter(l => {
21
- const from = new Date(l.fromdt).getTime();
22
- const to = new Date(l.todt).getTime();
23
- return t >= from && t < to;
24
- });
25
- },
26
- getLayoutsAtTime(time) {
27
- return this.getAllLayoutsAtTime(time).map(l => l.file);
28
- },
29
- };
30
- }
31
-
32
- function hoursFromNow(h) {
33
- return new Date(Date.now() + h * 3600000).toISOString();
34
- }
35
-
36
11
  // Fixed "now" for deterministic tests
37
12
  const NOW = new Date('2026-03-03T10:00:00Z');
38
13
 
39
- function fixedDate(isoTime) {
40
- return new Date(isoTime);
41
- }
42
-
43
14
  // ── Tests ────────────────────────────────────────────────────────
44
15
 
45
16
  describe('calculateTimeline', () => {
46
- describe('Basic scheduling', () => {
47
- it('should produce entries for a single scheduled layout', () => {
48
- const schedule = createMockSchedule({
49
- layouts: [{
50
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
51
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
52
- }],
53
- });
54
- const durations = new Map([['100.xlf', 30]]);
17
+ describe('Basic queue walking', () => {
18
+ it('should produce entries from a single-entry queue', () => {
19
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
55
20
 
56
- const timeline = calculateTimeline(schedule, durations, {
57
- from: NOW, hours: 1,
21
+ const timeline = calculateTimeline(queue, 0, {
22
+ from: NOW, hours: 0.1,
58
23
  });
59
24
 
60
25
  expect(timeline.length).toBeGreaterThan(0);
@@ -63,189 +28,142 @@ describe('calculateTimeline', () => {
63
28
  expect(timeline[0].isDefault).toBe(false);
64
29
  });
65
30
 
66
- it('should use default layout when no scheduled layouts exist', () => {
67
- const schedule = createMockSchedule({
68
- layouts: [],
69
- defaultLayout: 'default.xlf',
70
- });
71
- const durations = new Map([['default.xlf', 45]]);
31
+ it('should tag default layout entries', () => {
32
+ const queue = [{ layoutId: 'default.xlf', duration: 45 }];
72
33
 
73
- const timeline = calculateTimeline(schedule, durations, {
34
+ const timeline = calculateTimeline(queue, 0, {
74
35
  from: NOW, hours: 0.1,
36
+ defaultLayout: 'default.xlf',
75
37
  });
76
38
 
77
- expect(timeline.length).toBeGreaterThan(0);
78
39
  expect(timeline[0].layoutFile).toBe('default.xlf');
79
40
  expect(timeline[0].isDefault).toBe(true);
80
41
  expect(timeline[0].duration).toBe(45);
81
42
  });
82
43
 
83
- it('should use fallback duration when layout not in durations map', () => {
84
- const schedule = createMockSchedule({
85
- layouts: [{
86
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
87
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
88
- }],
89
- });
90
- const durations = new Map(); // No durations known
91
-
92
- const timeline = calculateTimeline(schedule, durations, {
93
- from: NOW, hours: 0.1, defaultDuration: 42,
94
- });
95
-
96
- expect(timeline[0].duration).toBe(42);
97
- });
44
+ it('should cycle through multiple queue entries', () => {
45
+ const queue = [
46
+ { layoutId: '100.xlf', duration: 30 },
47
+ { layoutId: '200.xlf', duration: 45 },
48
+ ];
98
49
 
99
- it('should round-robin multiple layouts at same priority', () => {
100
- const schedule = createMockSchedule({
101
- layouts: [
102
- { file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
103
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
104
- { file: '200.xlf', priority: 10, maxPlaysPerHour: 0,
105
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
106
- ],
107
- });
108
- const durations = new Map([['100.xlf', 30], ['200.xlf', 30]]);
109
-
110
- const timeline = calculateTimeline(schedule, durations, {
50
+ const timeline = calculateTimeline(queue, 0, {
111
51
  from: NOW, hours: 0.1,
112
52
  });
113
53
 
114
- // Both layouts should appear in the timeline
115
54
  const files = timeline.map(e => e.layoutFile);
116
55
  expect(files).toContain('100.xlf');
117
56
  expect(files).toContain('200.xlf');
57
+ // Should alternate
58
+ expect(files[0]).toBe('100.xlf');
59
+ expect(files[1]).toBe('200.xlf');
60
+ expect(files[2]).toBe('100.xlf');
118
61
  });
119
- });
120
62
 
121
- describe('Priority handling', () => {
122
- it('should play higher priority layout over lower priority', () => {
123
- const schedule = createMockSchedule({
124
- layouts: [
125
- { file: 'low.xlf', priority: 5, maxPlaysPerHour: 0,
126
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
127
- { file: 'high.xlf', priority: 10, maxPlaysPerHour: 0,
128
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
129
- ],
63
+ it('should use live durations map over queue baked-in durations', () => {
64
+ const queue = [{ layoutId: '100.xlf', duration: 60 }]; // queue says 60s
65
+ const durations = new Map([['100.xlf', 300]]); // video metadata says 300s
66
+
67
+ const timeline = calculateTimeline(queue, 0, {
68
+ from: NOW, hours: 0.5, durations,
130
69
  });
131
- const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
132
70
 
133
- const timeline = calculateTimeline(schedule, durations, {
134
- from: NOW, hours: 0.5,
71
+ expect(timeline[0].duration).toBe(300);
72
+ // Only ~6 entries in 30min at 300s each, not 30 at 60s
73
+ expect(timeline.length).toBeLessThanOrEqual(7);
74
+ });
75
+
76
+ it('should fall back to queue duration when not in durations map', () => {
77
+ const queue = [{ layoutId: '100.xlf', duration: 45 }];
78
+ const durations = new Map(); // empty
79
+
80
+ const timeline = calculateTimeline(queue, 0, {
81
+ from: NOW, hours: 0.1, durations,
135
82
  });
136
83
 
137
- // All entries should be high priority
138
- const uniqueFiles = [...new Set(timeline.map(e => e.layoutFile))];
139
- expect(uniqueFiles).toEqual(['high.xlf']);
84
+ expect(timeline[0].duration).toBe(45);
140
85
  });
141
86
 
142
- it('should annotate hidden (overshadowed) layouts', () => {
143
- const schedule = createMockSchedule({
144
- layouts: [
145
- { file: 'low.xlf', priority: 5, maxPlaysPerHour: 0,
146
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
147
- { file: 'high.xlf', priority: 10, maxPlaysPerHour: 0,
148
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
149
- ],
87
+ it('should start from the given queue position', () => {
88
+ const queue = [
89
+ { layoutId: '100.xlf', duration: 30 },
90
+ { layoutId: '200.xlf', duration: 30 },
91
+ { layoutId: '300.xlf', duration: 30 },
92
+ ];
93
+
94
+ const timeline = calculateTimeline(queue, 2, {
95
+ from: NOW, hours: 0.1,
150
96
  });
151
- const durations = new Map([['low.xlf', 30], ['high.xlf', 30]]);
152
97
 
153
- const timeline = calculateTimeline(schedule, durations, {
98
+ expect(timeline[0].layoutFile).toBe('300.xlf');
99
+ expect(timeline[1].layoutFile).toBe('100.xlf');
100
+ expect(timeline[2].layoutFile).toBe('200.xlf');
101
+ });
102
+
103
+ it('should wrap queue position when past end', () => {
104
+ const queue = [
105
+ { layoutId: '100.xlf', duration: 30 },
106
+ { layoutId: '200.xlf', duration: 30 },
107
+ ];
108
+
109
+ const timeline = calculateTimeline(queue, 5, {
154
110
  from: NOW, hours: 0.1,
155
111
  });
156
112
 
157
- // First entry should have hidden layouts
158
- expect(timeline[0].hidden).toBeDefined();
159
- expect(timeline[0].hidden).toEqual(
160
- expect.arrayContaining([expect.objectContaining({ file: 'low.xlf' })])
161
- );
113
+ // 5 % 2 = 1, so starts at 200.xlf
114
+ expect(timeline[0].layoutFile).toBe('200.xlf');
115
+ expect(timeline[1].layoutFile).toBe('100.xlf');
162
116
  });
163
117
  });
164
118
 
165
- describe('Rate limiting (maxPlaysPerHour)', () => {
166
- it('should respect maxPlaysPerHour by falling back to lower priority', () => {
167
- const schedule = createMockSchedule({
168
- layouts: [
169
- { file: 'limited.xlf', priority: 10, maxPlaysPerHour: 2,
170
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
171
- { file: 'filler.xlf', priority: 5, maxPlaysPerHour: 0,
172
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
173
- ],
174
- });
175
- const durations = new Map([['limited.xlf', 30], ['filler.xlf', 30]]);
119
+ describe('currentLayoutStartedAt (remaining time adjustment)', () => {
120
+ it('should adjust first entry duration to remaining time', () => {
121
+ const queue = [{ layoutId: '100.xlf', duration: 60 }];
176
122
 
177
- const timeline = calculateTimeline(schedule, durations, {
178
- from: NOW, hours: 1,
179
- });
123
+ // Layout started 20 seconds ago → 40 seconds remaining
124
+ const startedAt = new Date(NOW.getTime() - 20000);
180
125
 
181
- // limited.xlf should appear at most 2 times in the hour
182
- const limitedPlays = timeline.filter(e => e.layoutFile === 'limited.xlf');
183
- expect(limitedPlays.length).toBeLessThanOrEqual(2);
126
+ const timeline = calculateTimeline(queue, 0, {
127
+ from: NOW, hours: 0.5,
128
+ currentLayoutStartedAt: startedAt,
129
+ });
184
130
 
185
- // filler.xlf should fill the gaps
186
- const fillerPlays = timeline.filter(e => e.layoutFile === 'filler.xlf');
187
- expect(fillerPlays.length).toBeGreaterThan(0);
131
+ expect(timeline[0].duration).toBe(40); // 60 - 20 = 40
188
132
  });
189
133
 
190
- it('should fall back to default when all layouts are rate-limited', () => {
191
- const schedule = createMockSchedule({
192
- layouts: [
193
- { file: 'limited.xlf', priority: 10, maxPlaysPerHour: 1,
194
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
195
- ],
196
- defaultLayout: 'default.xlf',
197
- });
198
- const durations = new Map([['limited.xlf', 30], ['default.xlf', 30]]);
134
+ it('should clamp remaining time to at least 1 second', () => {
135
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
199
136
 
200
- const timeline = calculateTimeline(schedule, durations, {
201
- from: NOW, hours: 1,
202
- });
137
+ // Layout started 60 seconds ago but duration is only 30 → already overdue
138
+ const startedAt = new Date(NOW.getTime() - 60000);
203
139
 
204
- // Should have both limited and default layouts
205
- const files = [...new Set(timeline.map(e => e.layoutFile))];
206
- expect(files).toContain('limited.xlf');
207
- expect(files).toContain('default.xlf');
140
+ const timeline = calculateTimeline(queue, 0, {
141
+ from: NOW, hours: 0.1,
142
+ currentLayoutStartedAt: startedAt,
143
+ });
208
144
 
209
- // limited.xlf at most once per hour
210
- const limitedPlays = timeline.filter(e => e.layoutFile === 'limited.xlf');
211
- expect(limitedPlays.length).toBeLessThanOrEqual(1);
145
+ expect(timeline[0].duration).toBeGreaterThanOrEqual(1);
212
146
  });
213
- });
214
147
 
215
- describe('Time boundaries', () => {
216
- it('should stop at schedule end time', () => {
217
- const schedule = createMockSchedule({
218
- layouts: [{
219
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
220
- fromdt: '2026-03-03T10:00:00Z', todt: '2026-03-03T10:30:00Z',
221
- }],
222
- defaultLayout: 'default.xlf',
223
- });
224
- const durations = new Map([['100.xlf', 60], ['default.xlf', 60]]);
148
+ it('should only adjust first entry, not subsequent', () => {
149
+ const queue = [{ layoutId: '100.xlf', duration: 60 }];
150
+ const startedAt = new Date(NOW.getTime() - 20000);
225
151
 
226
- const timeline = calculateTimeline(schedule, durations, {
227
- from: NOW, hours: 1,
152
+ const timeline = calculateTimeline(queue, 0, {
153
+ from: NOW, hours: 0.1,
154
+ currentLayoutStartedAt: startedAt,
228
155
  });
229
156
 
230
- // After 10:30, should switch to default
231
- const afterEnd = timeline.filter(e =>
232
- e.startTime >= new Date('2026-03-03T10:30:00Z')
233
- );
234
- for (const entry of afterEnd) {
235
- expect(entry.layoutFile).toBe('default.xlf');
236
- }
157
+ expect(timeline[0].duration).toBe(40);
158
+ expect(timeline[1].duration).toBe(60); // Full duration
237
159
  });
160
+ });
238
161
 
162
+ describe('Time boundaries', () => {
239
163
  it('should not produce entries beyond the simulation window', () => {
240
- const schedule = createMockSchedule({
241
- layouts: [{
242
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
243
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T14:00:00Z',
244
- }],
245
- });
246
- const durations = new Map([['100.xlf', 30]]);
164
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
247
165
 
248
- const timeline = calculateTimeline(schedule, durations, {
166
+ const timeline = calculateTimeline(queue, 0, {
249
167
  from: NOW, hours: 1,
250
168
  });
251
169
 
@@ -254,64 +172,42 @@ describe('calculateTimeline', () => {
254
172
  expect(entry.startTime.getTime()).toBeLessThan(endOfWindow.getTime());
255
173
  }
256
174
  });
257
- });
258
175
 
259
- describe('currentLayoutStartedAt (remaining time adjustment)', () => {
260
- it('should adjust first entry duration to remaining time', () => {
261
- const schedule = createMockSchedule({
262
- layouts: [{
263
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
264
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
265
- }],
266
- });
267
- const durations = new Map([['100.xlf', 60]]);
268
-
269
- // Layout started 20 seconds ago → 40 seconds remaining
270
- const startedAt = new Date(NOW.getTime() - 20000);
176
+ it('should produce continuous timeline (no gaps between entries)', () => {
177
+ const queue = [
178
+ { layoutId: '100.xlf', duration: 30 },
179
+ { layoutId: '200.xlf', duration: 45 },
180
+ ];
271
181
 
272
- const timeline = calculateTimeline(schedule, durations, {
182
+ const timeline = calculateTimeline(queue, 0, {
273
183
  from: NOW, hours: 0.5,
274
- currentLayoutStartedAt: startedAt,
275
184
  });
276
185
 
277
- expect(timeline[0].duration).toBe(40); // 60 - 20 = 40
186
+ for (let i = 1; i < timeline.length; i++) {
187
+ expect(timeline[i].startTime.getTime()).toBe(timeline[i - 1].endTime.getTime());
188
+ }
278
189
  });
279
190
 
280
- it('should clamp remaining time to at least 1 second', () => {
281
- const schedule = createMockSchedule({
282
- layouts: [{
283
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
284
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
285
- }],
286
- });
287
- const durations = new Map([['100.xlf', 30]]);
288
-
289
- // Layout started 60 seconds ago but duration is only 30 → already overdue
290
- const startedAt = new Date(NOW.getTime() - 60000);
191
+ it('should handle a large number of entries without exceeding 500 cap', () => {
192
+ const queue = [{ layoutId: '100.xlf', duration: 5 }]; // 5s = many entries
291
193
 
292
- const timeline = calculateTimeline(schedule, durations, {
293
- from: NOW, hours: 0.1,
294
- currentLayoutStartedAt: startedAt,
194
+ const timeline = calculateTimeline(queue, 0, {
195
+ from: NOW, hours: 2,
295
196
  });
296
197
 
297
- expect(timeline[0].duration).toBeGreaterThanOrEqual(1);
198
+ expect(timeline.length).toBeLessThanOrEqual(500);
298
199
  });
299
200
  });
300
201
 
301
202
  describe('Determinism', () => {
302
203
  it('should produce identical output for identical inputs', () => {
303
- const schedule = createMockSchedule({
304
- layouts: [
305
- { file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
306
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
307
- { file: '200.xlf', priority: 10, maxPlaysPerHour: 0,
308
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z' },
309
- ],
310
- });
311
- const durations = new Map([['100.xlf', 30], ['200.xlf', 45]]);
204
+ const queue = [
205
+ { layoutId: '100.xlf', duration: 30 },
206
+ { layoutId: '200.xlf', duration: 45 },
207
+ ];
312
208
 
313
- const t1 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
314
- const t2 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
209
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
210
+ const t2 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
315
211
 
316
212
  expect(t1.length).toBe(t2.length);
317
213
  for (let i = 0; i < t1.length; i++) {
@@ -323,67 +219,54 @@ describe('calculateTimeline', () => {
323
219
  });
324
220
 
325
221
  it('should produce DIFFERENT output when "from" time changes', () => {
326
- const schedule = createMockSchedule({
327
- layouts: [{
328
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
329
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
330
- }],
331
- });
332
- const durations = new Map([['100.xlf', 30]]);
222
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
333
223
 
334
- const t1 = calculateTimeline(schedule, durations, { from: NOW, hours: 1 });
224
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 1 });
335
225
  const laterNow = new Date(NOW.getTime() + 300000); // 5 min later
336
- const t2 = calculateTimeline(schedule, durations, { from: laterNow, hours: 1 });
226
+ const t2 = calculateTimeline(queue, 0, { from: laterNow, hours: 1 });
337
227
 
338
228
  // Start times must differ because the anchor moved
339
229
  expect(t1[0].startTime.getTime()).not.toBe(t2[0].startTime.getTime());
340
230
  });
341
- });
342
231
 
343
- describe('Edge cases', () => {
344
- it('should return empty array when no layouts and no default', () => {
345
- const schedule = createMockSchedule({ layouts: [], defaultLayout: null });
346
- const durations = new Map();
232
+ it('should produce DIFFERENT output when position changes', () => {
233
+ const queue = [
234
+ { layoutId: '100.xlf', duration: 30 },
235
+ { layoutId: '200.xlf', duration: 30 },
236
+ ];
347
237
 
348
- const timeline = calculateTimeline(schedule, durations, {
349
- from: NOW, hours: 1,
350
- });
238
+ const t1 = calculateTimeline(queue, 0, { from: NOW, hours: 0.1 });
239
+ const t2 = calculateTimeline(queue, 1, { from: NOW, hours: 0.1 });
351
240
 
352
- expect(timeline).toEqual([]);
241
+ expect(t1[0].layoutFile).toBe('100.xlf');
242
+ expect(t2[0].layoutFile).toBe('200.xlf');
353
243
  });
244
+ });
354
245
 
355
- it('should handle a large number of entries without exceeding 500 cap', () => {
356
- const schedule = createMockSchedule({
357
- layouts: [{
358
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
359
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T20:00:00Z',
360
- }],
361
- });
362
- const durations = new Map([['100.xlf', 5]]); // 5s = many entries
363
-
364
- const timeline = calculateTimeline(schedule, durations, {
365
- from: NOW, hours: 2,
366
- });
246
+ describe('Edge cases', () => {
247
+ it('should return empty array for empty queue', () => {
248
+ const timeline = calculateTimeline([], 0, { from: NOW, hours: 1 });
249
+ expect(timeline).toEqual([]);
250
+ });
367
251
 
368
- expect(timeline.length).toBeLessThanOrEqual(500);
252
+ it('should return empty array for null queue', () => {
253
+ const timeline = calculateTimeline(null, 0, { from: NOW, hours: 1 });
254
+ expect(timeline).toEqual([]);
369
255
  });
370
256
 
371
- it('should produce continuous timeline (no gaps between entries)', () => {
372
- const schedule = createMockSchedule({
373
- layouts: [{
374
- file: '100.xlf', priority: 10, maxPlaysPerHour: 0,
375
- fromdt: '2026-03-03T09:00:00Z', todt: '2026-03-03T12:00:00Z',
376
- }],
377
- });
378
- const durations = new Map([['100.xlf', 30]]);
257
+ it('should handle mixed default and scheduled entries in queue', () => {
258
+ const queue = [
259
+ { layoutId: '100.xlf', duration: 30 },
260
+ { layoutId: 'default.xlf', duration: 60 },
261
+ ];
379
262
 
380
- const timeline = calculateTimeline(schedule, durations, {
381
- from: NOW, hours: 0.5,
263
+ const timeline = calculateTimeline(queue, 0, {
264
+ from: NOW, hours: 0.1,
265
+ defaultLayout: 'default.xlf',
382
266
  });
383
267
 
384
- for (let i = 1; i < timeline.length; i++) {
385
- expect(timeline[i].startTime.getTime()).toBe(timeline[i - 1].endTime.getTime());
386
- }
268
+ expect(timeline[0].isDefault).toBe(false);
269
+ expect(timeline[1].isDefault).toBe(true);
387
270
  });
388
271
  });
389
272
  });
@@ -468,6 +351,41 @@ describe('parseLayoutDuration', () => {
468
351
  });
469
352
  });
470
353
 
354
+ describe('Canvas region duration (#186)', () => {
355
+ it('should use max widget duration for canvas regions (not sum)', () => {
356
+ const result = parseLayoutDuration(xlf({
357
+ regions: [
358
+ { type: 'canvas', widgets: [
359
+ { duration: 10, useDuration: 1 },
360
+ { duration: 30, useDuration: 1 },
361
+ { duration: 20, useDuration: 1 },
362
+ ]},
363
+ ],
364
+ }));
365
+ // Canvas: max(10, 30, 20) = 30, not sum(10+30+20) = 60
366
+ expect(result).toEqual({ duration: 30, isDynamic: false });
367
+ });
368
+
369
+ it('should use sum for normal regions alongside canvas', () => {
370
+ const result = parseLayoutDuration(xlf({
371
+ regions: [
372
+ { type: 'canvas', widgets: [
373
+ { duration: 10, useDuration: 1 },
374
+ { duration: 20, useDuration: 1 },
375
+ ]},
376
+ { widgets: [
377
+ { duration: 15, useDuration: 1 },
378
+ { duration: 25, useDuration: 1 },
379
+ ]},
380
+ ],
381
+ }));
382
+ // Canvas region: max(10, 20) = 20
383
+ // Normal region: sum(15+25) = 40
384
+ // Layout: max(20, 40) = 40
385
+ expect(result).toEqual({ duration: 40, isDynamic: false });
386
+ });
387
+ });
388
+
471
389
  describe('videoDurations (Phase 2 probing)', () => {
472
390
  it('should use probed duration when fileId matches', () => {
473
391
  const videoDurations = new Map([['vid1', 45]]);