@xiboplayer/xmds 0.3.7 → 0.4.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/package.json +2 -2
- package/src/rest-client.js +80 -11
- package/src/schedule-parser.js +80 -20
- package/src/schedule-parser.test.js +282 -0
- package/src/xmds-client.js +78 -9
- package/src/xmds.overlays.test.js +2 -2
- package/src/xmds.rest.test.js +53 -21
- package/src/xmds.test.js +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/xmds",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "XMDS SOAP client for Xibo CMS communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./xmds": "./src/xmds.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@xiboplayer/utils": "0.
|
|
12
|
+
"@xiboplayer/utils": "0.4.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"vitest": "^2.0.0"
|
package/src/rest-client.js
CHANGED
|
@@ -142,13 +142,14 @@ export class RestClient {
|
|
|
142
142
|
|
|
143
143
|
const json = await this.restSend('POST', '/register', {
|
|
144
144
|
displayName: this.config.displayName,
|
|
145
|
-
clientType: 'chromeOS',
|
|
146
|
-
clientVersion: '0.1.0',
|
|
147
|
-
clientCode: 1,
|
|
145
|
+
clientType: this.config.clientType || 'chromeOS',
|
|
146
|
+
clientVersion: this.config.clientVersion || '0.1.0',
|
|
147
|
+
clientCode: this.config.clientCode || 1,
|
|
148
148
|
operatingSystem: os,
|
|
149
149
|
macAddress: this.config.macAddress || 'n/a',
|
|
150
150
|
xmrChannel: this.config.xmrChannel,
|
|
151
151
|
xmrPubKey: this.config.xmrPubKey || '',
|
|
152
|
+
licenceResult: 'licensed',
|
|
152
153
|
});
|
|
153
154
|
|
|
154
155
|
return this._parseRegisterDisplayJson(json);
|
|
@@ -169,14 +170,42 @@ export class RestClient {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
const settings = {};
|
|
173
|
+
let tags = [];
|
|
174
|
+
let commands = [];
|
|
172
175
|
for (const [key, value] of Object.entries(display)) {
|
|
173
|
-
if (key === '@attributes' || key === '
|
|
176
|
+
if (key === '@attributes' || key === 'file') continue;
|
|
177
|
+
if (key === 'commands') {
|
|
178
|
+
// Parse commands: array of {code/commandCode, commandString} objects
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
commands = value.map(c => ({
|
|
181
|
+
commandCode: c.code || c.commandCode || '',
|
|
182
|
+
commandString: c.commandString || ''
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (key === 'tags') {
|
|
188
|
+
// Parse tags: array of strings, or array of {tag: "value"} objects
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
tags = value.map(t => typeof t === 'object' ? (t.tag || t.value || '') : String(t)).filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
174
194
|
settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
const checkRf = attrs.checkRf || '';
|
|
178
198
|
const checkSchedule = attrs.checkSchedule || '';
|
|
179
199
|
|
|
200
|
+
// Extract display-level attributes from CMS (server time, status, version info)
|
|
201
|
+
const displayAttrs = {
|
|
202
|
+
date: attrs.date || display.date || null,
|
|
203
|
+
timezone: attrs.timezone || display.timezone || null,
|
|
204
|
+
status: attrs.status || display.status || null,
|
|
205
|
+
localDate: attrs.localDate || display.localDate || null,
|
|
206
|
+
version_instructions: attrs.version_instructions || display.version_instructions || null,
|
|
207
|
+
};
|
|
208
|
+
|
|
180
209
|
// Extract sync group config if present (multi-display sync coordination)
|
|
181
210
|
// syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
|
|
182
211
|
const syncConfig = display.syncGroup ? {
|
|
@@ -187,7 +216,7 @@ export class RestClient {
|
|
|
187
216
|
isLead: String(display.syncGroup) === 'lead',
|
|
188
217
|
} : null;
|
|
189
218
|
|
|
190
|
-
return { code, message, settings, checkRf, checkSchedule, syncConfig };
|
|
219
|
+
return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
|
|
191
220
|
}
|
|
192
221
|
|
|
193
222
|
/**
|
|
@@ -221,6 +250,8 @@ export class RestClient {
|
|
|
221
250
|
md5: attrs.md5 || null,
|
|
222
251
|
download: attrs.download || null,
|
|
223
252
|
path,
|
|
253
|
+
saveAs: attrs.saveAs || null,
|
|
254
|
+
fileType: attrs.fileType || null,
|
|
224
255
|
code: attrs.code || null,
|
|
225
256
|
layoutid: attrs.layoutid || null,
|
|
226
257
|
regionid: attrs.regionid || null,
|
|
@@ -228,7 +259,19 @@ export class RestClient {
|
|
|
228
259
|
});
|
|
229
260
|
}
|
|
230
261
|
|
|
231
|
-
|
|
262
|
+
// Parse purge items — files CMS wants the player to delete
|
|
263
|
+
const purgeItems = [];
|
|
264
|
+
let purgeList = json.purge?.item || [];
|
|
265
|
+
if (!Array.isArray(purgeList)) purgeList = [purgeList];
|
|
266
|
+
for (const p of purgeList) {
|
|
267
|
+
const pAttrs = p['@attributes'] || p;
|
|
268
|
+
purgeItems.push({
|
|
269
|
+
id: pAttrs.id || null,
|
|
270
|
+
storedAs: pAttrs.storedAs || null,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { files, purge: purgeItems };
|
|
232
275
|
}
|
|
233
276
|
|
|
234
277
|
/**
|
|
@@ -272,6 +315,11 @@ export class RestClient {
|
|
|
272
315
|
status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
273
316
|
}
|
|
274
317
|
|
|
318
|
+
// Add statusDialog (summary for CMS display status page) if not provided
|
|
319
|
+
if (!status.statusDialog) {
|
|
320
|
+
status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
275
323
|
return this.restSend('PUT', '/status', {
|
|
276
324
|
statusData: status,
|
|
277
325
|
});
|
|
@@ -291,13 +339,25 @@ export class RestClient {
|
|
|
291
339
|
|
|
292
340
|
/**
|
|
293
341
|
* BlackList - report broken media to CMS
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
342
|
+
* POST /blacklist → JSON acknowledgement
|
|
343
|
+
* @param {string|number} mediaId - The media ID
|
|
344
|
+
* @param {string} type - File type ('media' or 'layout')
|
|
345
|
+
* @param {string} reason - Reason for blacklisting
|
|
346
|
+
* @returns {Promise<boolean>}
|
|
297
347
|
*/
|
|
298
348
|
async blackList(mediaId, type, reason) {
|
|
299
|
-
|
|
300
|
-
|
|
349
|
+
try {
|
|
350
|
+
const result = await this.restSend('POST', '/blacklist', {
|
|
351
|
+
mediaId: String(mediaId),
|
|
352
|
+
type: type || 'media',
|
|
353
|
+
reason: reason || 'Failed to render',
|
|
354
|
+
});
|
|
355
|
+
log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
|
|
356
|
+
return result?.success === true;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
log.warn('BlackList failed:', error);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
301
361
|
}
|
|
302
362
|
|
|
303
363
|
/**
|
|
@@ -337,6 +397,15 @@ export class RestClient {
|
|
|
337
397
|
return result?.success === true;
|
|
338
398
|
}
|
|
339
399
|
|
|
400
|
+
/**
|
|
401
|
+
* GetWeather - get current weather data for schedule criteria
|
|
402
|
+
* GET /weather → JSON weather data
|
|
403
|
+
* @returns {Promise<Object>} Weather data from CMS
|
|
404
|
+
*/
|
|
405
|
+
async getWeather() {
|
|
406
|
+
return this.restGet('/weather');
|
|
407
|
+
}
|
|
408
|
+
|
|
340
409
|
async submitStats(statsXml) {
|
|
341
410
|
try {
|
|
342
411
|
// Accept array (JSON-native) or string (XML) — send under the right key
|
package/src/schedule-parser.js
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
function parseCriteria(parentEl) {
|
|
18
18
|
const criteria = [];
|
|
19
|
-
for (const
|
|
19
|
+
for (const child of parentEl.children) {
|
|
20
|
+
if (child.tagName !== 'criteria') continue;
|
|
20
21
|
criteria.push({
|
|
21
|
-
metric:
|
|
22
|
-
condition:
|
|
23
|
-
type:
|
|
24
|
-
value:
|
|
22
|
+
metric: child.getAttribute('metric') || '',
|
|
23
|
+
condition: child.getAttribute('condition') || '',
|
|
24
|
+
type: child.getAttribute('type') || 'string',
|
|
25
|
+
value: child.textContent || ''
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
return criteria;
|
|
@@ -39,6 +40,8 @@ export function parseScheduleResponse(xml) {
|
|
|
39
40
|
|
|
40
41
|
const schedule = {
|
|
41
42
|
default: null,
|
|
43
|
+
defaultDependants: [],
|
|
44
|
+
dependants: [], // Global dependants that gate ALL layouts
|
|
42
45
|
layouts: [],
|
|
43
46
|
campaigns: [],
|
|
44
47
|
overlays: [],
|
|
@@ -47,9 +50,29 @@ export function parseScheduleResponse(xml) {
|
|
|
47
50
|
dataConnectors: []
|
|
48
51
|
};
|
|
49
52
|
|
|
53
|
+
// Parse global dependants (root-level <dependants> — must be cached before any layout plays)
|
|
54
|
+
const scheduleEl = doc.querySelector('schedule');
|
|
55
|
+
if (scheduleEl) {
|
|
56
|
+
const globalDeps = Array.from(scheduleEl.children).filter(
|
|
57
|
+
el => el.tagName === 'dependants'
|
|
58
|
+
);
|
|
59
|
+
for (const depContainer of globalDeps) {
|
|
60
|
+
// Skip if this is nested inside <default>, <layout>, etc.
|
|
61
|
+
if (depContainer.parentElement !== scheduleEl) continue;
|
|
62
|
+
for (const fileEl of depContainer.querySelectorAll('file')) {
|
|
63
|
+
if (fileEl.textContent) schedule.dependants.push(fileEl.textContent);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
const defaultEl = doc.querySelector('default');
|
|
51
69
|
if (defaultEl) {
|
|
52
70
|
schedule.default = defaultEl.getAttribute('file');
|
|
71
|
+
// Parse dependants — files that must be cached before this layout plays
|
|
72
|
+
const defaultDeps = defaultEl.querySelectorAll('dependants > file');
|
|
73
|
+
if (defaultDeps.length > 0) {
|
|
74
|
+
schedule.defaultDependants = [...defaultDeps].map(el => el.textContent);
|
|
75
|
+
}
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
// Parse campaigns (groups of layouts with shared priority)
|
|
@@ -60,12 +83,23 @@ export function parseScheduleResponse(xml) {
|
|
|
60
83
|
fromdt: campaignEl.getAttribute('fromdt'),
|
|
61
84
|
todt: campaignEl.getAttribute('todt'),
|
|
62
85
|
scheduleid: campaignEl.getAttribute('scheduleid'),
|
|
86
|
+
maxPlaysPerHour: parseInt(campaignEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
87
|
+
shareOfVoice: parseInt(campaignEl.getAttribute('shareOfVoice') || '0'),
|
|
88
|
+
isGeoAware: campaignEl.getAttribute('isGeoAware') === '1',
|
|
89
|
+
geoLocation: campaignEl.getAttribute('geoLocation') || '',
|
|
90
|
+
syncEvent: campaignEl.getAttribute('syncEvent') === '1',
|
|
91
|
+
recurrenceType: campaignEl.getAttribute('recurrenceType') || null,
|
|
92
|
+
recurrenceDetail: parseInt(campaignEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
93
|
+
recurrenceRepeatsOn: campaignEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
94
|
+
recurrenceRange: campaignEl.getAttribute('recurrenceRange') || null,
|
|
95
|
+
criteria: parseCriteria(campaignEl),
|
|
63
96
|
layouts: []
|
|
64
97
|
};
|
|
65
98
|
|
|
66
99
|
// Parse layouts within this campaign
|
|
67
100
|
for (const layoutEl of campaignEl.querySelectorAll('layout')) {
|
|
68
101
|
const fileId = layoutEl.getAttribute('file');
|
|
102
|
+
const depEls = layoutEl.querySelectorAll('dependants > file');
|
|
69
103
|
campaign.layouts.push({
|
|
70
104
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
71
105
|
file: fileId,
|
|
@@ -80,6 +114,11 @@ export function parseScheduleResponse(xml) {
|
|
|
80
114
|
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
81
115
|
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
82
116
|
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
117
|
+
duration: parseInt(layoutEl.getAttribute('duration') || '0'),
|
|
118
|
+
cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
|
|
119
|
+
groupKey: layoutEl.getAttribute('groupKey') || null,
|
|
120
|
+
playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
|
|
121
|
+
dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
|
|
83
122
|
criteria: parseCriteria(layoutEl)
|
|
84
123
|
});
|
|
85
124
|
}
|
|
@@ -90,6 +129,7 @@ export function parseScheduleResponse(xml) {
|
|
|
90
129
|
// Parse standalone layouts (not in campaigns)
|
|
91
130
|
for (const layoutEl of doc.querySelectorAll('schedule > layout')) {
|
|
92
131
|
const fileId = layoutEl.getAttribute('file');
|
|
132
|
+
const depEls = layoutEl.querySelectorAll('dependants > file');
|
|
93
133
|
schedule.layouts.push({
|
|
94
134
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
95
135
|
file: fileId,
|
|
@@ -103,6 +143,15 @@ export function parseScheduleResponse(xml) {
|
|
|
103
143
|
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
104
144
|
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
105
145
|
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
146
|
+
duration: parseInt(layoutEl.getAttribute('duration') || '0'),
|
|
147
|
+
cyclePlayback: layoutEl.getAttribute('cyclePlayback') === '1',
|
|
148
|
+
groupKey: layoutEl.getAttribute('groupKey') || null,
|
|
149
|
+
playCount: parseInt(layoutEl.getAttribute('playCount') || '0'),
|
|
150
|
+
recurrenceType: layoutEl.getAttribute('recurrenceType') || null,
|
|
151
|
+
recurrenceDetail: parseInt(layoutEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
152
|
+
recurrenceRepeatsOn: layoutEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
153
|
+
recurrenceRange: layoutEl.getAttribute('recurrenceRange') || null,
|
|
154
|
+
dependants: depEls.length > 0 ? [...depEls].map(el => el.textContent) : [],
|
|
106
155
|
criteria: parseCriteria(layoutEl)
|
|
107
156
|
});
|
|
108
157
|
}
|
|
@@ -116,14 +165,18 @@ export function parseScheduleResponse(xml) {
|
|
|
116
165
|
id: String(fileId), // Normalized string ID for consistent type usage
|
|
117
166
|
duration: parseInt(overlayEl.getAttribute('duration') || '60'),
|
|
118
167
|
file: fileId,
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
fromdt: overlayEl.getAttribute('fromdt'),
|
|
169
|
+
todt: overlayEl.getAttribute('todt'),
|
|
121
170
|
priority: parseInt(overlayEl.getAttribute('priority') || '0'),
|
|
122
|
-
|
|
171
|
+
scheduleid: overlayEl.getAttribute('scheduleid'),
|
|
123
172
|
isGeoAware: overlayEl.getAttribute('isGeoAware') === '1',
|
|
124
173
|
geoLocation: overlayEl.getAttribute('geoLocation') || '',
|
|
125
174
|
syncEvent: overlayEl.getAttribute('syncEvent') === '1',
|
|
126
175
|
maxPlaysPerHour: parseInt(overlayEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
176
|
+
recurrenceType: overlayEl.getAttribute('recurrenceType') || null,
|
|
177
|
+
recurrenceDetail: parseInt(overlayEl.getAttribute('recurrenceDetail') || '0') || null,
|
|
178
|
+
recurrenceRepeatsOn: overlayEl.getAttribute('recurrenceRepeatsOn') || null,
|
|
179
|
+
recurrenceRange: overlayEl.getAttribute('recurrenceRange') || null,
|
|
127
180
|
criteria: parseCriteria(overlayEl)
|
|
128
181
|
});
|
|
129
182
|
}
|
|
@@ -139,10 +192,10 @@ export function parseScheduleResponse(xml) {
|
|
|
139
192
|
layoutCode: actionEl.getAttribute('layoutCode') || '',
|
|
140
193
|
commandCode: actionEl.getAttribute('commandCode') || '',
|
|
141
194
|
duration: parseInt(actionEl.getAttribute('duration') || '0'),
|
|
142
|
-
|
|
143
|
-
|
|
195
|
+
fromdt: actionEl.getAttribute('fromdt'),
|
|
196
|
+
todt: actionEl.getAttribute('todt'),
|
|
144
197
|
priority: parseInt(actionEl.getAttribute('priority') || '0'),
|
|
145
|
-
|
|
198
|
+
scheduleid: actionEl.getAttribute('scheduleid'),
|
|
146
199
|
isGeoAware: actionEl.getAttribute('isGeoAware') === '1',
|
|
147
200
|
geoLocation: actionEl.getAttribute('geoLocation') || ''
|
|
148
201
|
});
|
|
@@ -152,20 +205,27 @@ export function parseScheduleResponse(xml) {
|
|
|
152
205
|
// Parse server commands (remote control)
|
|
153
206
|
for (const cmdEl of doc.querySelectorAll('schedule > command')) {
|
|
154
207
|
schedule.commands.push({
|
|
155
|
-
code: cmdEl.getAttribute('
|
|
208
|
+
code: cmdEl.getAttribute('code') || '',
|
|
156
209
|
date: cmdEl.getAttribute('date') || ''
|
|
157
210
|
});
|
|
158
211
|
}
|
|
159
212
|
|
|
160
213
|
// Parse data connectors (real-time data sources for widgets)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
214
|
+
// Spec: <dataConnectors><connector dataSetId="" dataParams="" js=""/></dataConnectors>
|
|
215
|
+
const dataConnectorsContainer = doc.querySelector('dataConnectors');
|
|
216
|
+
if (dataConnectorsContainer) {
|
|
217
|
+
for (const dcEl of dataConnectorsContainer.querySelectorAll('connector')) {
|
|
218
|
+
schedule.dataConnectors.push({
|
|
219
|
+
id: dcEl.getAttribute('id') || '',
|
|
220
|
+
dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
|
|
221
|
+
dataSetId: dcEl.getAttribute('dataSetId') || '',
|
|
222
|
+
dataKey: dcEl.getAttribute('dataKey') || '',
|
|
223
|
+
dataParams: dcEl.getAttribute('dataParams') || '',
|
|
224
|
+
js: dcEl.getAttribute('js') || '',
|
|
225
|
+
url: dcEl.getAttribute('url') || '',
|
|
226
|
+
updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
return schedule;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule Parser Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for XML parsing of schedule responses, ensuring all attributes
|
|
5
|
+
* are correctly extracted — especially recurrence/dayparting fields.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { parseScheduleResponse } from './schedule-parser.js';
|
|
10
|
+
|
|
11
|
+
describe('parseScheduleResponse', () => {
|
|
12
|
+
it('should parse default layout', () => {
|
|
13
|
+
const xml = '<schedule><default file="100.xlf"/></schedule>';
|
|
14
|
+
const result = parseScheduleResponse(xml);
|
|
15
|
+
expect(result.default).toBe('100.xlf');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should parse standalone layout with basic attributes', () => {
|
|
19
|
+
const xml = `<schedule>
|
|
20
|
+
<layout file="200.xlf" fromdt="2025-01-01 09:00:00" todt="2025-12-31 17:00:00"
|
|
21
|
+
scheduleid="5" priority="3"/>
|
|
22
|
+
</schedule>`;
|
|
23
|
+
const result = parseScheduleResponse(xml);
|
|
24
|
+
expect(result.layouts).toHaveLength(1);
|
|
25
|
+
expect(result.layouts[0].file).toBe('200.xlf');
|
|
26
|
+
expect(result.layouts[0].priority).toBe(3);
|
|
27
|
+
expect(result.layouts[0].scheduleid).toBe('5');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should parse recurrence attributes on standalone layouts', () => {
|
|
31
|
+
const xml = `<schedule>
|
|
32
|
+
<layout file="300.xlf" fromdt="2025-01-06 09:00:00" todt="2025-01-06 17:00:00"
|
|
33
|
+
scheduleid="10" priority="1"
|
|
34
|
+
recurrenceType="Week"
|
|
35
|
+
recurrenceRepeatsOn="1,2,3,4,5"
|
|
36
|
+
recurrenceRange="2025-12-31 23:59:59"/>
|
|
37
|
+
</schedule>`;
|
|
38
|
+
const result = parseScheduleResponse(xml);
|
|
39
|
+
const layout = result.layouts[0];
|
|
40
|
+
|
|
41
|
+
expect(layout.recurrenceType).toBe('Week');
|
|
42
|
+
expect(layout.recurrenceRepeatsOn).toBe('1,2,3,4,5');
|
|
43
|
+
expect(layout.recurrenceRange).toBe('2025-12-31 23:59:59');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should set recurrence fields to null when absent', () => {
|
|
47
|
+
const xml = `<schedule>
|
|
48
|
+
<layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
49
|
+
scheduleid="20" priority="0"/>
|
|
50
|
+
</schedule>`;
|
|
51
|
+
const result = parseScheduleResponse(xml);
|
|
52
|
+
const layout = result.layouts[0];
|
|
53
|
+
|
|
54
|
+
expect(layout.recurrenceType).toBeNull();
|
|
55
|
+
expect(layout.recurrenceRepeatsOn).toBeNull();
|
|
56
|
+
expect(layout.recurrenceRange).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should parse recurrence attributes on campaigns', () => {
|
|
60
|
+
const xml = `<schedule>
|
|
61
|
+
<campaign id="c1" priority="5" fromdt="2025-01-06 08:00:00" todt="2025-01-06 18:00:00"
|
|
62
|
+
scheduleid="30"
|
|
63
|
+
recurrenceType="Week"
|
|
64
|
+
recurrenceRepeatsOn="6,7">
|
|
65
|
+
<layout file="500.xlf"/>
|
|
66
|
+
<layout file="501.xlf"/>
|
|
67
|
+
</campaign>
|
|
68
|
+
</schedule>`;
|
|
69
|
+
const result = parseScheduleResponse(xml);
|
|
70
|
+
|
|
71
|
+
expect(result.campaigns).toHaveLength(1);
|
|
72
|
+
const campaign = result.campaigns[0];
|
|
73
|
+
expect(campaign.recurrenceType).toBe('Week');
|
|
74
|
+
expect(campaign.recurrenceRepeatsOn).toBe('6,7');
|
|
75
|
+
expect(campaign.recurrenceRange).toBeNull();
|
|
76
|
+
expect(campaign.layouts).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should parse recurrence attributes on overlays', () => {
|
|
80
|
+
const xml = `<schedule>
|
|
81
|
+
<overlays>
|
|
82
|
+
<overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
|
|
83
|
+
scheduleid="40" priority="2" duration="30"
|
|
84
|
+
recurrenceType="Week"
|
|
85
|
+
recurrenceRepeatsOn="1,3,5"/>
|
|
86
|
+
</overlays>
|
|
87
|
+
</schedule>`;
|
|
88
|
+
const result = parseScheduleResponse(xml);
|
|
89
|
+
|
|
90
|
+
expect(result.overlays).toHaveLength(1);
|
|
91
|
+
const overlay = result.overlays[0];
|
|
92
|
+
expect(overlay.recurrenceType).toBe('Week');
|
|
93
|
+
expect(overlay.recurrenceRepeatsOn).toBe('1,3,5');
|
|
94
|
+
expect(overlay.recurrenceRange).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should parse criteria on layouts', () => {
|
|
98
|
+
const xml = `<schedule>
|
|
99
|
+
<layout file="700.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
100
|
+
scheduleid="50" priority="1">
|
|
101
|
+
<criteria metric="dayOfWeek" condition="equals" type="string">Monday</criteria>
|
|
102
|
+
</layout>
|
|
103
|
+
</schedule>`;
|
|
104
|
+
const result = parseScheduleResponse(xml);
|
|
105
|
+
const layout = result.layouts[0];
|
|
106
|
+
|
|
107
|
+
expect(layout.criteria).toHaveLength(1);
|
|
108
|
+
expect(layout.criteria[0].metric).toBe('dayOfWeek');
|
|
109
|
+
expect(layout.criteria[0].condition).toBe('equals');
|
|
110
|
+
expect(layout.criteria[0].value).toBe('Monday');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should parse dependants on default layout', () => {
|
|
114
|
+
const xml = `<schedule>
|
|
115
|
+
<default file="100.xlf">
|
|
116
|
+
<dependants><file>bg.jpg</file><file>logo.png</file></dependants>
|
|
117
|
+
</default>
|
|
118
|
+
</schedule>`;
|
|
119
|
+
const result = parseScheduleResponse(xml);
|
|
120
|
+
expect(result.default).toBe('100.xlf');
|
|
121
|
+
expect(result.defaultDependants).toEqual(['bg.jpg', 'logo.png']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should parse dependants on standalone layouts', () => {
|
|
125
|
+
const xml = `<schedule>
|
|
126
|
+
<layout file="200.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
127
|
+
scheduleid="5" priority="1">
|
|
128
|
+
<dependants><file>video.mp4</file></dependants>
|
|
129
|
+
</layout>
|
|
130
|
+
</schedule>`;
|
|
131
|
+
const result = parseScheduleResponse(xml);
|
|
132
|
+
expect(result.layouts[0].dependants).toEqual(['video.mp4']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should parse dependants on campaign layouts', () => {
|
|
136
|
+
const xml = `<schedule>
|
|
137
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
138
|
+
scheduleid="30">
|
|
139
|
+
<layout file="300.xlf">
|
|
140
|
+
<dependants><file>font.woff2</file></dependants>
|
|
141
|
+
</layout>
|
|
142
|
+
</campaign>
|
|
143
|
+
</schedule>`;
|
|
144
|
+
const result = parseScheduleResponse(xml);
|
|
145
|
+
expect(result.campaigns[0].layouts[0].dependants).toEqual(['font.woff2']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return empty array when no dependants', () => {
|
|
149
|
+
const xml = `<schedule>
|
|
150
|
+
<layout file="400.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
151
|
+
scheduleid="20" priority="0"/>
|
|
152
|
+
</schedule>`;
|
|
153
|
+
const result = parseScheduleResponse(xml);
|
|
154
|
+
expect(result.layouts[0].dependants).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should parse duration, cyclePlayback, groupKey, playCount on standalone layouts', () => {
|
|
158
|
+
const xml = `<schedule>
|
|
159
|
+
<layout file="500.xlf" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
160
|
+
scheduleid="60" priority="1" duration="120"
|
|
161
|
+
cyclePlayback="1" groupKey="group-A" playCount="3"/>
|
|
162
|
+
</schedule>`;
|
|
163
|
+
const result = parseScheduleResponse(xml);
|
|
164
|
+
const layout = result.layouts[0];
|
|
165
|
+
expect(layout.duration).toBe(120);
|
|
166
|
+
expect(layout.cyclePlayback).toBe(true);
|
|
167
|
+
expect(layout.groupKey).toBe('group-A');
|
|
168
|
+
expect(layout.playCount).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should parse command code attribute correctly', () => {
|
|
172
|
+
const xml = `<schedule>
|
|
173
|
+
<command code="collectNow" date="2026-01-01"/>
|
|
174
|
+
<command code="reboot" date="2026-01-02"/>
|
|
175
|
+
</schedule>`;
|
|
176
|
+
const result = parseScheduleResponse(xml);
|
|
177
|
+
expect(result.commands).toHaveLength(2);
|
|
178
|
+
expect(result.commands[0].code).toBe('collectNow');
|
|
179
|
+
expect(result.commands[1].code).toBe('reboot');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should parse dataConnectors with connector child elements', () => {
|
|
183
|
+
const xml = `<schedule>
|
|
184
|
+
<dataConnectors>
|
|
185
|
+
<connector id="dc1" dataSetId="42" dataParams="limit=10" js="render.js"
|
|
186
|
+
url="http://cms.example.com/data" updateInterval="60"/>
|
|
187
|
+
</dataConnectors>
|
|
188
|
+
</schedule>`;
|
|
189
|
+
const result = parseScheduleResponse(xml);
|
|
190
|
+
expect(result.dataConnectors).toHaveLength(1);
|
|
191
|
+
expect(result.dataConnectors[0].id).toBe('dc1');
|
|
192
|
+
expect(result.dataConnectors[0].dataSetId).toBe('42');
|
|
193
|
+
expect(result.dataConnectors[0].dataParams).toBe('limit=10');
|
|
194
|
+
expect(result.dataConnectors[0].js).toBe('render.js');
|
|
195
|
+
expect(result.dataConnectors[0].url).toBe('http://cms.example.com/data');
|
|
196
|
+
expect(result.dataConnectors[0].updateInterval).toBe(60);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should return empty dataConnectors when no dataConnectors element', () => {
|
|
200
|
+
const xml = '<schedule><default file="0"/></schedule>';
|
|
201
|
+
const result = parseScheduleResponse(xml);
|
|
202
|
+
expect(result.dataConnectors).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should parse global dependants from schedule root', () => {
|
|
206
|
+
const xml = `<schedule>
|
|
207
|
+
<default file="0"/>
|
|
208
|
+
<dependants><file>global-font.woff2</file><file>shared-bg.jpg</file></dependants>
|
|
209
|
+
</schedule>`;
|
|
210
|
+
const result = parseScheduleResponse(xml);
|
|
211
|
+
expect(result.dependants).toEqual(['global-font.woff2', 'shared-bg.jpg']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should default global dependants to empty array', () => {
|
|
215
|
+
const xml = '<schedule><default file="0"/></schedule>';
|
|
216
|
+
const result = parseScheduleResponse(xml);
|
|
217
|
+
expect(result.dependants).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should parse campaign-level geo/sync/shareOfVoice attributes', () => {
|
|
221
|
+
const xml = `<schedule>
|
|
222
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
223
|
+
scheduleid="30" shareOfVoice="50" isGeoAware="1"
|
|
224
|
+
geoLocation="51.5,-0.1" syncEvent="1">
|
|
225
|
+
<layout file="500.xlf"/>
|
|
226
|
+
</campaign>
|
|
227
|
+
</schedule>`;
|
|
228
|
+
const result = parseScheduleResponse(xml);
|
|
229
|
+
const campaign = result.campaigns[0];
|
|
230
|
+
expect(campaign.shareOfVoice).toBe(50);
|
|
231
|
+
expect(campaign.isGeoAware).toBe(true);
|
|
232
|
+
expect(campaign.geoLocation).toBe('51.5,-0.1');
|
|
233
|
+
expect(campaign.syncEvent).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should parse criteria on campaigns', () => {
|
|
237
|
+
const xml = `<schedule>
|
|
238
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
239
|
+
scheduleid="30">
|
|
240
|
+
<criteria metric="temperature" condition="gt" type="number">25</criteria>
|
|
241
|
+
<layout file="500.xlf"/>
|
|
242
|
+
</campaign>
|
|
243
|
+
</schedule>`;
|
|
244
|
+
const result = parseScheduleResponse(xml);
|
|
245
|
+
expect(result.campaigns[0].criteria).toHaveLength(1);
|
|
246
|
+
expect(result.campaigns[0].criteria[0].metric).toBe('temperature');
|
|
247
|
+
expect(result.campaigns[0].criteria[0].value).toBe('25');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should parse duration, cyclePlayback, groupKey, playCount on campaign layouts', () => {
|
|
251
|
+
const xml = `<schedule>
|
|
252
|
+
<campaign id="c1" priority="5" fromdt="2025-01-01 00:00:00" todt="2025-12-31 23:59:59"
|
|
253
|
+
scheduleid="30">
|
|
254
|
+
<layout file="500.xlf" duration="90" cyclePlayback="1" groupKey="group-B" playCount="2"/>
|
|
255
|
+
</campaign>
|
|
256
|
+
</schedule>`;
|
|
257
|
+
const result = parseScheduleResponse(xml);
|
|
258
|
+
const layout = result.campaigns[0].layouts[0];
|
|
259
|
+
expect(layout.duration).toBe(90);
|
|
260
|
+
expect(layout.cyclePlayback).toBe(true);
|
|
261
|
+
expect(layout.groupKey).toBe('group-B');
|
|
262
|
+
expect(layout.playCount).toBe(2);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should use consistent lowercase casing for fromdt/todt/scheduleid on overlays', () => {
|
|
266
|
+
const xml = `<schedule>
|
|
267
|
+
<overlays>
|
|
268
|
+
<overlay file="600.xlf" fromdt="2025-01-01 12:00:00" todt="2025-01-01 13:00:00"
|
|
269
|
+
scheduleid="40" priority="2"/>
|
|
270
|
+
</overlays>
|
|
271
|
+
</schedule>`;
|
|
272
|
+
const result = parseScheduleResponse(xml);
|
|
273
|
+
const overlay = result.overlays[0];
|
|
274
|
+
expect(overlay.fromdt).toBe('2025-01-01 12:00:00');
|
|
275
|
+
expect(overlay.todt).toBe('2025-01-01 13:00:00');
|
|
276
|
+
expect(overlay.scheduleid).toBe('40');
|
|
277
|
+
// Verify old camelCase properties are NOT present
|
|
278
|
+
expect(overlay.fromDt).toBeUndefined();
|
|
279
|
+
expect(overlay.toDt).toBeUndefined();
|
|
280
|
+
expect(overlay.scheduleId).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
package/src/xmds-client.js
CHANGED
|
@@ -149,13 +149,14 @@ export class XmdsClient {
|
|
|
149
149
|
serverKey: this.config.cmsKey,
|
|
150
150
|
hardwareKey: this.config.hardwareKey,
|
|
151
151
|
displayName: this.config.displayName,
|
|
152
|
-
clientType: 'chromeOS',
|
|
153
|
-
clientVersion: '0.1.0',
|
|
154
|
-
clientCode: '1',
|
|
152
|
+
clientType: this.config.clientType || 'chromeOS',
|
|
153
|
+
clientVersion: this.config.clientVersion || '0.1.0',
|
|
154
|
+
clientCode: this.config.clientCode || '1',
|
|
155
155
|
operatingSystem: os,
|
|
156
156
|
macAddress: this.config.macAddress || 'n/a',
|
|
157
|
-
xmrChannel: this.config.xmrChannel,
|
|
158
|
-
xmrPubKey: this.config.xmrPubKey || ''
|
|
157
|
+
xmrChannel: this.config.xmrChannel || '',
|
|
158
|
+
xmrPubKey: this.config.xmrPubKey || '',
|
|
159
|
+
licenceResult: 'licensed'
|
|
159
160
|
});
|
|
160
161
|
|
|
161
162
|
return this.parseRegisterDisplayResponse(xml);
|
|
@@ -181,16 +182,54 @@ export class XmdsClient {
|
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
const settings = {};
|
|
185
|
+
const tags = [];
|
|
186
|
+
const commands = [];
|
|
184
187
|
for (const child of display.children) {
|
|
185
|
-
|
|
186
|
-
|
|
188
|
+
const name = child.tagName.toLowerCase();
|
|
189
|
+
if (name === 'file') continue;
|
|
190
|
+
if (name === 'commands') {
|
|
191
|
+
// Parse <commands><command code="xyz" commandString="args"/></commands>
|
|
192
|
+
for (const cmdEl of child.querySelectorAll('command')) {
|
|
193
|
+
commands.push({
|
|
194
|
+
commandCode: cmdEl.getAttribute('code') || cmdEl.getAttribute('commandCode') || '',
|
|
195
|
+
commandString: cmdEl.getAttribute('commandString') || ''
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
187
199
|
}
|
|
200
|
+
if (name === 'tags') {
|
|
201
|
+
// Parse <tags><tag>value</tag>...</tags> into array
|
|
202
|
+
for (const tagEl of child.querySelectorAll('tag')) {
|
|
203
|
+
if (tagEl.textContent) tags.push(tagEl.textContent);
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
settings[child.tagName] = child.textContent;
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
const checkRf = display.getAttribute('checkRf') || '';
|
|
191
211
|
const checkSchedule = display.getAttribute('checkSchedule') || '';
|
|
192
212
|
|
|
193
|
-
|
|
213
|
+
// Extract display-level attributes from CMS (server time, status, version info)
|
|
214
|
+
const displayAttrs = {
|
|
215
|
+
date: display.getAttribute('date') || null,
|
|
216
|
+
timezone: display.getAttribute('timezone') || null,
|
|
217
|
+
status: display.getAttribute('status') || null,
|
|
218
|
+
localDate: display.getAttribute('localDate') || null,
|
|
219
|
+
version_instructions: display.getAttribute('version_instructions') || null,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Extract sync group config if present (multi-display sync coordination)
|
|
223
|
+
const syncGroupVal = settings.syncGroup || null;
|
|
224
|
+
const syncConfig = syncGroupVal ? {
|
|
225
|
+
syncGroup: syncGroupVal,
|
|
226
|
+
syncPublisherPort: parseInt(settings.syncPublisherPort || '9590', 10),
|
|
227
|
+
syncSwitchDelay: parseInt(settings.syncSwitchDelay || '750', 10),
|
|
228
|
+
syncVideoPauseDelay: parseInt(settings.syncVideoPauseDelay || '100', 10),
|
|
229
|
+
isLead: syncGroupVal === 'lead',
|
|
230
|
+
} : null;
|
|
231
|
+
|
|
232
|
+
return { code, message, settings, tags, commands, displayAttrs, checkRf, checkSchedule, syncConfig };
|
|
194
233
|
}
|
|
195
234
|
|
|
196
235
|
/**
|
|
@@ -221,6 +260,8 @@ export class XmdsClient {
|
|
|
221
260
|
md5: fileEl.getAttribute('md5'),
|
|
222
261
|
download: fileEl.getAttribute('download'),
|
|
223
262
|
path: fileEl.getAttribute('path'),
|
|
263
|
+
saveAs: fileEl.getAttribute('saveAs') || null,
|
|
264
|
+
fileType: fileEl.getAttribute('fileType') || null,
|
|
224
265
|
code: fileEl.getAttribute('code'),
|
|
225
266
|
layoutid: fileEl.getAttribute('layoutid'),
|
|
226
267
|
regionid: fileEl.getAttribute('regionid'),
|
|
@@ -228,7 +269,19 @@ export class XmdsClient {
|
|
|
228
269
|
});
|
|
229
270
|
}
|
|
230
271
|
|
|
231
|
-
|
|
272
|
+
// Parse purge block — files CMS wants the player to delete
|
|
273
|
+
const purgeItems = [];
|
|
274
|
+
const purgeEl = doc.querySelector('purge');
|
|
275
|
+
if (purgeEl) {
|
|
276
|
+
for (const itemEl of purgeEl.querySelectorAll('item')) {
|
|
277
|
+
purgeItems.push({
|
|
278
|
+
id: itemEl.getAttribute('id'),
|
|
279
|
+
storedAs: itemEl.getAttribute('storedAs')
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { files, purge: purgeItems };
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
/**
|
|
@@ -277,6 +330,11 @@ export class XmdsClient {
|
|
|
277
330
|
status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
278
331
|
}
|
|
279
332
|
|
|
333
|
+
// Add statusDialog (summary for CMS display status page) if not provided
|
|
334
|
+
if (!status.statusDialog) {
|
|
335
|
+
status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
280
338
|
return await this.call('NotifyStatus', {
|
|
281
339
|
serverKey: this.config.cmsKey,
|
|
282
340
|
hardwareKey: this.config.hardwareKey,
|
|
@@ -367,6 +425,17 @@ export class XmdsClient {
|
|
|
367
425
|
});
|
|
368
426
|
}
|
|
369
427
|
|
|
428
|
+
/**
|
|
429
|
+
* GetWeather - get current weather data for schedule criteria
|
|
430
|
+
* @returns {Promise<string>} Weather data XML from CMS
|
|
431
|
+
*/
|
|
432
|
+
async getWeather() {
|
|
433
|
+
return this.call('GetWeather', {
|
|
434
|
+
serverKey: this.config.cmsKey,
|
|
435
|
+
hardwareKey: this.config.hardwareKey
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
370
439
|
async submitStats(statsXml) {
|
|
371
440
|
try {
|
|
372
441
|
const xml = await this.call('SubmitStats', {
|
|
@@ -25,8 +25,8 @@ describe('Schedule Parsing - Overlays', () => {
|
|
|
25
25
|
expect(schedule.overlays[0].file).toBe('101.xlf');
|
|
26
26
|
expect(schedule.overlays[0].duration).toBe(60);
|
|
27
27
|
expect(schedule.overlays[0].priority).toBe(10);
|
|
28
|
-
expect(schedule.overlays[0].
|
|
29
|
-
expect(schedule.overlays[0].
|
|
28
|
+
expect(schedule.overlays[0].fromdt).toBe('2026-01-01 00:00:00');
|
|
29
|
+
expect(schedule.overlays[0].todt).toBe('2026-12-31 23:59:59');
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it('should parse multiple overlays', () => {
|
package/src/xmds.rest.test.js
CHANGED
|
@@ -353,17 +353,18 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
353
353
|
]
|
|
354
354
|
}, { etag: '"files-v1"' }));
|
|
355
355
|
|
|
356
|
-
const
|
|
356
|
+
const result = await client.requiredFiles();
|
|
357
357
|
|
|
358
|
-
expect(files).toHaveLength(3);
|
|
359
|
-
expect(files[0]).toEqual({
|
|
358
|
+
expect(result.files).toHaveLength(3);
|
|
359
|
+
expect(result.files[0]).toEqual(expect.objectContaining({
|
|
360
360
|
type: 'media', id: '42', size: 1024, md5: 'abc', download: 'http',
|
|
361
361
|
path: '/media/42.jpg', code: null, layoutid: null, regionid: null, mediaid: null,
|
|
362
|
-
});
|
|
363
|
-
expect(files[1].type).toBe('layout');
|
|
364
|
-
expect(files[2].layoutid).toBe('10');
|
|
365
|
-
expect(files[2].regionid).toBe('5');
|
|
366
|
-
expect(files[2].mediaid).toBe('99');
|
|
362
|
+
}));
|
|
363
|
+
expect(result.files[1].type).toBe('layout');
|
|
364
|
+
expect(result.files[2].layoutid).toBe('10');
|
|
365
|
+
expect(result.files[2].regionid).toBe('5');
|
|
366
|
+
expect(result.files[2].mediaid).toBe('99');
|
|
367
|
+
expect(result.purge).toEqual([]);
|
|
367
368
|
});
|
|
368
369
|
|
|
369
370
|
it('should handle single file (not array)', async () => {
|
|
@@ -371,16 +372,16 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
371
372
|
file: { '@attributes': { type: 'media', id: '1', size: '100', md5: 'x' } }
|
|
372
373
|
}));
|
|
373
374
|
|
|
374
|
-
const
|
|
375
|
-
expect(files).toHaveLength(1);
|
|
376
|
-
expect(files[0].id).toBe('1');
|
|
375
|
+
const result = await client.requiredFiles();
|
|
376
|
+
expect(result.files).toHaveLength(1);
|
|
377
|
+
expect(result.files[0].id).toBe('1');
|
|
377
378
|
});
|
|
378
379
|
|
|
379
380
|
it('should handle empty file list', async () => {
|
|
380
381
|
mockFetch.mockResolvedValue(jsonResponse({}));
|
|
381
382
|
|
|
382
|
-
const
|
|
383
|
-
expect(files).toHaveLength(0);
|
|
383
|
+
const result = await client.requiredFiles();
|
|
384
|
+
expect(result.files).toHaveLength(0);
|
|
384
385
|
});
|
|
385
386
|
|
|
386
387
|
it('should use ETag caching', async () => {
|
|
@@ -407,12 +408,12 @@ describe('RestClient - RequiredFiles', () => {
|
|
|
407
408
|
|
|
408
409
|
const result = client._parseRequiredFilesJson(json);
|
|
409
410
|
|
|
410
|
-
expect(result[0].type).toBe('media');
|
|
411
|
-
expect(result[0].id).toBe('42');
|
|
412
|
-
expect(result[0].size).toBe(1024);
|
|
413
|
-
expect(result[0].md5).toBe('abc');
|
|
414
|
-
expect(result[0].download).toBe('http');
|
|
415
|
-
expect(result[0].path).toBe('/media/42.jpg');
|
|
411
|
+
expect(result.files[0].type).toBe('media');
|
|
412
|
+
expect(result.files[0].id).toBe('42');
|
|
413
|
+
expect(result.files[0].size).toBe(1024);
|
|
414
|
+
expect(result.files[0].md5).toBe('abc');
|
|
415
|
+
expect(result.files[0].download).toBe('http');
|
|
416
|
+
expect(result.files[0].path).toBe('/media/42.jpg');
|
|
416
417
|
});
|
|
417
418
|
});
|
|
418
419
|
|
|
@@ -676,10 +677,41 @@ describe('RestClient - MediaInventory', () => {
|
|
|
676
677
|
});
|
|
677
678
|
});
|
|
678
679
|
|
|
679
|
-
// ─── BlackList
|
|
680
|
+
// ─── BlackList ───────────────────────────────────────────────────────
|
|
680
681
|
|
|
681
682
|
describe('RestClient - BlackList', () => {
|
|
682
|
-
|
|
683
|
+
let mockFetch;
|
|
684
|
+
|
|
685
|
+
beforeEach(() => {
|
|
686
|
+
mockFetch = vi.fn();
|
|
687
|
+
global.fetch = mockFetch;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('should POST to /blacklist endpoint', async () => {
|
|
691
|
+
mockFetch.mockResolvedValueOnce({
|
|
692
|
+
ok: true,
|
|
693
|
+
status: 200,
|
|
694
|
+
headers: { get: () => 'application/json' },
|
|
695
|
+
json: () => Promise.resolve({ success: true }),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const client = createRestClient();
|
|
699
|
+
const result = await client.blackList('42', 'media', 'Broken file');
|
|
700
|
+
|
|
701
|
+
expect(result).toBe(true);
|
|
702
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
703
|
+
expect.stringContaining('/blacklist'),
|
|
704
|
+
expect.objectContaining({ method: 'POST' })
|
|
705
|
+
);
|
|
706
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
707
|
+
expect(body.mediaId).toBe('42');
|
|
708
|
+
expect(body.type).toBe('media');
|
|
709
|
+
expect(body.reason).toBe('Broken file');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should return false on failure', async () => {
|
|
713
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
714
|
+
|
|
683
715
|
const client = createRestClient();
|
|
684
716
|
const result = await client.blackList('42', 'media', 'Broken');
|
|
685
717
|
expect(result).toBe(false);
|
package/src/xmds.test.js
CHANGED
|
@@ -883,6 +883,105 @@ describe('XmdsClient - NotifyStatus', () => {
|
|
|
883
883
|
});
|
|
884
884
|
});
|
|
885
885
|
|
|
886
|
+
describe('XmdsClient - GetWeather', () => {
|
|
887
|
+
let client;
|
|
888
|
+
let mockFetch;
|
|
889
|
+
|
|
890
|
+
beforeEach(() => {
|
|
891
|
+
client = new XmdsClient({
|
|
892
|
+
cmsAddress: 'https://cms.example.com',
|
|
893
|
+
cmsKey: 'test-server-key',
|
|
894
|
+
hardwareKey: 'test-hardware-key',
|
|
895
|
+
retryOptions: { maxRetries: 0 }
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
mockFetch = vi.fn();
|
|
899
|
+
global.fetch = mockFetch;
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('should build correct SOAP envelope for GetWeather', async () => {
|
|
903
|
+
mockFetch.mockResolvedValue({
|
|
904
|
+
ok: true,
|
|
905
|
+
text: async () => `<?xml version="1.0"?>
|
|
906
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
907
|
+
<soap:Body>
|
|
908
|
+
<GetWeatherResponse>
|
|
909
|
+
<weather>{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}</weather>
|
|
910
|
+
</GetWeatherResponse>
|
|
911
|
+
</soap:Body>
|
|
912
|
+
</soap:Envelope>`
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
await client.getWeather();
|
|
916
|
+
|
|
917
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
918
|
+
'https://cms.example.com/xmds.php?v=5&method=GetWeather',
|
|
919
|
+
expect.objectContaining({
|
|
920
|
+
method: 'POST',
|
|
921
|
+
headers: {
|
|
922
|
+
'Content-Type': 'text/xml; charset=utf-8'
|
|
923
|
+
}
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
const body = mockFetch.mock.calls[0][1].body;
|
|
928
|
+
expect(body).toContain('<tns:GetWeather>');
|
|
929
|
+
expect(body).toContain('<serverKey xsi:type="xsd:string">test-server-key</serverKey>');
|
|
930
|
+
expect(body).toContain('<hardwareKey xsi:type="xsd:string">test-hardware-key</hardwareKey>');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should return weather data from SOAP response', async () => {
|
|
934
|
+
const weatherJson = '{"temperature":22,"humidity":65,"windSpeed":10,"condition":"Clear","cloudCover":15}';
|
|
935
|
+
mockFetch.mockResolvedValue({
|
|
936
|
+
ok: true,
|
|
937
|
+
text: async () => `<?xml version="1.0"?>
|
|
938
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
939
|
+
<soap:Body>
|
|
940
|
+
<GetWeatherResponse>
|
|
941
|
+
<weather>${weatherJson}</weather>
|
|
942
|
+
</GetWeatherResponse>
|
|
943
|
+
</soap:Body>
|
|
944
|
+
</soap:Envelope>`
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
const result = await client.getWeather();
|
|
948
|
+
expect(result).toBe(weatherJson);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('should handle SOAP fault', async () => {
|
|
952
|
+
mockFetch.mockResolvedValue({
|
|
953
|
+
ok: true,
|
|
954
|
+
text: async () => `<?xml version="1.0"?>
|
|
955
|
+
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
956
|
+
<soap:Body>
|
|
957
|
+
<soap:Fault>
|
|
958
|
+
<faultcode>Server</faultcode>
|
|
959
|
+
<faultstring>Weather service unavailable</faultstring>
|
|
960
|
+
</soap:Fault>
|
|
961
|
+
</soap:Body>
|
|
962
|
+
</soap:Envelope>`
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
await expect(client.getWeather()).rejects.toThrow('SOAP Fault: Weather service unavailable');
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it('should handle HTTP errors', async () => {
|
|
969
|
+
mockFetch.mockResolvedValue({
|
|
970
|
+
ok: false,
|
|
971
|
+
status: 500,
|
|
972
|
+
statusText: 'Internal Server Error'
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
await expect(client.getWeather()).rejects.toThrow('XMDS GetWeather failed: 500 Internal Server Error');
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it('should handle network errors', async () => {
|
|
979
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
980
|
+
|
|
981
|
+
await expect(client.getWeather()).rejects.toThrow('Network error');
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
886
985
|
describe('XmdsClient - MediaInventory', () => {
|
|
887
986
|
let client;
|
|
888
987
|
let mockFetch;
|