@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 +282 -14
- package/package.json +2 -2
- package/src/index.d.ts +1 -1
- package/src/schedule.dayparting.test.js +3 -12
- package/src/timeline.js +53 -127
- package/src/timeline.test.js +180 -262
package/README.md
CHANGED
|
@@ -1,17 +1,59 @@
|
|
|
1
1
|
# @xiboplayer/schedule
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Complete scheduling solution: campaigns, dayparting, interrupts, overlays, and timeline prediction.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Manages all aspects of digital signage scheduling to determine which layouts play when:
|
|
8
8
|
|
|
9
|
-
- **Campaign scheduling**
|
|
10
|
-
- **Dayparting**
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
schedule.update(scheduleXml);
|
|
116
|
+
### Rate limiting with even distribution
|
|
29
117
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
39
|
-
vi.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
widgetDuration = probed; // Phase 2: probed video duration
|
|
51
54
|
} else if (dur > 0 && useDuration !== 0) {
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
164
|
-
*
|
|
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 {
|
|
167
|
-
* @param {
|
|
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
|
|
171
|
-
* @param {
|
|
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(
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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;
|
package/src/timeline.test.js
CHANGED
|
@@ -1,60 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Timeline Calculator Tests
|
|
3
3
|
*
|
|
4
|
-
* Tests for calculateTimeline() —
|
|
5
|
-
*
|
|
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
|
|
47
|
-
it('should produce entries
|
|
48
|
-
const
|
|
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(
|
|
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
|
|
67
|
-
const
|
|
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(
|
|
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
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
158
|
-
expect(timeline[0].
|
|
159
|
-
expect(timeline[
|
|
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('
|
|
166
|
-
it('should
|
|
167
|
-
const
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
127
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
128
|
+
from: NOW, hours: 0.5,
|
|
129
|
+
currentLayoutStartedAt: startedAt,
|
|
130
|
+
});
|
|
184
131
|
|
|
185
|
-
//
|
|
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
|
|
191
|
-
const
|
|
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
|
|
201
|
-
from: NOW, hours: 1,
|
|
202
|
-
});
|
|
138
|
+
const startedAt = new Date(NOW.getTime() - 60000);
|
|
203
139
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
140
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
141
|
+
from: NOW, hours: 0.1,
|
|
142
|
+
currentLayoutStartedAt: startedAt,
|
|
143
|
+
});
|
|
208
144
|
|
|
209
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
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(
|
|
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
|
-
//
|
|
231
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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(
|
|
182
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
273
183
|
from: NOW, hours: 0.5,
|
|
274
|
-
currentLayoutStartedAt: startedAt,
|
|
275
184
|
});
|
|
276
185
|
|
|
277
|
-
|
|
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
|
|
281
|
-
const
|
|
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(
|
|
293
|
-
from: NOW, hours:
|
|
294
|
-
currentLayoutStartedAt: startedAt,
|
|
194
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
195
|
+
from: NOW, hours: 2,
|
|
295
196
|
});
|
|
296
197
|
|
|
297
|
-
expect(timeline
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
314
|
-
const t2 = calculateTimeline(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
349
|
-
|
|
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(
|
|
241
|
+
expect(t1[0].layoutFile).toBe('100.xlf');
|
|
242
|
+
expect(t2[0].layoutFile).toBe('200.xlf');
|
|
353
243
|
});
|
|
244
|
+
});
|
|
354
245
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(
|
|
381
|
-
from: NOW, hours: 0.
|
|
263
|
+
const timeline = calculateTimeline(queue, 0, {
|
|
264
|
+
from: NOW, hours: 0.1,
|
|
265
|
+
defaultLayout: 'default.xlf',
|
|
382
266
|
});
|
|
383
267
|
|
|
384
|
-
|
|
385
|
-
|
|
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]]);
|