@xiboplayer/schedule 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,59 @@
1
1
  # @xiboplayer/schedule
2
2
 
3
- **Campaign scheduling with dayparting, interrupts, overlays, and timeline prediction.**
3
+ **Complete scheduling solution: campaigns, dayparting, interrupts, overlays, and timeline prediction.**
4
4
 
5
5
  ## Overview
6
6
 
7
- Complete scheduling solution for Xibo digital signage:
7
+ Manages all aspects of digital signage scheduling to determine which layouts play when:
8
8
 
9
- - **Campaign scheduling** priority-based campaign rotation with configurable play counts
10
- - **Dayparting** weekly time slots with midnight-crossing support
11
- - **Interrupts** percentage-based share-of-voice scheduling with even interleaving across the hour
12
- - **Overlays** multiple simultaneous overlay layouts with independent scheduling and priority
13
- - **Geo-fencing** location-based schedule filtering with criteria evaluation
14
- - **Timeline prediction** deterministic future schedule simulation for proactive content preloading
9
+ - **Campaign scheduling** -- groups of layouts with time windows and priorities
10
+ - **Dayparting** -- weekly time slots (Mon-Fri 09:00-17:00, evenings, weekends) with midnight-crossing support
11
+ - **Priority fallback** -- higher-priority layouts hide lower-priority ones; rate limiting triggers automatic fallback
12
+ - **Rate limiting** -- `maxPlaysPerHour` with even distribution (prevents bursts, ensures spacing)
13
+ - **Interrupts (Share of Voice)** -- layouts that must play X% of each hour, interleaved with normal content
14
+ - **Overlays** -- layouts that appear on top of main layouts without interrupting playback
15
+ - **Criteria evaluation** -- conditional display based on time, weather, custom display properties
16
+ - **Geo-fencing** -- location-based filtering (point + radius, Haversine distance)
17
+ - **Timeline prediction** -- deterministic simulation of future playback for UI overlays
18
+ - **Default layout** -- fallback when no campaigns are active
19
+
20
+ ## Architecture
21
+
22
+ ```
23
+ CMS Schedule XML
24
+ |
25
+ v
26
+ +-------------------------------------+
27
+ | Schedule Parser |
28
+ | +- campaigns[] |
29
+ | +- layouts[] |
30
+ | +- overlays[] |
31
+ | +- default layout |
32
+ +-------------------------------------+
33
+ |
34
+ v
35
+ +-------------------------------------+
36
+ | Evaluation Engine |
37
+ | +- Recurrence (Week/Day/Month) |
38
+ | +- Time windows (dayparting) |
39
+ | +- Criteria (weather, properties) |
40
+ | +- Geo-fencing |
41
+ | +- Priority + rate-limit filtering |
42
+ +-------------------------------------+
43
+ |
44
+ v
45
+ +-------------------------------------+
46
+ | Schedule Queue Builder (LCM-based) |
47
+ | Deterministic round-robin with: |
48
+ | +- Rate-limited slots (even spaced)|
49
+ | +- Priority fallback |
50
+ | +- Default fills gaps |
51
+ +-------------------------------------+
52
+ |
53
+ +-> getCurrentLayouts() -> Renderer
54
+ +-> getLayoutsInTimeRange() -> Timeline Overlay
55
+ +-> Track play history -> Rate limiting
56
+ ```
15
57
 
16
58
  ## Installation
17
59
 
@@ -21,19 +63,245 @@ npm install @xiboplayer/schedule
21
63
 
22
64
  ## Usage
23
65
 
66
+ ### Basic scheduling
67
+
68
+ ```javascript
69
+ import { ScheduleManager } from '@xiboplayer/schedule';
70
+
71
+ const schedule = new ScheduleManager();
72
+
73
+ schedule.setSchedule({
74
+ campaigns: [
75
+ {
76
+ id: 1,
77
+ priority: 100,
78
+ fromdt: '2025-01-01 09:00',
79
+ todt: '2025-12-31 17:00',
80
+ recurrenceType: 'Week',
81
+ recurrenceRepeatsOn: '1,2,3,4,5', // Mon-Fri
82
+ layouts: [
83
+ { id: 10, file: '10.xlf', duration: 30 },
84
+ { id: 11, file: '11.xlf', duration: 30 },
85
+ ],
86
+ },
87
+ ],
88
+ default: '99.xlf',
89
+ });
90
+
91
+ const layoutsToPlay = schedule.getCurrentLayouts();
92
+ // Business hours: ['10.xlf', '11.xlf']
93
+ // After hours: ['99.xlf'] (default)
94
+ ```
95
+
96
+ ### Dayparting with midnight crossing
97
+
24
98
  ```javascript
25
- import { Schedule } from '@xiboplayer/schedule';
99
+ schedule.setSchedule({
100
+ layouts: [
101
+ {
102
+ id: 1,
103
+ file: '1.xlf',
104
+ recurrenceType: 'Week',
105
+ recurrenceRepeatsOn: '1,2,3,4,5,6,7',
106
+ fromdt: '1970-01-01 22:00:00', // 10 PM
107
+ todt: '1970-01-01 02:00:00', // 2 AM (next day)
108
+ },
109
+ ],
110
+ });
111
+
112
+ // Friday 23:00: returns ['1.xlf']
113
+ // Saturday 01:00: returns ['1.xlf'] (midnight crossing works)
114
+ ```
26
115
 
27
- const schedule = new Schedule();
28
- schedule.update(scheduleXml);
116
+ ### Rate limiting with even distribution
29
117
 
30
- const currentLayouts = schedule.getCurrentLayouts();
31
- const timeline = schedule.getTimeline(now, now + 3600000); // next hour
118
+ ```javascript
119
+ schedule.setSchedule({
120
+ layouts: [
121
+ {
122
+ id: 1,
123
+ file: '1.xlf',
124
+ maxPlaysPerHour: 3, // 3 times per hour, evenly spaced
125
+ },
126
+ ],
127
+ });
128
+
129
+ schedule.recordPlay('1'); // Play at 09:00
130
+ // Can't play again until 09:20 (60 / 3 = 20 min minimum gap)
131
+ ```
132
+
133
+ ### Interrupts (Share of Voice)
134
+
135
+ ```javascript
136
+ schedule.setSchedule({
137
+ layouts: [
138
+ { id: 1, file: '1.xlf', duration: 30 },
139
+ { id: 2, file: '2.xlf', duration: 30, shareOfVoice: 20 }, // 20% of each hour
140
+ ],
141
+ });
142
+
143
+ const layouts = schedule.getCurrentLayouts();
144
+ // Interleaved: normal, normal, interrupt, normal, normal, interrupt, ...
145
+ ```
146
+
147
+ ### Criteria-based display
148
+
149
+ ```javascript
150
+ schedule.setSchedule({
151
+ layouts: [
152
+ {
153
+ id: 1,
154
+ file: '1.xlf',
155
+ criteria: [
156
+ { metric: 'weatherTemp', condition: 'greaterThan', value: '25', type: 'number' },
157
+ ],
158
+ },
159
+ ],
160
+ });
161
+
162
+ schedule.setWeatherData({ temperature: 28, humidity: 65 });
163
+ // Layout 1 displays only when temperature > 25
164
+ ```
165
+
166
+ ### Geo-fencing
167
+
168
+ ```javascript
169
+ schedule.setLocation(37.7749, -122.4194);
170
+
171
+ schedule.setSchedule({
172
+ layouts: [
173
+ {
174
+ id: 1,
175
+ file: '1.xlf',
176
+ isGeoAware: true,
177
+ geoLocation: '37.7749,-122.4194,500', // lat,lng,radius_meters
178
+ },
179
+ ],
180
+ });
181
+
182
+ // Returns ['1.xlf'] only if player is within 500m
183
+ ```
184
+
185
+ ### Timeline prediction
186
+
187
+ ```javascript
188
+ const timeline = calculateTimeline(queue, queuePosition, {
189
+ from: new Date(),
190
+ hours: 2,
191
+ defaultLayout: schedule.schedule.default,
192
+ durations: durations,
193
+ });
194
+
195
+ // Returns:
196
+ // [
197
+ // { layoutFile: '10.xlf', startTime, endTime, duration: 30, isDefault: false },
198
+ // { layoutFile: '11.xlf', startTime, endTime, duration: 30, isDefault: false },
199
+ // ...
200
+ // ]
201
+ ```
202
+
203
+ ## Campaign Evaluation Algorithm
204
+
205
+ When `getCurrentLayouts()` is called:
206
+
207
+ 1. **Filter time-active items** -- campaigns and standalone layouts within their date/time window and recurrence rules
208
+ 2. **Apply criteria** -- filter by weather, display properties, geo-fencing
209
+ 3. **Apply rate limiting** -- exclude layouts that exceeded `maxPlaysPerHour`
210
+ 4. **Find max priority** -- only max priority items win
211
+ 5. **Extract layouts** -- campaigns return all their layouts; standalone layouts contribute themselves
212
+ 6. **Process interrupts** -- separate interrupt layouts, calculate share-of-voice, interleave
213
+ 7. **Return layout files** -- ready for the renderer
214
+
215
+ ## Key Concepts
216
+
217
+ ### Schedule Queue (LCM-based)
218
+
219
+ The queue is a pre-computed, deterministic round-robin cycle:
220
+
221
+ - **LCM period** -- Least Common Multiple of all `maxPlaysPerHour` intervals (capped at 2 hours)
222
+ - **Simulation** -- walks the period applying priority and rate-limit rules at each step
223
+ - **Caching** -- reused until the active layout set changes
224
+ - **Predictable** -- answers "what's playing in 30 minutes?" offline
225
+
226
+ ### Dayparting
227
+
228
+ | Type | Pattern | Example |
229
+ |------|---------|---------|
230
+ | Week | Specific days + time-of-day | Mon-Fri 09:00-17:00 |
231
+ | Day | Daily with optional interval | Every 2 days |
232
+ | Month | Specific days of month | 1st, 15th (monthly) |
233
+
234
+ Midnight crossing: `22:00 - 02:00` works across day boundaries.
235
+
236
+ ### Criteria Evaluation
237
+
238
+ **Built-in metrics:** `dayOfWeek`, `dayOfMonth`, `month`, `hour`, `isoDay`
239
+
240
+ **Weather metrics:** `weatherTemp`, `weatherHumidity`, `weatherWindSpeed`, `weatherCondition`, `weatherCloudCover`
241
+
242
+ **Operators:** `equals`, `notEquals`, `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals`, `contains`, `startsWith`, `endsWith`, `in`
243
+
244
+ ### Geo-fencing
245
+
246
+ - Format: `"lat,lng,radius"` (e.g., `"37.7749,-122.4194,500"`)
247
+ - Default radius: 500 meters
248
+ - Calculation: Haversine formula (great-circle distance)
249
+ - Permissive: if no location available, layout displays (fail-open for offline)
250
+
251
+ ## API Reference
252
+
253
+ ### Constructor
254
+
255
+ ```javascript
256
+ new ScheduleManager(options?)
257
+ ```
258
+
259
+ | Option | Type | Description |
260
+ |--------|------|-------------|
261
+ | `interruptScheduler` | InterruptScheduler? | Optional interrupt handler |
262
+ | `displayProperties` | Object? | Custom display fields from CMS |
263
+
264
+ ### Methods
265
+
266
+ | Method | Returns | Description |
267
+ |--------|---------|-------------|
268
+ | `setSchedule(schedule)` | void | Load schedule from XMDS response |
269
+ | `getCurrentLayouts()` | string[] | Layouts active now |
270
+ | `getLayoutsAtTime(date)` | string[] | Layouts at specific time |
271
+ | `getAllLayoutsAtTime(date)` | Array | All time-active layouts with metadata |
272
+ | `getScheduleQueue(durations)` | {queue, periodSeconds} | Pre-computed round-robin queue |
273
+ | `popNextFromQueue(durations)` | {layoutId, duration} | Pop next entry, advance position |
274
+ | `peekNextInQueue(durations)` | {layoutId, duration} | Peek without advancing |
275
+ | `recordPlay(layoutId)` | void | Track a play for rate limiting |
276
+ | `canPlayLayout(layoutId, max)` | boolean | Check if layout can play now |
277
+ | `setWeatherData(data)` | void | Update weather for criteria |
278
+ | `setLocation(lat, lng)` | void | Set GPS location for geo-fencing |
279
+ | `setDisplayProperties(props)` | void | Set custom display fields |
280
+ | `detectConflicts(options)` | Array | Find priority-shadowing conflicts |
281
+
282
+ ### Overlay Methods
283
+
284
+ | Method | Returns | Description |
285
+ |--------|---------|-------------|
286
+ | `setOverlays(overlays)` | void | Update overlay list |
287
+ | `getCurrentOverlays()` | Array | Active overlays (sorted by priority) |
288
+ | `shouldCheckOverlays(lastCheck)` | boolean | Check interval (every 60s) |
289
+
290
+ ### Timeline Functions
291
+
292
+ ```javascript
293
+ import { calculateTimeline, parseLayoutDuration, buildScheduleQueue } from '@xiboplayer/schedule';
294
+
295
+ const { duration } = parseLayoutDuration(xlfXml, videoDurations?);
296
+ const { queue, periodSeconds } = buildScheduleQueue(allLayouts, durations);
297
+ const timeline = calculateTimeline(queue, position, { from, hours, defaultLayout, durations });
32
298
  ```
33
299
 
34
300
  ## Dependencies
35
301
 
36
- - `@xiboplayer/utils` logger, events
302
+ No external dependencies -- fully self-contained scheduling engine.
303
+
304
+ - `@xiboplayer/utils` -- logging only
37
305
 
38
306
  ---
39
307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/schedule",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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.4"
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,62 @@ 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);
214
- }
215
-
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
- }
234
-
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
- }
247
-
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
- }
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
+ // Note: queuePosition has already advanced past the current layout
191
+ // (via popNextFromQueue), so the first entry here is the NEXT layout.
192
+ // No elapsed-time adjustment needed — the overlay handles countdown
193
+ // for the current layout via wall-clock layoutStartedAt.
194
+ isFirstEntry = false;
195
+
196
+ const endMs = currentTime.getTime() + dur * 1000;
197
+
198
+ timeline.push({
199
+ layoutFile: entry.layoutId,
200
+ startTime: new Date(currentTime),
201
+ endTime: new Date(endMs),
202
+ duration: dur,
203
+ isDefault: defaultLayout ? entry.layoutId === defaultLayout : false,
204
+ });
205
+
206
+ currentTime = new Date(endMs);
207
+ pos = (pos + 1) % queue.length;
282
208
  }
283
209
 
284
210
  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
- });
98
-
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]]);
44
+ it('should cycle through multiple queue entries', () => {
45
+ const queue = [
46
+ { layoutId: '100.xlf', duration: 30 },
47
+ { layoutId: '200.xlf', duration: 45 },
48
+ ];
109
49
 
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 (no adjustment — queuePosition already advanced)', () => {
120
+ it('should not adjust first entry duration since queue has advanced past current layout', () => {
121
+ const queue = [{ layoutId: '100.xlf', duration: 60 }];
176
122
 
177
- const timeline = calculateTimeline(schedule, durations, {
178
- from: NOW, hours: 1,
179
- });
123
+ // currentLayoutStartedAt is ignored — the first timeline entry is the
124
+ // NEXT layout (queuePosition already advanced via popNextFromQueue)
125
+ const startedAt = new Date(NOW.getTime() - 20000);
180
126
 
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);
127
+ const timeline = calculateTimeline(queue, 0, {
128
+ from: NOW, hours: 0.5,
129
+ currentLayoutStartedAt: startedAt,
130
+ });
184
131
 
185
- // filler.xlf should fill the gaps
186
- const fillerPlays = timeline.filter(e => e.layoutFile === 'filler.xlf');
187
- expect(fillerPlays.length).toBeGreaterThan(0);
132
+ expect(timeline[0].duration).toBe(60); // Full duration, no adjustment
188
133
  });
189
134
 
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]]);
135
+ it('should use full duration even when layout would be overdue', () => {
136
+ const queue = [{ layoutId: '100.xlf', duration: 30 }];
199
137
 
200
- const timeline = calculateTimeline(schedule, durations, {
201
- from: NOW, hours: 1,
202
- });
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).toBe(30); // Full duration
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 use full duration for all entries', () => {
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(60); // Full duration
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]]);