@xiboplayer/core 0.1.0
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/CAMPAIGNS.md +254 -0
- package/README.md +163 -0
- package/TESTING_STATUS.md +281 -0
- package/TEST_STANDARDIZATION_COMPLETE.md +287 -0
- package/docs/ARCHITECTURE.md +714 -0
- package/docs/README.md +92 -0
- package/examples/dayparting-schedule-example.json +190 -0
- package/index.html +262 -0
- package/package.json +53 -0
- package/proxy.js +72 -0
- package/public/manifest.json +22 -0
- package/public/sw.js +218 -0
- package/setup.html +220 -0
- package/src/data-connectors.js +198 -0
- package/src/index.js +4 -0
- package/src/main.js +580 -0
- package/src/player-core.js +1120 -0
- package/src/player-core.test.js +1796 -0
- package/src/state.js +54 -0
- package/src/state.test.js +206 -0
- package/src/test-utils.js +217 -0
- package/src/xmds-test.html +109 -0
- package/src/xmds.test.js +516 -0
- package/vite.config.js +51 -0
- package/vitest.config.js +35 -0
|
@@ -0,0 +1,1796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlayerCore Tests
|
|
3
|
+
*
|
|
4
|
+
* Contract-based testing for PlayerCore orchestration module
|
|
5
|
+
* Tests collection cycle, layout transitions, XMR integration, and event emission
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import { PlayerCore } from './player-core.js';
|
|
10
|
+
import { createSpy } from './test-utils.js';
|
|
11
|
+
|
|
12
|
+
describe('PlayerCore', () => {
|
|
13
|
+
let core;
|
|
14
|
+
let mockConfig;
|
|
15
|
+
let mockXmds;
|
|
16
|
+
let mockCache;
|
|
17
|
+
let mockSchedule;
|
|
18
|
+
let mockRenderer;
|
|
19
|
+
let mockXmrWrapper;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Mock dependencies
|
|
23
|
+
mockConfig = {
|
|
24
|
+
cmsAddress: 'https://test.cms.com',
|
|
25
|
+
hardwareKey: 'test-hw-key',
|
|
26
|
+
serverKey: 'test-server-key'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
mockXmds = {
|
|
30
|
+
registerDisplay: vi.fn(() => Promise.resolve({
|
|
31
|
+
displayName: 'Test Display',
|
|
32
|
+
settings: {
|
|
33
|
+
collectInterval: '300',
|
|
34
|
+
xmrWebSocketAddress: 'wss://test.xmr.com',
|
|
35
|
+
xmrCmsKey: 'xmr-key'
|
|
36
|
+
}
|
|
37
|
+
})),
|
|
38
|
+
requiredFiles: vi.fn(() => Promise.resolve([
|
|
39
|
+
{ id: '1', type: 'media', path: 'http://test.com/file1.mp4' },
|
|
40
|
+
{ id: '2', type: 'layout', path: 'http://test.com/layout.xlf' }
|
|
41
|
+
])),
|
|
42
|
+
schedule: vi.fn(() => Promise.resolve({
|
|
43
|
+
default: '0',
|
|
44
|
+
layouts: [{ file: '100.xlf', priority: 10 }],
|
|
45
|
+
campaigns: []
|
|
46
|
+
})),
|
|
47
|
+
notifyStatus: vi.fn(() => Promise.resolve()),
|
|
48
|
+
mediaInventory: vi.fn(() => Promise.resolve()),
|
|
49
|
+
blackList: vi.fn(() => Promise.resolve(true))
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
mockCache = {
|
|
53
|
+
requestDownload: vi.fn(() => Promise.resolve()),
|
|
54
|
+
getFile: vi.fn(() => Promise.resolve(new Blob(['test'])))
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
mockSchedule = {
|
|
58
|
+
setSchedule: vi.fn(),
|
|
59
|
+
getCurrentLayouts: vi.fn(() => ['100.xlf']),
|
|
60
|
+
getDataConnectors: vi.fn(() => []),
|
|
61
|
+
findActionByTrigger: vi.fn(() => null)
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
mockRenderer = {
|
|
65
|
+
renderLayout: vi.fn(() => Promise.resolve()),
|
|
66
|
+
on: vi.fn(),
|
|
67
|
+
cleanup: vi.fn()
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
mockXmrWrapper = vi.fn(function() {
|
|
71
|
+
this.start = vi.fn(() => Promise.resolve());
|
|
72
|
+
this.stop = vi.fn();
|
|
73
|
+
this.isConnected = vi.fn(() => false);
|
|
74
|
+
this.reconnectAttempts = 0;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Create PlayerCore instance
|
|
78
|
+
core = new PlayerCore({
|
|
79
|
+
config: mockConfig,
|
|
80
|
+
xmds: mockXmds,
|
|
81
|
+
cache: mockCache,
|
|
82
|
+
schedule: mockSchedule,
|
|
83
|
+
renderer: mockRenderer,
|
|
84
|
+
xmrWrapper: mockXmrWrapper
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Ensure offline cache is empty so error tests don't fall into offline fallback
|
|
88
|
+
// (IndexedDB from previous runs may contain stale data)
|
|
89
|
+
core._offlineCache = { schedule: null, settings: null, requiredFiles: null };
|
|
90
|
+
core._offlineDbReady = Promise.resolve();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
core.cleanup();
|
|
95
|
+
vi.clearAllTimers();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Initialization', () => {
|
|
99
|
+
it('should create PlayerCore with dependencies', () => {
|
|
100
|
+
expect(core.config).toBe(mockConfig);
|
|
101
|
+
expect(core.xmds).toBe(mockXmds);
|
|
102
|
+
expect(core.cache).toBe(mockCache);
|
|
103
|
+
expect(core.schedule).toBe(mockSchedule);
|
|
104
|
+
expect(core.renderer).toBe(mockRenderer);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should start with null currentLayoutId', () => {
|
|
108
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should start with collecting = false', () => {
|
|
112
|
+
expect(core.isCollecting()).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should start with no pending layouts', () => {
|
|
116
|
+
expect(core.getPendingLayouts()).toHaveLength(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('Collection Cycle', () => {
|
|
121
|
+
it('should emit collection-start event', async () => {
|
|
122
|
+
const spy = createSpy();
|
|
123
|
+
core.on('collection-start', spy);
|
|
124
|
+
|
|
125
|
+
await core.collect();
|
|
126
|
+
|
|
127
|
+
expect(spy).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should register display and emit register-complete', async () => {
|
|
131
|
+
const spy = createSpy();
|
|
132
|
+
core.on('register-complete', spy);
|
|
133
|
+
|
|
134
|
+
await core.collect();
|
|
135
|
+
|
|
136
|
+
expect(mockXmds.registerDisplay).toHaveBeenCalled();
|
|
137
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
138
|
+
displayName: 'Test Display'
|
|
139
|
+
}));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should get required files and emit files-received', async () => {
|
|
143
|
+
const spy = createSpy();
|
|
144
|
+
core.on('files-received', spy);
|
|
145
|
+
|
|
146
|
+
await core.collect();
|
|
147
|
+
|
|
148
|
+
expect(mockXmds.requiredFiles).toHaveBeenCalled();
|
|
149
|
+
expect(spy).toHaveBeenCalledWith(expect.arrayContaining([
|
|
150
|
+
expect.objectContaining({ id: '1', type: 'media' })
|
|
151
|
+
]));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should emit download-request with files', async () => {
|
|
155
|
+
const spy = createSpy();
|
|
156
|
+
core.on('download-request', spy);
|
|
157
|
+
|
|
158
|
+
await core.collect();
|
|
159
|
+
|
|
160
|
+
expect(spy).toHaveBeenCalledWith(expect.arrayContaining([
|
|
161
|
+
expect.objectContaining({ id: '1', type: 'media' })
|
|
162
|
+
]));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should get schedule and emit schedule-received', async () => {
|
|
166
|
+
const spy = createSpy();
|
|
167
|
+
core.on('schedule-received', spy);
|
|
168
|
+
|
|
169
|
+
await core.collect();
|
|
170
|
+
|
|
171
|
+
expect(mockXmds.schedule).toHaveBeenCalled();
|
|
172
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
173
|
+
default: '0',
|
|
174
|
+
layouts: expect.any(Array)
|
|
175
|
+
}));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should update schedule manager with received schedule', async () => {
|
|
179
|
+
await core.collect();
|
|
180
|
+
|
|
181
|
+
expect(mockSchedule.setSchedule).toHaveBeenCalledWith(expect.objectContaining({
|
|
182
|
+
default: '0'
|
|
183
|
+
}));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should emit layouts-scheduled with current layouts', async () => {
|
|
187
|
+
const spy = createSpy();
|
|
188
|
+
core.on('layouts-scheduled', spy);
|
|
189
|
+
|
|
190
|
+
await core.collect();
|
|
191
|
+
|
|
192
|
+
expect(mockSchedule.getCurrentLayouts).toHaveBeenCalled();
|
|
193
|
+
expect(spy).toHaveBeenCalledWith(['100.xlf']);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should emit layout-prepare-request for first layout', async () => {
|
|
197
|
+
const spy = createSpy();
|
|
198
|
+
core.on('layout-prepare-request', spy);
|
|
199
|
+
|
|
200
|
+
await core.collect();
|
|
201
|
+
|
|
202
|
+
expect(spy).toHaveBeenCalledWith(100); // layoutId from 100.xlf
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should emit collection-complete when successful', async () => {
|
|
206
|
+
const spy = createSpy();
|
|
207
|
+
core.on('collection-complete', spy);
|
|
208
|
+
|
|
209
|
+
await core.collect();
|
|
210
|
+
|
|
211
|
+
expect(spy).toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should emit collection-error on failure', async () => {
|
|
215
|
+
const spy = createSpy();
|
|
216
|
+
core.on('collection-error', spy);
|
|
217
|
+
|
|
218
|
+
mockXmds.registerDisplay.mockRejectedValue(new Error('Network error'));
|
|
219
|
+
|
|
220
|
+
await expect(core.collect()).rejects.toThrow('Network error');
|
|
221
|
+
|
|
222
|
+
expect(spy).toHaveBeenCalledWith(expect.any(Error));
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('Concurrent Collection Prevention', () => {
|
|
227
|
+
it('should prevent concurrent collections', async () => {
|
|
228
|
+
const spy = createSpy();
|
|
229
|
+
core.on('collection-start', spy);
|
|
230
|
+
|
|
231
|
+
// Start first collection
|
|
232
|
+
const promise1 = core.collect();
|
|
233
|
+
|
|
234
|
+
// Try to start second collection while first is running
|
|
235
|
+
const promise2 = core.collect();
|
|
236
|
+
|
|
237
|
+
await Promise.all([promise1, promise2]);
|
|
238
|
+
|
|
239
|
+
// Should only start once
|
|
240
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should set collecting flag during collection', async () => {
|
|
244
|
+
expect(core.isCollecting()).toBe(false);
|
|
245
|
+
|
|
246
|
+
mockXmds.registerDisplay.mockImplementation(() => {
|
|
247
|
+
// Check flag during execution
|
|
248
|
+
expect(core.isCollecting()).toBe(true);
|
|
249
|
+
return Promise.resolve({ displayName: 'Test', settings: {} });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await core.collect();
|
|
253
|
+
|
|
254
|
+
// Flag cleared after completion
|
|
255
|
+
expect(core.isCollecting()).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should clear collecting flag on error', async () => {
|
|
259
|
+
mockXmds.registerDisplay.mockRejectedValue(new Error('Test error'));
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await core.collect();
|
|
263
|
+
} catch (e) {
|
|
264
|
+
// Expected
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
expect(core.isCollecting()).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('Layout Management', () => {
|
|
272
|
+
it('should skip reload if layout already playing', async () => {
|
|
273
|
+
const spy = createSpy();
|
|
274
|
+
core.on('layout-already-playing', spy);
|
|
275
|
+
|
|
276
|
+
// First collection sets currentLayoutId to 100
|
|
277
|
+
await core.collect();
|
|
278
|
+
core.setCurrentLayout(100);
|
|
279
|
+
|
|
280
|
+
// Second collection with same layout
|
|
281
|
+
await core.collect();
|
|
282
|
+
|
|
283
|
+
expect(spy).toHaveBeenCalledWith(100);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should emit no-layouts-scheduled when schedule empty', async () => {
|
|
287
|
+
const spy = createSpy();
|
|
288
|
+
core.on('no-layouts-scheduled', spy);
|
|
289
|
+
|
|
290
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
291
|
+
|
|
292
|
+
await core.collect();
|
|
293
|
+
|
|
294
|
+
expect(spy).toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should track current layout', () => {
|
|
298
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
299
|
+
|
|
300
|
+
core.setCurrentLayout(123);
|
|
301
|
+
|
|
302
|
+
expect(core.getCurrentLayoutId()).toBe(123);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should emit layout-current when layout set', () => {
|
|
306
|
+
const spy = createSpy();
|
|
307
|
+
core.on('layout-current', spy);
|
|
308
|
+
|
|
309
|
+
core.setCurrentLayout(123);
|
|
310
|
+
|
|
311
|
+
expect(spy).toHaveBeenCalledWith(123);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should clear current layout', () => {
|
|
315
|
+
core.setCurrentLayout(123);
|
|
316
|
+
expect(core.getCurrentLayoutId()).toBe(123);
|
|
317
|
+
|
|
318
|
+
core.clearCurrentLayout();
|
|
319
|
+
|
|
320
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should emit layout-cleared when layout cleared', () => {
|
|
324
|
+
const spy = createSpy();
|
|
325
|
+
core.on('layout-cleared', spy);
|
|
326
|
+
|
|
327
|
+
core.setCurrentLayout(123);
|
|
328
|
+
core.clearCurrentLayout();
|
|
329
|
+
|
|
330
|
+
expect(spy).toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('Pending Layouts', () => {
|
|
335
|
+
it('should track pending layouts', () => {
|
|
336
|
+
expect(core.getPendingLayouts()).toHaveLength(0);
|
|
337
|
+
|
|
338
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
339
|
+
|
|
340
|
+
expect(core.getPendingLayouts()).toContain(100);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should emit layout-pending when layout set as pending', () => {
|
|
344
|
+
const spy = createSpy();
|
|
345
|
+
core.on('layout-pending', spy);
|
|
346
|
+
|
|
347
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
348
|
+
|
|
349
|
+
expect(spy).toHaveBeenCalledWith(100, [1, 2, 3]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should remove pending layout when set as current', () => {
|
|
353
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
354
|
+
expect(core.getPendingLayouts()).toContain(100);
|
|
355
|
+
|
|
356
|
+
core.setCurrentLayout(100);
|
|
357
|
+
|
|
358
|
+
expect(core.getPendingLayouts()).not.toContain(100);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should check pending layouts when media ready', () => {
|
|
362
|
+
const spy = createSpy();
|
|
363
|
+
core.on('check-pending-layout', spy);
|
|
364
|
+
|
|
365
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
366
|
+
|
|
367
|
+
core.notifyMediaReady(2);
|
|
368
|
+
|
|
369
|
+
expect(spy).toHaveBeenCalledWith(100, [1, 2, 3]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should not emit check-pending-layout for unrelated media', () => {
|
|
373
|
+
const spy = createSpy();
|
|
374
|
+
core.on('check-pending-layout', spy);
|
|
375
|
+
|
|
376
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
377
|
+
|
|
378
|
+
core.notifyMediaReady(99); // Not in required list
|
|
379
|
+
|
|
380
|
+
expect(spy).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('XMR Integration', () => {
|
|
385
|
+
it('should initialize XMR on first collection', async () => {
|
|
386
|
+
await core.collect();
|
|
387
|
+
|
|
388
|
+
expect(mockXmrWrapper).toHaveBeenCalledWith(mockConfig, core);
|
|
389
|
+
expect(core.xmr).toBeTruthy();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should emit xmr-connected when XMR initializes', async () => {
|
|
393
|
+
const spy = createSpy();
|
|
394
|
+
core.on('xmr-connected', spy);
|
|
395
|
+
|
|
396
|
+
await core.collect();
|
|
397
|
+
|
|
398
|
+
expect(spy).toHaveBeenCalledWith('wss://test.xmr.com');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should skip XMR if no URL provided', async () => {
|
|
402
|
+
mockXmds.registerDisplay.mockResolvedValue({
|
|
403
|
+
displayName: 'Test',
|
|
404
|
+
settings: {} // No XMR URL
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await core.collect();
|
|
408
|
+
|
|
409
|
+
expect(mockXmrWrapper).not.toHaveBeenCalled();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should reconnect XMR if disconnected', async () => {
|
|
413
|
+
// First collection creates XMR
|
|
414
|
+
await core.collect();
|
|
415
|
+
|
|
416
|
+
const firstXmr = core.xmr;
|
|
417
|
+
firstXmr.isConnected.mockReturnValue(false);
|
|
418
|
+
|
|
419
|
+
const spy = createSpy();
|
|
420
|
+
core.on('xmr-reconnected', spy);
|
|
421
|
+
|
|
422
|
+
// Second collection should reconnect
|
|
423
|
+
await core.collect();
|
|
424
|
+
|
|
425
|
+
expect(spy).toHaveBeenCalledWith('wss://test.xmr.com');
|
|
426
|
+
expect(firstXmr.reconnectAttempts).toBe(0); // Reset
|
|
427
|
+
expect(firstXmr.start).toHaveBeenCalledTimes(2);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should not reconnect if XMR already connected', async () => {
|
|
431
|
+
// First collection
|
|
432
|
+
await core.collect();
|
|
433
|
+
|
|
434
|
+
const firstXmr = core.xmr;
|
|
435
|
+
firstXmr.isConnected.mockReturnValue(true);
|
|
436
|
+
|
|
437
|
+
const startCallCount = firstXmr.start.mock.calls.length;
|
|
438
|
+
|
|
439
|
+
// Second collection should skip reconnect
|
|
440
|
+
await core.collect();
|
|
441
|
+
|
|
442
|
+
expect(firstXmr.start).toHaveBeenCalledTimes(startCallCount); // Not called again
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('Collection Interval', () => {
|
|
447
|
+
it('should setup collection interval on first run', async () => {
|
|
448
|
+
vi.useFakeTimers();
|
|
449
|
+
|
|
450
|
+
const spy = createSpy();
|
|
451
|
+
core.on('collection-interval-set', spy);
|
|
452
|
+
|
|
453
|
+
await core.collect();
|
|
454
|
+
|
|
455
|
+
expect(spy).toHaveBeenCalledWith(300); // From mock settings
|
|
456
|
+
expect(core.collectionInterval).toBeTruthy();
|
|
457
|
+
|
|
458
|
+
vi.useRealTimers();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('should not setup interval again on subsequent collections', async () => {
|
|
462
|
+
vi.useFakeTimers();
|
|
463
|
+
|
|
464
|
+
const spy = createSpy();
|
|
465
|
+
core.on('collection-interval-set', spy);
|
|
466
|
+
|
|
467
|
+
await core.collect();
|
|
468
|
+
await core.collect();
|
|
469
|
+
|
|
470
|
+
expect(spy).toHaveBeenCalledTimes(1); // Only once
|
|
471
|
+
|
|
472
|
+
vi.useRealTimers();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should run collection automatically on interval', async () => {
|
|
476
|
+
vi.useFakeTimers();
|
|
477
|
+
|
|
478
|
+
// First collection sets up the interval
|
|
479
|
+
await core.collect();
|
|
480
|
+
|
|
481
|
+
// Clear the interval to prevent infinite loop
|
|
482
|
+
const interval = core.collectionInterval;
|
|
483
|
+
expect(interval).toBeTruthy();
|
|
484
|
+
|
|
485
|
+
const collectionSpy = createSpy();
|
|
486
|
+
core.on('collection-start', collectionSpy);
|
|
487
|
+
|
|
488
|
+
// Manually trigger the interval callback once
|
|
489
|
+
// (Testing the interval setup, not the actual timer execution)
|
|
490
|
+
clearInterval(interval);
|
|
491
|
+
core.collectionInterval = null;
|
|
492
|
+
|
|
493
|
+
// Verify interval was set correctly by checking the settings
|
|
494
|
+
expect(mockXmds.registerDisplay).toHaveBeenCalled();
|
|
495
|
+
|
|
496
|
+
vi.useRealTimers();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('Layout Change Requests', () => {
|
|
501
|
+
it('should emit layout-change-requested', async () => {
|
|
502
|
+
const spy = createSpy();
|
|
503
|
+
core.on('layout-change-requested', spy);
|
|
504
|
+
|
|
505
|
+
await core.requestLayoutChange(456);
|
|
506
|
+
|
|
507
|
+
expect(spy).toHaveBeenCalledWith(456);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('should clear current layout on change request', async () => {
|
|
511
|
+
core.setCurrentLayout(100);
|
|
512
|
+
expect(core.getCurrentLayoutId()).toBe(100);
|
|
513
|
+
|
|
514
|
+
await core.requestLayoutChange(200);
|
|
515
|
+
|
|
516
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('Status Notification', () => {
|
|
521
|
+
it('should notify CMS of layout status', async () => {
|
|
522
|
+
await core.notifyLayoutStatus(123);
|
|
523
|
+
|
|
524
|
+
expect(mockXmds.notifyStatus).toHaveBeenCalledWith({ currentLayoutId: 123 });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('should emit status-notified on success', async () => {
|
|
528
|
+
const spy = createSpy();
|
|
529
|
+
core.on('status-notified', spy);
|
|
530
|
+
|
|
531
|
+
await core.notifyLayoutStatus(123);
|
|
532
|
+
|
|
533
|
+
expect(spy).toHaveBeenCalledWith(123);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should emit status-notify-failed on error', async () => {
|
|
537
|
+
const spy = createSpy();
|
|
538
|
+
core.on('status-notify-failed', spy);
|
|
539
|
+
|
|
540
|
+
mockXmds.notifyStatus.mockRejectedValue(new Error('Network error'));
|
|
541
|
+
|
|
542
|
+
await core.notifyLayoutStatus(123);
|
|
543
|
+
|
|
544
|
+
expect(spy).toHaveBeenCalledWith(123, expect.any(Error));
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('should not throw on notify failure (kiosk mode)', async () => {
|
|
548
|
+
mockXmds.notifyStatus.mockRejectedValue(new Error('Network error'));
|
|
549
|
+
|
|
550
|
+
await expect(core.notifyLayoutStatus(123)).resolves.toBeUndefined();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe('Media Ready Notifications', () => {
|
|
555
|
+
it('should emit check-pending-layout when media is ready', () => {
|
|
556
|
+
const spy = createSpy();
|
|
557
|
+
core.on('check-pending-layout', spy);
|
|
558
|
+
|
|
559
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
560
|
+
|
|
561
|
+
core.notifyMediaReady(2);
|
|
562
|
+
|
|
563
|
+
expect(spy).toHaveBeenCalledWith(100, [1, 2, 3]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should check multiple pending layouts', () => {
|
|
567
|
+
const spy = createSpy();
|
|
568
|
+
core.on('check-pending-layout', spy);
|
|
569
|
+
|
|
570
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
571
|
+
core.setPendingLayout(200, [1, 4, 5]);
|
|
572
|
+
|
|
573
|
+
core.notifyMediaReady(1); // Shared by both layouts
|
|
574
|
+
|
|
575
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
576
|
+
expect(spy).toHaveBeenCalledWith(100, [1, 2, 3]);
|
|
577
|
+
expect(spy).toHaveBeenCalledWith(200, [1, 4, 5]);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should not check layouts without the ready media', () => {
|
|
581
|
+
const spy = createSpy();
|
|
582
|
+
core.on('check-pending-layout', spy);
|
|
583
|
+
|
|
584
|
+
core.setPendingLayout(100, [1, 2, 3]);
|
|
585
|
+
|
|
586
|
+
core.notifyMediaReady(99); // Not in required list
|
|
587
|
+
|
|
588
|
+
expect(spy).not.toHaveBeenCalled();
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe('Cleanup', () => {
|
|
593
|
+
it('should clear collection interval', async () => {
|
|
594
|
+
vi.useFakeTimers();
|
|
595
|
+
|
|
596
|
+
await core.collect();
|
|
597
|
+
|
|
598
|
+
expect(core.collectionInterval).toBeTruthy();
|
|
599
|
+
|
|
600
|
+
core.cleanup();
|
|
601
|
+
|
|
602
|
+
expect(core.collectionInterval).toBeNull();
|
|
603
|
+
|
|
604
|
+
vi.useRealTimers();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('should stop XMR', async () => {
|
|
608
|
+
await core.collect();
|
|
609
|
+
|
|
610
|
+
const xmr = core.xmr;
|
|
611
|
+
expect(xmr).toBeTruthy();
|
|
612
|
+
|
|
613
|
+
core.cleanup();
|
|
614
|
+
|
|
615
|
+
expect(xmr.stop).toHaveBeenCalled();
|
|
616
|
+
expect(core.xmr).toBeNull();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('should remove all event listeners', () => {
|
|
620
|
+
const spy = createSpy();
|
|
621
|
+
core.on('test-event', spy);
|
|
622
|
+
|
|
623
|
+
core.cleanup();
|
|
624
|
+
|
|
625
|
+
core.emit('test-event');
|
|
626
|
+
|
|
627
|
+
expect(spy).not.toHaveBeenCalled();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('should emit cleanup-complete before removing listeners', () => {
|
|
631
|
+
const spy = createSpy();
|
|
632
|
+
core.on('cleanup-complete', spy);
|
|
633
|
+
|
|
634
|
+
core.cleanup();
|
|
635
|
+
|
|
636
|
+
// cleanup-complete should be emitted before removeAllListeners()
|
|
637
|
+
expect(spy).toHaveBeenCalled();
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe('Error Handling', () => {
|
|
642
|
+
it('should handle registerDisplay failure', async () => {
|
|
643
|
+
mockXmds.registerDisplay.mockRejectedValue(new Error('Registration failed'));
|
|
644
|
+
|
|
645
|
+
await expect(core.collect()).rejects.toThrow('Registration failed');
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('should handle requiredFiles failure', async () => {
|
|
649
|
+
mockXmds.requiredFiles.mockRejectedValue(new Error('Files fetch failed'));
|
|
650
|
+
|
|
651
|
+
await expect(core.collect()).rejects.toThrow('Files fetch failed');
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('should handle schedule failure', async () => {
|
|
655
|
+
mockXmds.schedule.mockRejectedValue(new Error('Schedule fetch failed'));
|
|
656
|
+
|
|
657
|
+
await expect(core.collect()).rejects.toThrow('Schedule fetch failed');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('should clear collecting flag on any error', async () => {
|
|
661
|
+
mockXmds.registerDisplay.mockRejectedValue(new Error('Test error'));
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
await core.collect();
|
|
665
|
+
} catch (e) {
|
|
666
|
+
// Expected
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
expect(core.isCollecting()).toBe(false);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
describe('Screenshot Capture', () => {
|
|
674
|
+
it('should emit screenshot-request when captureScreenshot is called', async () => {
|
|
675
|
+
const spy = createSpy();
|
|
676
|
+
core.on('screenshot-request', spy);
|
|
677
|
+
|
|
678
|
+
await core.captureScreenshot();
|
|
679
|
+
|
|
680
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
describe('Change Layout', () => {
|
|
685
|
+
it('should emit layout-prepare-request with parsed layoutId', async () => {
|
|
686
|
+
const spy = createSpy();
|
|
687
|
+
core.on('layout-prepare-request', spy);
|
|
688
|
+
|
|
689
|
+
await core.changeLayout('456');
|
|
690
|
+
|
|
691
|
+
expect(spy).toHaveBeenCalledWith(456);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should clear currentLayoutId to force re-render', async () => {
|
|
695
|
+
core.setCurrentLayout(100);
|
|
696
|
+
expect(core.getCurrentLayoutId()).toBe(100);
|
|
697
|
+
|
|
698
|
+
await core.changeLayout('200');
|
|
699
|
+
|
|
700
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should parse string layoutId to integer', async () => {
|
|
704
|
+
const spy = createSpy();
|
|
705
|
+
core.on('layout-prepare-request', spy);
|
|
706
|
+
|
|
707
|
+
await core.changeLayout('789');
|
|
708
|
+
|
|
709
|
+
expect(spy).toHaveBeenCalledWith(789);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe('Handle Trigger', () => {
|
|
714
|
+
it('should call changeLayout when matching navLayout action found', () => {
|
|
715
|
+
const spy = createSpy();
|
|
716
|
+
core.on('layout-prepare-request', spy);
|
|
717
|
+
|
|
718
|
+
mockSchedule.findActionByTrigger = vi.fn((code) => {
|
|
719
|
+
if (code === 'trigger1') {
|
|
720
|
+
return { actionType: 'navLayout', triggerCode: 'trigger1', layoutCode: '42' };
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
core.handleTrigger('trigger1');
|
|
726
|
+
|
|
727
|
+
expect(mockSchedule.findActionByTrigger).toHaveBeenCalledWith('trigger1');
|
|
728
|
+
expect(spy).toHaveBeenCalledWith(42);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('should handle navigateToLayout action type', () => {
|
|
732
|
+
const spy = createSpy();
|
|
733
|
+
core.on('layout-prepare-request', spy);
|
|
734
|
+
|
|
735
|
+
mockSchedule.findActionByTrigger = vi.fn(() => ({
|
|
736
|
+
actionType: 'navigateToLayout',
|
|
737
|
+
triggerCode: 'trigger1',
|
|
738
|
+
layoutCode: '99'
|
|
739
|
+
}));
|
|
740
|
+
|
|
741
|
+
core.handleTrigger('trigger1');
|
|
742
|
+
|
|
743
|
+
expect(spy).toHaveBeenCalledWith(99);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it('should do nothing when no matching action found', () => {
|
|
747
|
+
const layoutSpy = createSpy();
|
|
748
|
+
const widgetSpy = createSpy();
|
|
749
|
+
const commandSpy = createSpy();
|
|
750
|
+
core.on('layout-prepare-request', layoutSpy);
|
|
751
|
+
core.on('navigate-to-widget', widgetSpy);
|
|
752
|
+
core.on('execute-command', commandSpy);
|
|
753
|
+
|
|
754
|
+
mockSchedule.findActionByTrigger = vi.fn(() => null);
|
|
755
|
+
|
|
756
|
+
core.handleTrigger('nonexistent');
|
|
757
|
+
|
|
758
|
+
expect(mockSchedule.findActionByTrigger).toHaveBeenCalledWith('nonexistent');
|
|
759
|
+
expect(layoutSpy).not.toHaveBeenCalled();
|
|
760
|
+
expect(widgetSpy).not.toHaveBeenCalled();
|
|
761
|
+
expect(commandSpy).not.toHaveBeenCalled();
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should emit navigate-to-widget for navWidget action', () => {
|
|
765
|
+
const spy = createSpy();
|
|
766
|
+
core.on('navigate-to-widget', spy);
|
|
767
|
+
|
|
768
|
+
const action = { actionType: 'navWidget', triggerCode: 'trigger1', layoutCode: '10' };
|
|
769
|
+
mockSchedule.findActionByTrigger = vi.fn(() => action);
|
|
770
|
+
|
|
771
|
+
core.handleTrigger('trigger1');
|
|
772
|
+
|
|
773
|
+
expect(spy).toHaveBeenCalledWith(action);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('should emit execute-command for command action', () => {
|
|
777
|
+
const spy = createSpy();
|
|
778
|
+
core.on('execute-command', spy);
|
|
779
|
+
|
|
780
|
+
mockSchedule.findActionByTrigger = vi.fn(() => ({
|
|
781
|
+
actionType: 'command',
|
|
782
|
+
triggerCode: 'trigger1',
|
|
783
|
+
commandCode: 'restart'
|
|
784
|
+
}));
|
|
785
|
+
|
|
786
|
+
core.handleTrigger('trigger1');
|
|
787
|
+
|
|
788
|
+
expect(spy).toHaveBeenCalledWith('restart');
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe('Purge Request', () => {
|
|
793
|
+
it('should emit purge-request when RequiredFiles includes purge entries', async () => {
|
|
794
|
+
const spy = createSpy();
|
|
795
|
+
core.on('purge-request', spy);
|
|
796
|
+
|
|
797
|
+
mockXmds.requiredFiles.mockResolvedValue([
|
|
798
|
+
{ id: '1', type: 'media', path: 'http://test.com/file1.mp4' },
|
|
799
|
+
{ id: '2', type: 'layout', path: 'http://test.com/layout.xlf' },
|
|
800
|
+
{ id: '3', type: 'purge', path: null },
|
|
801
|
+
{ id: '4', type: 'purge', path: null }
|
|
802
|
+
]);
|
|
803
|
+
|
|
804
|
+
await core.collect();
|
|
805
|
+
|
|
806
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
807
|
+
expect(spy).toHaveBeenCalledWith([
|
|
808
|
+
expect.objectContaining({ id: '3', type: 'purge' }),
|
|
809
|
+
expect.objectContaining({ id: '4', type: 'purge' })
|
|
810
|
+
]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('should not emit purge-request when no purge entries', async () => {
|
|
814
|
+
const spy = createSpy();
|
|
815
|
+
core.on('purge-request', spy);
|
|
816
|
+
|
|
817
|
+
mockXmds.requiredFiles.mockResolvedValue([
|
|
818
|
+
{ id: '1', type: 'media', path: 'http://test.com/file1.mp4' }
|
|
819
|
+
]);
|
|
820
|
+
|
|
821
|
+
await core.collect();
|
|
822
|
+
|
|
823
|
+
expect(spy).not.toHaveBeenCalled();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('should separate purge entries from download-request', async () => {
|
|
827
|
+
const downloadSpy = createSpy();
|
|
828
|
+
core.on('download-request', downloadSpy);
|
|
829
|
+
|
|
830
|
+
mockXmds.requiredFiles.mockResolvedValue([
|
|
831
|
+
{ id: '1', type: 'media', path: 'http://test.com/file1.mp4' },
|
|
832
|
+
{ id: '2', type: 'purge', path: null }
|
|
833
|
+
]);
|
|
834
|
+
|
|
835
|
+
await core.collect();
|
|
836
|
+
|
|
837
|
+
// download-request should only contain non-purge files
|
|
838
|
+
const downloadedFiles = downloadSpy.mock.calls[0][0];
|
|
839
|
+
expect(downloadedFiles.every(f => f.type !== 'purge')).toBe(true);
|
|
840
|
+
expect(downloadedFiles).toHaveLength(1);
|
|
841
|
+
expect(downloadedFiles[0].id).toBe('1');
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
describe('State Consistency', () => {
|
|
846
|
+
it('should maintain invariant: collecting flag matches execution state', async () => {
|
|
847
|
+
expect(core.isCollecting()).toBe(false);
|
|
848
|
+
|
|
849
|
+
const promise = core.collect();
|
|
850
|
+
expect(core.isCollecting()).toBe(true);
|
|
851
|
+
|
|
852
|
+
await promise;
|
|
853
|
+
expect(core.isCollecting()).toBe(false);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('should maintain invariant: currentLayoutId updated correctly', () => {
|
|
857
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
858
|
+
|
|
859
|
+
core.setCurrentLayout(100);
|
|
860
|
+
expect(core.getCurrentLayoutId()).toBe(100);
|
|
861
|
+
|
|
862
|
+
core.clearCurrentLayout();
|
|
863
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
864
|
+
|
|
865
|
+
core.setCurrentLayout(200);
|
|
866
|
+
expect(core.getCurrentLayoutId()).toBe(200);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should maintain invariant: pending layouts tracked correctly', () => {
|
|
870
|
+
expect(core.getPendingLayouts()).toHaveLength(0);
|
|
871
|
+
|
|
872
|
+
core.setPendingLayout(100, [1, 2]);
|
|
873
|
+
expect(core.getPendingLayouts()).toHaveLength(1);
|
|
874
|
+
|
|
875
|
+
core.setPendingLayout(200, [3, 4]);
|
|
876
|
+
expect(core.getPendingLayouts()).toHaveLength(2);
|
|
877
|
+
|
|
878
|
+
core.setCurrentLayout(100);
|
|
879
|
+
expect(core.getPendingLayouts()).toHaveLength(1); // 100 removed
|
|
880
|
+
|
|
881
|
+
core.setCurrentLayout(200);
|
|
882
|
+
expect(core.getPendingLayouts()).toHaveLength(0); // All removed
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
describe('overlayLayout', () => {
|
|
887
|
+
it('should emit overlay-layout-request with parsed layoutId', async () => {
|
|
888
|
+
const spy = createSpy();
|
|
889
|
+
core.on('overlay-layout-request', spy);
|
|
890
|
+
|
|
891
|
+
await core.overlayLayout('555');
|
|
892
|
+
|
|
893
|
+
expect(spy).toHaveBeenCalledWith(555);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should set _layoutOverride with overlay type', async () => {
|
|
897
|
+
await core.overlayLayout('123');
|
|
898
|
+
|
|
899
|
+
expect(core._layoutOverride).toEqual({ layoutId: 123, type: 'overlay' });
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('should parse string layoutId to integer', async () => {
|
|
903
|
+
const spy = createSpy();
|
|
904
|
+
core.on('overlay-layout-request', spy);
|
|
905
|
+
|
|
906
|
+
await core.overlayLayout('007');
|
|
907
|
+
|
|
908
|
+
expect(spy).toHaveBeenCalledWith(7);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('should overwrite previous layout override', async () => {
|
|
912
|
+
await core.overlayLayout('100');
|
|
913
|
+
expect(core._layoutOverride).toEqual({ layoutId: 100, type: 'overlay' });
|
|
914
|
+
|
|
915
|
+
await core.overlayLayout('200');
|
|
916
|
+
expect(core._layoutOverride).toEqual({ layoutId: 200, type: 'overlay' });
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
describe('revertToSchedule', () => {
|
|
921
|
+
it('should clear _layoutOverride', async () => {
|
|
922
|
+
core._layoutOverride = { layoutId: 42, type: 'change' };
|
|
923
|
+
|
|
924
|
+
await core.revertToSchedule();
|
|
925
|
+
|
|
926
|
+
expect(core._layoutOverride).toBeNull();
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it('should clear currentLayoutId', async () => {
|
|
930
|
+
core.setCurrentLayout(100);
|
|
931
|
+
expect(core.getCurrentLayoutId()).toBe(100);
|
|
932
|
+
|
|
933
|
+
await core.revertToSchedule();
|
|
934
|
+
|
|
935
|
+
expect(core.getCurrentLayoutId()).toBeNull();
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('should emit revert-to-schedule event', async () => {
|
|
939
|
+
const spy = createSpy();
|
|
940
|
+
core.on('revert-to-schedule', spy);
|
|
941
|
+
|
|
942
|
+
await core.revertToSchedule();
|
|
943
|
+
|
|
944
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it('should emit layout-prepare-request when schedule has layouts', async () => {
|
|
948
|
+
const spy = createSpy();
|
|
949
|
+
core.on('layout-prepare-request', spy);
|
|
950
|
+
|
|
951
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['200.xlf']);
|
|
952
|
+
|
|
953
|
+
await core.revertToSchedule();
|
|
954
|
+
|
|
955
|
+
expect(spy).toHaveBeenCalledWith(200);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('should emit no-layouts-scheduled when schedule is empty', async () => {
|
|
959
|
+
const spy = createSpy();
|
|
960
|
+
core.on('no-layouts-scheduled', spy);
|
|
961
|
+
|
|
962
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
963
|
+
|
|
964
|
+
await core.revertToSchedule();
|
|
965
|
+
|
|
966
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('should re-evaluate schedule after clearing override', async () => {
|
|
970
|
+
core._layoutOverride = { layoutId: 999, type: 'overlay' };
|
|
971
|
+
core.setCurrentLayout(999);
|
|
972
|
+
|
|
973
|
+
const revertSpy = createSpy();
|
|
974
|
+
const layoutSpy = createSpy();
|
|
975
|
+
core.on('revert-to-schedule', revertSpy);
|
|
976
|
+
core.on('layout-prepare-request', layoutSpy);
|
|
977
|
+
|
|
978
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf']);
|
|
979
|
+
|
|
980
|
+
await core.revertToSchedule();
|
|
981
|
+
|
|
982
|
+
expect(revertSpy).toHaveBeenCalledTimes(1);
|
|
983
|
+
expect(layoutSpy).toHaveBeenCalledWith(100);
|
|
984
|
+
expect(core._layoutOverride).toBeNull();
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
describe('purgeAll', () => {
|
|
989
|
+
it('should clear CRC checksums to force fresh collection', async () => {
|
|
990
|
+
core._lastCheckRf = 'abc123';
|
|
991
|
+
core._lastCheckSchedule = 'def456';
|
|
992
|
+
|
|
993
|
+
// purgeAll clears checksums then calls collectNow/collect which re-fetches
|
|
994
|
+
// After collect completes, checksums are set to new values from regResult
|
|
995
|
+
await core.purgeAll();
|
|
996
|
+
|
|
997
|
+
// The old CRC values should be gone (replaced by fresh values from regResult)
|
|
998
|
+
expect(core._lastCheckRf).not.toBe('abc123');
|
|
999
|
+
expect(core._lastCheckSchedule).not.toBe('def456');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('should emit purge-all-request event', async () => {
|
|
1003
|
+
const spy = createSpy();
|
|
1004
|
+
core.on('purge-all-request', spy);
|
|
1005
|
+
|
|
1006
|
+
await core.purgeAll();
|
|
1007
|
+
|
|
1008
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('should trigger a fresh collection cycle via collectNow', async () => {
|
|
1012
|
+
const spy = createSpy();
|
|
1013
|
+
core.on('collection-start', spy);
|
|
1014
|
+
|
|
1015
|
+
await core.purgeAll();
|
|
1016
|
+
|
|
1017
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('should emit purge-all-request before collection-start', async () => {
|
|
1021
|
+
const order = [];
|
|
1022
|
+
core.on('purge-all-request', () => order.push('purge'));
|
|
1023
|
+
core.on('collection-start', () => order.push('collect'));
|
|
1024
|
+
|
|
1025
|
+
await core.purgeAll();
|
|
1026
|
+
|
|
1027
|
+
expect(order[0]).toBe('purge');
|
|
1028
|
+
expect(order[1]).toBe('collect');
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('executeCommand', () => {
|
|
1033
|
+
let originalFetch;
|
|
1034
|
+
|
|
1035
|
+
beforeEach(() => {
|
|
1036
|
+
originalFetch = global.fetch;
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
afterEach(() => {
|
|
1040
|
+
global.fetch = originalFetch;
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it('should emit command-result with unknown command when code not in commands map', async () => {
|
|
1044
|
+
const spy = createSpy();
|
|
1045
|
+
core.on('command-result', spy);
|
|
1046
|
+
|
|
1047
|
+
await core.executeCommand('reboot', { restart: { commandString: 'http|http://test.com/restart' } });
|
|
1048
|
+
|
|
1049
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1050
|
+
code: 'reboot',
|
|
1051
|
+
success: false,
|
|
1052
|
+
reason: 'Unknown command'
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('should emit command-result with unknown command when commands is null', async () => {
|
|
1057
|
+
const spy = createSpy();
|
|
1058
|
+
core.on('command-result', spy);
|
|
1059
|
+
|
|
1060
|
+
await core.executeCommand('reboot', null);
|
|
1061
|
+
|
|
1062
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1063
|
+
code: 'reboot',
|
|
1064
|
+
success: false,
|
|
1065
|
+
reason: 'Unknown command'
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('should execute HTTP command and emit success result', async () => {
|
|
1070
|
+
const spy = createSpy();
|
|
1071
|
+
core.on('command-result', spy);
|
|
1072
|
+
|
|
1073
|
+
global.fetch = vi.fn(() => Promise.resolve({
|
|
1074
|
+
ok: true,
|
|
1075
|
+
status: 200
|
|
1076
|
+
}));
|
|
1077
|
+
|
|
1078
|
+
const commands = {
|
|
1079
|
+
restart: { commandString: 'http|http://test.com/restart|application/json' }
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
await core.executeCommand('restart', commands);
|
|
1083
|
+
|
|
1084
|
+
expect(global.fetch).toHaveBeenCalledWith('http://test.com/restart', {
|
|
1085
|
+
method: 'POST',
|
|
1086
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1087
|
+
});
|
|
1088
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1089
|
+
code: 'restart',
|
|
1090
|
+
success: true,
|
|
1091
|
+
status: 200
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('should emit failure result when fetch throws', async () => {
|
|
1096
|
+
const spy = createSpy();
|
|
1097
|
+
core.on('command-result', spy);
|
|
1098
|
+
|
|
1099
|
+
global.fetch = vi.fn(() => Promise.reject(new Error('Network timeout')));
|
|
1100
|
+
|
|
1101
|
+
const commands = {
|
|
1102
|
+
restart: { commandString: 'http|http://test.com/restart' }
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
await core.executeCommand('restart', commands);
|
|
1106
|
+
|
|
1107
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1108
|
+
code: 'restart',
|
|
1109
|
+
success: false,
|
|
1110
|
+
reason: 'Network timeout'
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
it('should emit failure for non-HTTP commands', async () => {
|
|
1115
|
+
const spy = createSpy();
|
|
1116
|
+
core.on('command-result', spy);
|
|
1117
|
+
|
|
1118
|
+
const commands = {
|
|
1119
|
+
reboot: { commandString: 'shell|reboot' }
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
await core.executeCommand('reboot', commands);
|
|
1123
|
+
|
|
1124
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1125
|
+
code: 'reboot',
|
|
1126
|
+
success: false,
|
|
1127
|
+
reason: 'Only HTTP commands supported in browser'
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('should use value property as fallback when commandString is absent', async () => {
|
|
1132
|
+
const spy = createSpy();
|
|
1133
|
+
core.on('command-result', spy);
|
|
1134
|
+
|
|
1135
|
+
global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200 }));
|
|
1136
|
+
|
|
1137
|
+
const commands = {
|
|
1138
|
+
ping: { value: 'http|http://test.com/ping' }
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
await core.executeCommand('ping', commands);
|
|
1142
|
+
|
|
1143
|
+
expect(global.fetch).toHaveBeenCalledWith('http://test.com/ping', expect.any(Object));
|
|
1144
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ success: true }));
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it('should default content-type to application/json when not specified', async () => {
|
|
1148
|
+
global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200 }));
|
|
1149
|
+
|
|
1150
|
+
const commands = {
|
|
1151
|
+
action: { commandString: 'http|http://test.com/action' }
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
await core.executeCommand('action', commands);
|
|
1155
|
+
|
|
1156
|
+
expect(global.fetch).toHaveBeenCalledWith('http://test.com/action', {
|
|
1157
|
+
method: 'POST',
|
|
1158
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
describe('triggerWebhook', () => {
|
|
1164
|
+
it('should delegate to handleTrigger', () => {
|
|
1165
|
+
const spy = createSpy();
|
|
1166
|
+
core.on('layout-prepare-request', spy);
|
|
1167
|
+
|
|
1168
|
+
mockSchedule.findActionByTrigger = vi.fn((code) => {
|
|
1169
|
+
if (code === 'webhook1') {
|
|
1170
|
+
return { actionType: 'navLayout', triggerCode: 'webhook1', layoutCode: '77' };
|
|
1171
|
+
}
|
|
1172
|
+
return null;
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
core.triggerWebhook('webhook1');
|
|
1176
|
+
|
|
1177
|
+
expect(mockSchedule.findActionByTrigger).toHaveBeenCalledWith('webhook1');
|
|
1178
|
+
expect(spy).toHaveBeenCalledWith(77);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it('should do nothing when no matching action exists', () => {
|
|
1182
|
+
const layoutSpy = createSpy();
|
|
1183
|
+
const widgetSpy = createSpy();
|
|
1184
|
+
core.on('layout-prepare-request', layoutSpy);
|
|
1185
|
+
core.on('navigate-to-widget', widgetSpy);
|
|
1186
|
+
|
|
1187
|
+
mockSchedule.findActionByTrigger = vi.fn(() => null);
|
|
1188
|
+
|
|
1189
|
+
core.triggerWebhook('nonexistent');
|
|
1190
|
+
|
|
1191
|
+
expect(mockSchedule.findActionByTrigger).toHaveBeenCalledWith('nonexistent');
|
|
1192
|
+
expect(layoutSpy).not.toHaveBeenCalled();
|
|
1193
|
+
expect(widgetSpy).not.toHaveBeenCalled();
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
it('should handle command action type via webhook trigger', () => {
|
|
1197
|
+
const spy = createSpy();
|
|
1198
|
+
core.on('execute-command', spy);
|
|
1199
|
+
|
|
1200
|
+
mockSchedule.findActionByTrigger = vi.fn(() => ({
|
|
1201
|
+
actionType: 'command',
|
|
1202
|
+
triggerCode: 'webhook-cmd',
|
|
1203
|
+
commandCode: 'restart'
|
|
1204
|
+
}));
|
|
1205
|
+
|
|
1206
|
+
core.triggerWebhook('webhook-cmd');
|
|
1207
|
+
|
|
1208
|
+
expect(spy).toHaveBeenCalledWith('restart');
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
describe('refreshDataConnectors', () => {
|
|
1213
|
+
beforeEach(() => {
|
|
1214
|
+
// Stub refreshAll on the real DataConnectorManager instance
|
|
1215
|
+
core.dataConnectorManager.refreshAll = vi.fn();
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('should call dataConnectorManager.refreshAll', () => {
|
|
1219
|
+
core.refreshDataConnectors();
|
|
1220
|
+
|
|
1221
|
+
expect(core.dataConnectorManager.refreshAll).toHaveBeenCalledTimes(1);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it('should emit data-connectors-refreshed event', () => {
|
|
1225
|
+
const spy = createSpy();
|
|
1226
|
+
core.on('data-connectors-refreshed', spy);
|
|
1227
|
+
|
|
1228
|
+
core.refreshDataConnectors();
|
|
1229
|
+
|
|
1230
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('should call refreshAll before emitting event', () => {
|
|
1234
|
+
const order = [];
|
|
1235
|
+
core.dataConnectorManager.refreshAll = vi.fn(() => order.push('refresh'));
|
|
1236
|
+
core.on('data-connectors-refreshed', () => order.push('event'));
|
|
1237
|
+
|
|
1238
|
+
core.refreshDataConnectors();
|
|
1239
|
+
|
|
1240
|
+
expect(order).toEqual(['refresh', 'event']);
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
describe('submitMediaInventory', () => {
|
|
1245
|
+
beforeEach(() => {
|
|
1246
|
+
mockXmds.mediaInventory = vi.fn(() => Promise.resolve());
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it('should build XML and call xmds.mediaInventory', async () => {
|
|
1250
|
+
const files = [
|
|
1251
|
+
{ id: '10', type: 'media', md5: 'abc123' },
|
|
1252
|
+
{ id: '20', type: 'layout', md5: 'def456' }
|
|
1253
|
+
];
|
|
1254
|
+
|
|
1255
|
+
await core.submitMediaInventory(files);
|
|
1256
|
+
|
|
1257
|
+
expect(mockXmds.mediaInventory).toHaveBeenCalledTimes(1);
|
|
1258
|
+
const xml = mockXmds.mediaInventory.mock.calls[0][0];
|
|
1259
|
+
expect(xml).toContain('<files>');
|
|
1260
|
+
expect(xml).toContain('type="media"');
|
|
1261
|
+
expect(xml).toContain('id="10"');
|
|
1262
|
+
expect(xml).toContain('md5="abc123"');
|
|
1263
|
+
expect(xml).toContain('type="layout"');
|
|
1264
|
+
expect(xml).toContain('id="20"');
|
|
1265
|
+
expect(xml).toContain('md5="def456"');
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it('should emit media-inventory-submitted with file count', async () => {
|
|
1269
|
+
const spy = createSpy();
|
|
1270
|
+
core.on('media-inventory-submitted', spy);
|
|
1271
|
+
|
|
1272
|
+
const files = [
|
|
1273
|
+
{ id: '1', type: 'media', md5: 'a' },
|
|
1274
|
+
{ id: '2', type: 'layout', md5: 'b' }
|
|
1275
|
+
];
|
|
1276
|
+
|
|
1277
|
+
await core.submitMediaInventory(files);
|
|
1278
|
+
|
|
1279
|
+
expect(spy).toHaveBeenCalledWith(2);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it('should do nothing when files array is empty', async () => {
|
|
1283
|
+
const spy = createSpy();
|
|
1284
|
+
core.on('media-inventory-submitted', spy);
|
|
1285
|
+
|
|
1286
|
+
await core.submitMediaInventory([]);
|
|
1287
|
+
|
|
1288
|
+
expect(mockXmds.mediaInventory).not.toHaveBeenCalled();
|
|
1289
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it('should do nothing when files is null', async () => {
|
|
1293
|
+
const spy = createSpy();
|
|
1294
|
+
core.on('media-inventory-submitted', spy);
|
|
1295
|
+
|
|
1296
|
+
await core.submitMediaInventory(null);
|
|
1297
|
+
|
|
1298
|
+
expect(mockXmds.mediaInventory).not.toHaveBeenCalled();
|
|
1299
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it('should filter out non-media/non-layout file types from XML', async () => {
|
|
1303
|
+
const files = [
|
|
1304
|
+
{ id: '1', type: 'media', md5: 'a' },
|
|
1305
|
+
{ id: '2', type: 'resource', md5: 'b' },
|
|
1306
|
+
{ id: '3', type: 'layout', md5: 'c' }
|
|
1307
|
+
];
|
|
1308
|
+
|
|
1309
|
+
await core.submitMediaInventory(files);
|
|
1310
|
+
|
|
1311
|
+
const xml = mockXmds.mediaInventory.mock.calls[0][0];
|
|
1312
|
+
expect(xml).toContain('id="1"');
|
|
1313
|
+
expect(xml).toContain('id="3"');
|
|
1314
|
+
expect(xml).not.toContain('id="2"');
|
|
1315
|
+
expect(xml).not.toContain('type="resource"');
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
it('should not throw when xmds.mediaInventory fails', async () => {
|
|
1319
|
+
mockXmds.mediaInventory.mockRejectedValue(new Error('Server error'));
|
|
1320
|
+
|
|
1321
|
+
const files = [{ id: '1', type: 'media', md5: 'a' }];
|
|
1322
|
+
|
|
1323
|
+
await expect(core.submitMediaInventory(files)).resolves.toBeUndefined();
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
describe('blackList', () => {
|
|
1328
|
+
beforeEach(() => {
|
|
1329
|
+
mockXmds.blackList = vi.fn(() => Promise.resolve(true));
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it('should call xmds.blackList with correct arguments', async () => {
|
|
1333
|
+
await core.blackList('42', 'media', 'Corrupted file');
|
|
1334
|
+
|
|
1335
|
+
expect(mockXmds.blackList).toHaveBeenCalledWith('42', 'media', 'Corrupted file');
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
it('should emit media-blacklisted with details', async () => {
|
|
1339
|
+
const spy = createSpy();
|
|
1340
|
+
core.on('media-blacklisted', spy);
|
|
1341
|
+
|
|
1342
|
+
await core.blackList('42', 'media', 'Corrupted file');
|
|
1343
|
+
|
|
1344
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1345
|
+
mediaId: '42',
|
|
1346
|
+
type: 'media',
|
|
1347
|
+
reason: 'Corrupted file'
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('should not throw when xmds.blackList fails', async () => {
|
|
1352
|
+
mockXmds.blackList.mockRejectedValue(new Error('Server error'));
|
|
1353
|
+
|
|
1354
|
+
await expect(core.blackList('42', 'media', 'Bad file')).resolves.toBeUndefined();
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it('should not emit media-blacklisted when xmds call fails', async () => {
|
|
1358
|
+
const spy = createSpy();
|
|
1359
|
+
core.on('media-blacklisted', spy);
|
|
1360
|
+
|
|
1361
|
+
mockXmds.blackList.mockRejectedValue(new Error('Server error'));
|
|
1362
|
+
|
|
1363
|
+
await core.blackList('42', 'media', 'Bad file');
|
|
1364
|
+
|
|
1365
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('should handle layout type blacklisting', async () => {
|
|
1369
|
+
const spy = createSpy();
|
|
1370
|
+
core.on('media-blacklisted', spy);
|
|
1371
|
+
|
|
1372
|
+
await core.blackList('99', 'layout', 'Invalid XLF');
|
|
1373
|
+
|
|
1374
|
+
expect(mockXmds.blackList).toHaveBeenCalledWith('99', 'layout', 'Invalid XLF');
|
|
1375
|
+
expect(spy).toHaveBeenCalledWith({
|
|
1376
|
+
mediaId: '99',
|
|
1377
|
+
type: 'layout',
|
|
1378
|
+
reason: 'Invalid XLF'
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
describe('isLayoutOverridden', () => {
|
|
1384
|
+
it('should return false when no override is set', () => {
|
|
1385
|
+
expect(core.isLayoutOverridden()).toBe(false);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it('should return true after changeLayout sets an override', async () => {
|
|
1389
|
+
await core.changeLayout('123');
|
|
1390
|
+
|
|
1391
|
+
expect(core.isLayoutOverridden()).toBe(true);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('should return true after overlayLayout sets an override', async () => {
|
|
1395
|
+
await core.overlayLayout('456');
|
|
1396
|
+
|
|
1397
|
+
expect(core.isLayoutOverridden()).toBe(true);
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('should return false after revertToSchedule clears the override', async () => {
|
|
1401
|
+
await core.changeLayout('123');
|
|
1402
|
+
expect(core.isLayoutOverridden()).toBe(true);
|
|
1403
|
+
|
|
1404
|
+
await core.revertToSchedule();
|
|
1405
|
+
|
|
1406
|
+
expect(core.isLayoutOverridden()).toBe(false);
|
|
1407
|
+
});
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
describe('Schedule Cycling (Round-Robin)', () => {
|
|
1411
|
+
it('should initialize _currentLayoutIndex to 0', () => {
|
|
1412
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('should reset _currentLayoutIndex to 0 on collect()', async () => {
|
|
1416
|
+
core._currentLayoutIndex = 2;
|
|
1417
|
+
|
|
1418
|
+
await core.collect();
|
|
1419
|
+
|
|
1420
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
describe('getNextLayout', () => {
|
|
1424
|
+
it('should return first layout when index is 0', () => {
|
|
1425
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf', '300.xlf']);
|
|
1426
|
+
core._currentLayoutIndex = 0;
|
|
1427
|
+
|
|
1428
|
+
const result = core.getNextLayout();
|
|
1429
|
+
|
|
1430
|
+
expect(result).toEqual({ layoutId: 100, layoutFile: '100.xlf' });
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it('should return layout at current index', () => {
|
|
1434
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf', '300.xlf']);
|
|
1435
|
+
core._currentLayoutIndex = 1;
|
|
1436
|
+
|
|
1437
|
+
const result = core.getNextLayout();
|
|
1438
|
+
|
|
1439
|
+
expect(result).toEqual({ layoutId: 200, layoutFile: '200.xlf' });
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it('should return null when no layouts scheduled', () => {
|
|
1443
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
1444
|
+
|
|
1445
|
+
const result = core.getNextLayout();
|
|
1446
|
+
|
|
1447
|
+
expect(result).toBeNull();
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('should wrap index when schedule shrinks', () => {
|
|
1451
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf']);
|
|
1452
|
+
core._currentLayoutIndex = 5; // Out of bounds
|
|
1453
|
+
|
|
1454
|
+
const result = core.getNextLayout();
|
|
1455
|
+
|
|
1456
|
+
expect(result).toEqual({ layoutId: 100, layoutFile: '100.xlf' });
|
|
1457
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
describe('advanceToNextLayout', () => {
|
|
1462
|
+
it('should advance index and emit layout-prepare-request', () => {
|
|
1463
|
+
const spy = createSpy();
|
|
1464
|
+
core.on('layout-prepare-request', spy);
|
|
1465
|
+
|
|
1466
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf', '300.xlf']);
|
|
1467
|
+
core._currentLayoutIndex = 0;
|
|
1468
|
+
|
|
1469
|
+
core.advanceToNextLayout();
|
|
1470
|
+
|
|
1471
|
+
expect(core._currentLayoutIndex).toBe(1);
|
|
1472
|
+
expect(spy).toHaveBeenCalledWith(200);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('should wrap around to first layout after last', () => {
|
|
1476
|
+
const spy = createSpy();
|
|
1477
|
+
core.on('layout-prepare-request', spy);
|
|
1478
|
+
|
|
1479
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf', '300.xlf']);
|
|
1480
|
+
core._currentLayoutIndex = 2;
|
|
1481
|
+
|
|
1482
|
+
core.advanceToNextLayout();
|
|
1483
|
+
|
|
1484
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1485
|
+
expect(spy).toHaveBeenCalledWith(100);
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it('should trigger replay for single layout (wraps to same)', () => {
|
|
1489
|
+
const spy = createSpy();
|
|
1490
|
+
core.on('layout-prepare-request', spy);
|
|
1491
|
+
|
|
1492
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf']);
|
|
1493
|
+
core._currentLayoutIndex = 0;
|
|
1494
|
+
core.currentLayoutId = 100;
|
|
1495
|
+
|
|
1496
|
+
core.advanceToNextLayout();
|
|
1497
|
+
|
|
1498
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1499
|
+
// currentLayoutId should be cleared (for replay)
|
|
1500
|
+
expect(core.currentLayoutId).toBeNull();
|
|
1501
|
+
expect(spy).toHaveBeenCalledWith(100);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
it('should emit no-layouts-scheduled when schedule is empty', () => {
|
|
1505
|
+
const spy = createSpy();
|
|
1506
|
+
core.on('no-layouts-scheduled', spy);
|
|
1507
|
+
|
|
1508
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
1509
|
+
|
|
1510
|
+
core.advanceToNextLayout();
|
|
1511
|
+
|
|
1512
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
it('should not advance when layout override is active', () => {
|
|
1516
|
+
const spy = createSpy();
|
|
1517
|
+
core.on('layout-prepare-request', spy);
|
|
1518
|
+
core.on('no-layouts-scheduled', spy);
|
|
1519
|
+
|
|
1520
|
+
core._layoutOverride = { layoutId: 999, type: 'change' };
|
|
1521
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf']);
|
|
1522
|
+
|
|
1523
|
+
core.advanceToNextLayout();
|
|
1524
|
+
|
|
1525
|
+
expect(spy).not.toHaveBeenCalled();
|
|
1526
|
+
expect(core._currentLayoutIndex).toBe(0); // Unchanged
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it('should cycle through all layouts in order', () => {
|
|
1530
|
+
const emitted = [];
|
|
1531
|
+
core.on('layout-prepare-request', (id) => emitted.push(id));
|
|
1532
|
+
|
|
1533
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf', '200.xlf', '300.xlf']);
|
|
1534
|
+
core._currentLayoutIndex = 0;
|
|
1535
|
+
|
|
1536
|
+
core.advanceToNextLayout(); // → 200 (index 1)
|
|
1537
|
+
core.advanceToNextLayout(); // → 300 (index 2)
|
|
1538
|
+
core.advanceToNextLayout(); // → 100 (index 0, wrap)
|
|
1539
|
+
|
|
1540
|
+
expect(emitted).toEqual([200, 300, 100]);
|
|
1541
|
+
expect(core._currentLayoutIndex).toBe(0);
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
describe('Offline Mode', () => {
|
|
1547
|
+
it('isOffline should return false when navigator.onLine is true', () => {
|
|
1548
|
+
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
|
1549
|
+
|
|
1550
|
+
expect(core.isOffline()).toBe(false);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it('isOffline should return true when navigator.onLine is false', () => {
|
|
1554
|
+
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
|
1555
|
+
|
|
1556
|
+
expect(core.isOffline()).toBe(false === navigator.onLine);
|
|
1557
|
+
// Restore
|
|
1558
|
+
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
it('hasCachedData should return false when no schedule is cached', () => {
|
|
1562
|
+
core._offlineCache = { schedule: null, settings: null, requiredFiles: null };
|
|
1563
|
+
|
|
1564
|
+
expect(core.hasCachedData()).toBe(false);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
it('hasCachedData should return true when schedule is cached', () => {
|
|
1568
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1569
|
+
|
|
1570
|
+
expect(core.hasCachedData()).toBe(true);
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
it('collectOffline should set offlineMode to true and emit offline-mode', () => {
|
|
1574
|
+
const spy = createSpy();
|
|
1575
|
+
core.on('offline-mode', spy);
|
|
1576
|
+
|
|
1577
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1578
|
+
|
|
1579
|
+
core.collectOffline();
|
|
1580
|
+
|
|
1581
|
+
expect(core.offlineMode).toBe(true);
|
|
1582
|
+
expect(spy).toHaveBeenCalledWith(true);
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
it('collectOffline should apply cached schedule', () => {
|
|
1586
|
+
const cachedSchedule = { default: '0', layouts: [{ file: '300.xlf' }] };
|
|
1587
|
+
core._offlineCache = { schedule: cachedSchedule, settings: null, requiredFiles: null };
|
|
1588
|
+
|
|
1589
|
+
const scheduleSpy = createSpy();
|
|
1590
|
+
core.on('schedule-received', scheduleSpy);
|
|
1591
|
+
|
|
1592
|
+
core.collectOffline();
|
|
1593
|
+
|
|
1594
|
+
expect(mockSchedule.setSchedule).toHaveBeenCalledWith(cachedSchedule);
|
|
1595
|
+
expect(scheduleSpy).toHaveBeenCalledWith(cachedSchedule);
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
it('collectOffline should emit layout-prepare-request for first scheduled layout', () => {
|
|
1599
|
+
const spy = createSpy();
|
|
1600
|
+
core.on('layout-prepare-request', spy);
|
|
1601
|
+
|
|
1602
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1603
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['500.xlf']);
|
|
1604
|
+
|
|
1605
|
+
core.collectOffline();
|
|
1606
|
+
|
|
1607
|
+
expect(spy).toHaveBeenCalledWith(500);
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
it('collectOffline should emit no-layouts-scheduled when schedule empty', () => {
|
|
1611
|
+
const spy = createSpy();
|
|
1612
|
+
core.on('no-layouts-scheduled', spy);
|
|
1613
|
+
|
|
1614
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1615
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
1616
|
+
|
|
1617
|
+
core.collectOffline();
|
|
1618
|
+
|
|
1619
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it('collectOffline should skip reload if layout already playing', () => {
|
|
1623
|
+
const alreadySpy = createSpy();
|
|
1624
|
+
const prepareSpy = createSpy();
|
|
1625
|
+
core.on('layout-already-playing', alreadySpy);
|
|
1626
|
+
core.on('layout-prepare-request', prepareSpy);
|
|
1627
|
+
|
|
1628
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1629
|
+
mockSchedule.getCurrentLayouts.mockReturnValue(['100.xlf']);
|
|
1630
|
+
core.setCurrentLayout(100);
|
|
1631
|
+
|
|
1632
|
+
core.collectOffline();
|
|
1633
|
+
|
|
1634
|
+
expect(alreadySpy).toHaveBeenCalledWith(100);
|
|
1635
|
+
expect(prepareSpy).not.toHaveBeenCalled();
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it('collectOffline should emit collection-complete', () => {
|
|
1639
|
+
const spy = createSpy();
|
|
1640
|
+
core.on('collection-complete', spy);
|
|
1641
|
+
|
|
1642
|
+
core._offlineCache = { schedule: { default: '0', layouts: [] }, settings: null, requiredFiles: null };
|
|
1643
|
+
mockSchedule.getCurrentLayouts.mockReturnValue([]);
|
|
1644
|
+
|
|
1645
|
+
core.collectOffline();
|
|
1646
|
+
|
|
1647
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
it('collectNow should clear CRC checksums and trigger collect', async () => {
|
|
1651
|
+
core._lastCheckRf = 'old-crc';
|
|
1652
|
+
core._lastCheckSchedule = 'old-sched';
|
|
1653
|
+
|
|
1654
|
+
const spy = createSpy();
|
|
1655
|
+
core.on('collection-start', spy);
|
|
1656
|
+
|
|
1657
|
+
await core.collectNow();
|
|
1658
|
+
|
|
1659
|
+
// collectNow clears checksums then calls collect, which re-fetches and sets new values
|
|
1660
|
+
expect(core._lastCheckRf).not.toBe('old-crc');
|
|
1661
|
+
expect(core._lastCheckSchedule).not.toBe('old-sched');
|
|
1662
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
it('isInOfflineMode should reflect current offline mode state', () => {
|
|
1666
|
+
expect(core.isInOfflineMode()).toBe(false);
|
|
1667
|
+
|
|
1668
|
+
core.offlineMode = true;
|
|
1669
|
+
expect(core.isInOfflineMode()).toBe(true);
|
|
1670
|
+
|
|
1671
|
+
core.offlineMode = false;
|
|
1672
|
+
expect(core.isInOfflineMode()).toBe(false);
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
// ============================================================================
|
|
1677
|
+
// Download Priority / File Prioritization Tests
|
|
1678
|
+
// ============================================================================
|
|
1679
|
+
|
|
1680
|
+
describe('prioritizeFilesByLayout()', () => {
|
|
1681
|
+
it('should put current layout XLFs first (tier 0)', () => {
|
|
1682
|
+
const files = [
|
|
1683
|
+
{ id: '100', type: 'media', size: 500 },
|
|
1684
|
+
{ id: '87', type: 'layout', size: 1000 },
|
|
1685
|
+
{ id: '78', type: 'layout', size: 800 }
|
|
1686
|
+
];
|
|
1687
|
+
const currentLayouts = ['87.xlf'];
|
|
1688
|
+
|
|
1689
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1690
|
+
|
|
1691
|
+
// Layout 87 is the current layout → tier 0 (first)
|
|
1692
|
+
expect(result[0].id).toBe('87');
|
|
1693
|
+
expect(result[0].type).toBe('layout');
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
it('should put other layout XLFs in tier 1 (after current)', () => {
|
|
1697
|
+
const files = [
|
|
1698
|
+
{ id: '78', type: 'layout', size: 800 },
|
|
1699
|
+
{ id: '87', type: 'layout', size: 1000 },
|
|
1700
|
+
{ id: '81', type: 'layout', size: 900 }
|
|
1701
|
+
];
|
|
1702
|
+
const currentLayouts = ['87.xlf'];
|
|
1703
|
+
|
|
1704
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1705
|
+
|
|
1706
|
+
// Tier 0: layout 87 (current)
|
|
1707
|
+
expect(result[0].id).toBe('87');
|
|
1708
|
+
// Tier 1: other layouts (78, 81 sorted by size within tier)
|
|
1709
|
+
expect(result[1].type).toBe('layout');
|
|
1710
|
+
expect(result[2].type).toBe('layout');
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
it('should put resources (fonts, bundle) in tier 2', () => {
|
|
1714
|
+
const files = [
|
|
1715
|
+
{ id: '100', type: 'media', size: 50000 },
|
|
1716
|
+
{ id: 'fonts', type: 'resource', code: 'fonts.css', size: 100 },
|
|
1717
|
+
{ id: '87', type: 'layout', size: 1000 }
|
|
1718
|
+
];
|
|
1719
|
+
const currentLayouts = ['87.xlf'];
|
|
1720
|
+
|
|
1721
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1722
|
+
|
|
1723
|
+
// Order: layout (tier 0) → resource (tier 2) → media (tier 3)
|
|
1724
|
+
expect(result[0].type).toBe('layout');
|
|
1725
|
+
expect(result[1].type).toBe('resource');
|
|
1726
|
+
expect(result[2].type).toBe('media');
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
it('should put bundle.min.js in tier 2 (resource tier)', () => {
|
|
1730
|
+
const files = [
|
|
1731
|
+
{ id: '100', type: 'media', size: 50000 },
|
|
1732
|
+
{ id: 'bundle', type: 'media', path: 'http://cms/xmds.php?file=bundle.min.js', size: 200 },
|
|
1733
|
+
{ id: '87', type: 'layout', size: 1000 }
|
|
1734
|
+
];
|
|
1735
|
+
const currentLayouts = ['87.xlf'];
|
|
1736
|
+
|
|
1737
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1738
|
+
|
|
1739
|
+
// bundle.min.js path match → tier 2 (before regular media)
|
|
1740
|
+
expect(result[0].type).toBe('layout');
|
|
1741
|
+
expect(result[1].path).toContain('bundle.min');
|
|
1742
|
+
expect(result[2].type).toBe('media');
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it('should sort media by ascending size within tier 3', () => {
|
|
1746
|
+
const files = [
|
|
1747
|
+
{ id: '10', type: 'media', size: 272000000 }, // 272MB video
|
|
1748
|
+
{ id: '11', type: 'media', size: 1000 }, // 1KB image
|
|
1749
|
+
{ id: '12', type: 'media', size: 50000 } // 50KB image
|
|
1750
|
+
];
|
|
1751
|
+
const currentLayouts = [];
|
|
1752
|
+
|
|
1753
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1754
|
+
|
|
1755
|
+
// All tier 3 → sorted by size ascending
|
|
1756
|
+
expect(result[0].size).toBe(1000);
|
|
1757
|
+
expect(result[1].size).toBe(50000);
|
|
1758
|
+
expect(result[2].size).toBe(272000000);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it('should handle multiple current layouts', () => {
|
|
1762
|
+
const files = [
|
|
1763
|
+
{ id: '78', type: 'layout', size: 800 },
|
|
1764
|
+
{ id: '87', type: 'layout', size: 1000 },
|
|
1765
|
+
{ id: '81', type: 'layout', size: 900 },
|
|
1766
|
+
{ id: '99', type: 'layout', size: 700 }
|
|
1767
|
+
];
|
|
1768
|
+
const currentLayouts = ['87.xlf', '81.xlf', '78.xlf'];
|
|
1769
|
+
|
|
1770
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1771
|
+
|
|
1772
|
+
// Tier 0: current layouts (87, 81, 78)
|
|
1773
|
+
// Tier 1: other layouts (99)
|
|
1774
|
+
const currentIds = result.slice(0, 3).map(f => f.id).sort();
|
|
1775
|
+
expect(currentIds).toEqual(['78', '81', '87']);
|
|
1776
|
+
expect(result[3].id).toBe('99');
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it('should maintain complete file list (no files lost)', () => {
|
|
1780
|
+
const files = [
|
|
1781
|
+
{ id: '1', type: 'media', size: 100 },
|
|
1782
|
+
{ id: '2', type: 'layout', size: 200 },
|
|
1783
|
+
{ id: '3', type: 'resource', code: 'fonts.css', size: 50 },
|
|
1784
|
+
{ id: '4', type: 'media', size: 300 },
|
|
1785
|
+
{ id: '5', type: 'layout', size: 150 }
|
|
1786
|
+
];
|
|
1787
|
+
const currentLayouts = ['2.xlf'];
|
|
1788
|
+
|
|
1789
|
+
const result = core.prioritizeFilesByLayout(files, currentLayouts);
|
|
1790
|
+
|
|
1791
|
+
expect(result.length).toBe(files.length);
|
|
1792
|
+
const resultIds = result.map(f => f.id).sort();
|
|
1793
|
+
expect(resultIds).toEqual(['1', '2', '3', '4', '5']);
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
});
|