@xiboplayer/settings 0.9.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/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # @xiboplayer/settings
2
+
3
+ CMS display settings management and application for Xibo players.
4
+
5
+ ## Features
6
+
7
+ - Parse all settings from RegisterDisplay CMS response
8
+ - Validate and normalize setting values
9
+ - Dynamic collection interval updates
10
+ - Download window enforcement (same-day and overnight windows)
11
+ - Screenshot interval tracking
12
+ - Event emission on setting changes
13
+ - Comprehensive error handling
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @xiboplayer/settings
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Basic Usage
24
+
25
+ ```javascript
26
+ import { DisplaySettings } from '@xiboplayer/settings';
27
+
28
+ const displaySettings = new DisplaySettings();
29
+
30
+ // Apply settings from RegisterDisplay response
31
+ const result = displaySettings.applySettings(cmsSettings);
32
+
33
+ console.log('Changed settings:', result.changed);
34
+ console.log('Collection interval:', result.settings.collectInterval);
35
+ ```
36
+
37
+ ### With PlayerCore
38
+
39
+ ```javascript
40
+ import { PlayerCore } from '@xiboplayer/core';
41
+ import { DisplaySettings } from '@xiboplayer/settings';
42
+
43
+ const displaySettings = new DisplaySettings();
44
+
45
+ const core = new PlayerCore({
46
+ config,
47
+ xmds,
48
+ cache,
49
+ schedule,
50
+ renderer,
51
+ xmrWrapper,
52
+ displaySettings // Inject DisplaySettings
53
+ });
54
+
55
+ // Settings are automatically applied after RegisterDisplay
56
+ await core.collect();
57
+ ```
58
+
59
+ ### Event Handling
60
+
61
+ ```javascript
62
+ // Listen for collection interval changes
63
+ displaySettings.on('interval-changed', (newInterval) => {
64
+ console.log(`Collection interval changed to ${newInterval}s`);
65
+ });
66
+
67
+ // Listen for any settings changes
68
+ displaySettings.on('settings-applied', (settings, changes) => {
69
+ console.log('Settings updated:', changes.join(', '));
70
+ });
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### Constructor
76
+
77
+ ```javascript
78
+ const displaySettings = new DisplaySettings();
79
+ ```
80
+
81
+ ### Methods
82
+
83
+ #### `applySettings(settings)`
84
+
85
+ Apply CMS settings from RegisterDisplay response.
86
+
87
+ **Parameters:**
88
+ - `settings` (Object): Raw settings from CMS
89
+
90
+ **Returns:**
91
+ - Object with `changed` (array) and `settings` (object)
92
+
93
+ **Example:**
94
+ ```javascript
95
+ const result = displaySettings.applySettings({
96
+ collectInterval: 600,
97
+ displayName: 'Main Display',
98
+ statsEnabled: '1',
99
+ xmrWebSocketAddress: 'ws://xmr.example.com:9505'
100
+ });
101
+
102
+ console.log(result.changed); // ['collectInterval']
103
+ console.log(result.settings.collectInterval); // 600
104
+ ```
105
+
106
+ #### `getCollectInterval()`
107
+
108
+ Get current collection interval in seconds.
109
+
110
+ **Returns:** Number (seconds)
111
+
112
+ #### `getDisplayName()`
113
+
114
+ Get display name from CMS.
115
+
116
+ **Returns:** String
117
+
118
+ #### `getDisplaySize()`
119
+
120
+ Get display dimensions.
121
+
122
+ **Returns:** Object `{ width: number, height: number }`
123
+
124
+ #### `isStatsEnabled()`
125
+
126
+ Check if stats/proof of play is enabled.
127
+
128
+ **Returns:** Boolean
129
+
130
+ #### `getAllSettings()`
131
+
132
+ Get all current settings as an object.
133
+
134
+ **Returns:** Object
135
+
136
+ #### `getSetting(key, defaultValue)`
137
+
138
+ Get a specific setting by key.
139
+
140
+ **Parameters:**
141
+ - `key` (String): Setting key
142
+ - `defaultValue` (Any): Default if not set
143
+
144
+ **Returns:** Setting value or default
145
+
146
+ #### `isInDownloadWindow()`
147
+
148
+ Check if current time is within the download window.
149
+
150
+ **Returns:** Boolean
151
+
152
+ **Example:**
153
+ ```javascript
154
+ // Configure download window 09:00-17:00
155
+ displaySettings.applySettings({
156
+ downloadStartWindow: '09:00',
157
+ downloadEndWindow: '17:00'
158
+ });
159
+
160
+ // Check if downloads are allowed now
161
+ if (displaySettings.isInDownloadWindow()) {
162
+ await downloadFiles();
163
+ }
164
+ ```
165
+
166
+ #### `getNextDownloadWindow()`
167
+
168
+ Get the next download window start time.
169
+
170
+ **Returns:** Date object or null
171
+
172
+ #### `shouldTakeScreenshot(lastScreenshot)`
173
+
174
+ Check if screenshot interval has elapsed.
175
+
176
+ **Parameters:**
177
+ - `lastScreenshot` (Date): Last screenshot timestamp
178
+
179
+ **Returns:** Boolean
180
+
181
+ ## Supported Settings
182
+
183
+ ### Collection
184
+ - `collectInterval` - Collection interval in seconds (60-86400, default: 300)
185
+
186
+ ### Display Info
187
+ - `displayName` - Display name (default: 'Unknown Display')
188
+ - `sizeX` - Display width in pixels (default: 1920)
189
+ - `sizeY` - Display height in pixels (default: 1080)
190
+
191
+ ### Stats/Logging
192
+ - `statsEnabled` - Enable proof of play ('1' or '0')
193
+ - `aggregationLevel` - Stats aggregation ('Individual' or 'Aggregate')
194
+ - `logLevel` - Log level ('error', 'audit', 'info', 'debug')
195
+
196
+ ### XMR
197
+ - `xmrNetworkAddress` - XMR TCP address (e.g., 'tcp://xmr.example.com:9505')
198
+ - `xmrWebSocketAddress` - XMR WebSocket address (e.g., 'ws://xmr.example.com:9505')
199
+ - `xmrCmsKey` - XMR encryption key
200
+
201
+ ### Features
202
+ - `preventSleep` - Prevent display sleep (boolean, default: true)
203
+ - `embeddedServerPort` - Embedded server port (default: 9696)
204
+ - `screenshotInterval` - Screenshot interval in seconds (default: 120)
205
+
206
+ ### Download Windows
207
+ - `downloadStartWindow` - Download start time ('HH:MM')
208
+ - `downloadEndWindow` - Download end time ('HH:MM')
209
+
210
+ ### Other
211
+ - `licenceCode` - Commercial license code
212
+ - `isSspEnabled` - Enable SSP ad space (boolean)
213
+
214
+ ## Setting Name Formats
215
+
216
+ Supports both lowercase and CamelCase (uppercase first letter):
217
+
218
+ ```javascript
219
+ // Both formats work
220
+ displaySettings.applySettings({
221
+ collectInterval: 600, // lowercase
222
+ CollectInterval: 600 // CamelCase
223
+ });
224
+
225
+ displaySettings.applySettings({
226
+ displayName: 'Test', // lowercase
227
+ DisplayName: 'Test' // CamelCase
228
+ });
229
+ ```
230
+
231
+ ## Download Windows
232
+
233
+ ### Same-Day Window
234
+
235
+ ```javascript
236
+ displaySettings.applySettings({
237
+ downloadStartWindow: '09:00',
238
+ downloadEndWindow: '17:00'
239
+ });
240
+
241
+ // Downloads allowed 09:00-17:00
242
+ ```
243
+
244
+ ### Overnight Window
245
+
246
+ ```javascript
247
+ displaySettings.applySettings({
248
+ downloadStartWindow: '22:00',
249
+ downloadEndWindow: '06:00'
250
+ });
251
+
252
+ // Downloads allowed 22:00-23:59 and 00:00-06:00
253
+ ```
254
+
255
+ ## Events
256
+
257
+ ### `interval-changed`
258
+
259
+ Emitted when collection interval changes.
260
+
261
+ **Callback:** `(newInterval: number) => void`
262
+
263
+ ### `settings-applied`
264
+
265
+ Emitted when settings are applied.
266
+
267
+ **Callback:** `(settings: Object, changes: Array<string>) => void`
268
+
269
+ ## Testing
270
+
271
+ ```bash
272
+ npm test
273
+ ```
274
+
275
+ The test suite includes:
276
+ - 44 comprehensive test cases
277
+ - Constructor defaults
278
+ - Setting parsing and validation
279
+ - Collection interval enforcement (60s-24h)
280
+ - Boolean parsing ('1', '0', true, false)
281
+ - Download window logic (same-day and overnight)
282
+ - Screenshot interval tracking
283
+ - Edge cases and error handling
284
+
285
+ ## Integration with Upstream
286
+
287
+ Based on upstream electron-player implementation:
288
+ - `/upstream_players/electron-player/src/main/config/config.ts`
289
+ - `/upstream_players/electron-player/src/main/xmds/response/registerDisplay.ts`
290
+
291
+ ## License
292
+
293
+ AGPL-3.0-or-later
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@xiboplayer/settings",
3
+ "version": "0.9.0",
4
+ "description": "CMS settings management and application for Xibo Player",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./manager": "./src/settings.js"
10
+ },
11
+ "scripts": {
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "test:coverage": "vitest run --coverage"
15
+ },
16
+ "dependencies": {
17
+ "@xiboplayer/utils": "file:../utils"
18
+ },
19
+ "devDependencies": {
20
+ "jsdom": "^25.0.1",
21
+ "vitest": "^2.0.0"
22
+ },
23
+ "keywords": [
24
+ "xibo",
25
+ "digital-signage",
26
+ "settings",
27
+ "configuration",
28
+ "display-settings"
29
+ ],
30
+ "author": "Pau Aliagas <linuxnow@gmail.com>",
31
+ "license": "AGPL-3.0-or-later",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/xibo/xibo-players.git",
35
+ "directory": "packages/settings"
36
+ }
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // @xiboplayer/settings - CMS settings management
2
+
3
+ /**
4
+ * Settings manager for Xibo Player
5
+ * @module @xiboplayer/settings
6
+ */
7
+ export { DisplaySettings } from './settings.js';
@@ -0,0 +1,352 @@
1
+ /**
2
+ * DisplaySettings - CMS display settings management
3
+ *
4
+ * Parses and applies configuration from RegisterDisplay response.
5
+ * Based on upstream electron-player implementation.
6
+ *
7
+ * Architecture:
8
+ * ┌─────────────────────────────────────────────────────┐
9
+ * │ PlayerCore │
10
+ * │ - Receives RegisterDisplay response │
11
+ * │ - Passes to DisplaySettings.applySettings() │
12
+ * └─────────────────────────────────────────────────────┘
13
+ * ↓
14
+ * ┌─────────────────────────────────────────────────────┐
15
+ * │ DisplaySettings (this module) │
16
+ * │ - Parse all CMS settings │
17
+ * │ - Validate and normalize values │
18
+ * │ - Apply collection interval │
19
+ * │ - Check download windows │
20
+ * │ - Handle screenshot requests │
21
+ * │ - Emit events on changes │
22
+ * └─────────────────────────────────────────────────────┘
23
+ * ↓
24
+ * ┌─────────────────────────────────────────────────────┐
25
+ * │ Platform Layer (PWA/Electron/Mobile) │
26
+ * │ - Listen for setting change events │
27
+ * │ - Update UI with display name │
28
+ * │ - Handle screenshot requests │
29
+ * │ - Respect download windows │
30
+ * └─────────────────────────────────────────────────────┘
31
+ *
32
+ * Usage:
33
+ * const settings = new DisplaySettings();
34
+ * settings.applySettings(regResult.settings);
35
+ *
36
+ * // Get settings
37
+ * const collectInterval = settings.getCollectInterval();
38
+ * const canDownload = settings.isInDownloadWindow();
39
+ *
40
+ * // Listen for changes
41
+ * settings.on('interval-changed', (newInterval) => { ... });
42
+ */
43
+
44
+ import { EventEmitter } from '@xiboplayer/utils';
45
+
46
+ export class DisplaySettings extends EventEmitter {
47
+ constructor() {
48
+ super();
49
+
50
+ // Current settings (with defaults)
51
+ this.settings = {
52
+ // Collection
53
+ collectInterval: 300, // seconds (5 minutes default)
54
+
55
+ // Display info
56
+ displayName: 'Unknown Display',
57
+ sizeX: 1920,
58
+ sizeY: 1080,
59
+
60
+ // Stats
61
+ statsEnabled: false,
62
+ aggregationLevel: 'Individual', // or 'Aggregate'
63
+
64
+ // Logging
65
+ logLevel: 'error', // 'error', 'audit', 'info', 'debug'
66
+
67
+ // XMR
68
+ xmrNetworkAddress: null,
69
+ xmrWebSocketAddress: null,
70
+ xmrCmsKey: null,
71
+
72
+ // Features
73
+ preventSleep: true,
74
+ embeddedServerPort: 9696,
75
+ screenshotInterval: 120, // seconds
76
+
77
+ // Download windows
78
+ downloadStartWindow: null,
79
+ downloadEndWindow: null,
80
+
81
+ // License
82
+ licenceCode: null,
83
+
84
+ // SSP (ad space)
85
+ isSspEnabled: false,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Apply settings from RegisterDisplay response
91
+ * @param {Object} settings - Raw settings from CMS
92
+ * @returns {Object} Applied settings with changes
93
+ */
94
+ applySettings(settings) {
95
+ if (!settings) {
96
+ console.warn('[DisplaySettings] No settings provided');
97
+ return { changed: [], settings: this.settings };
98
+ }
99
+
100
+ const changes = [];
101
+ const oldInterval = this.settings.collectInterval;
102
+
103
+ // Parse all settings with defaults
104
+ // Handle both lowercase and CamelCase (uppercase first letter)
105
+ this.settings.collectInterval = this.parseCollectInterval(settings.collectInterval || settings.CollectInterval);
106
+ this.settings.displayName = settings.displayName || settings.DisplayName || this.settings.displayName;
107
+ this.settings.sizeX = parseInt(settings.sizeX || settings.SizeX || this.settings.sizeX);
108
+ this.settings.sizeY = parseInt(settings.sizeY || settings.SizeY || this.settings.sizeY);
109
+
110
+ // Stats
111
+ this.settings.statsEnabled = this.parseBoolean(settings.statsEnabled || settings.StatsEnabled);
112
+ this.settings.aggregationLevel = settings.aggregationLevel || settings.AggregationLevel || this.settings.aggregationLevel;
113
+
114
+ // Logging
115
+ this.settings.logLevel = settings.logLevel || settings.LogLevel || this.settings.logLevel;
116
+
117
+ // XMR
118
+ this.settings.xmrNetworkAddress = settings.xmrNetworkAddress || settings.XmrNetworkAddress || this.settings.xmrNetworkAddress;
119
+ this.settings.xmrWebSocketAddress = settings.xmrWebSocketAddress || settings.XmrWebSocketAddress || this.settings.xmrWebSocketAddress;
120
+ this.settings.xmrCmsKey = settings.xmrCmsKey || settings.XmrCmsKey || this.settings.xmrCmsKey;
121
+
122
+ // Features
123
+ this.settings.preventSleep = this.parseBoolean(settings.preventSleep || settings.PreventSleep, true);
124
+ this.settings.embeddedServerPort = parseInt(settings.embeddedServerPort || settings.EmbeddedServerPort || this.settings.embeddedServerPort);
125
+ this.settings.screenshotInterval = parseInt(settings.screenshotInterval || settings.ScreenshotInterval || this.settings.screenshotInterval);
126
+
127
+ // Download windows
128
+ this.settings.downloadStartWindow = settings.downloadStartWindow || settings.DownloadStartWindow || this.settings.downloadStartWindow;
129
+ this.settings.downloadEndWindow = settings.downloadEndWindow || settings.DownloadEndWindow || this.settings.downloadEndWindow;
130
+
131
+ // License
132
+ this.settings.licenceCode = settings.licenceCode || settings.LicenceCode || this.settings.licenceCode;
133
+
134
+ // SSP
135
+ this.settings.isSspEnabled = this.parseBoolean(settings.isAdspaceEnabled || settings.IsAdspaceEnabled);
136
+
137
+ // Detect changes
138
+ if (oldInterval !== this.settings.collectInterval) {
139
+ changes.push('collectInterval');
140
+ this.emit('interval-changed', this.settings.collectInterval);
141
+ }
142
+
143
+ // Emit generic settings-applied event
144
+ this.emit('settings-applied', this.settings, changes);
145
+
146
+ console.log('[DisplaySettings] Applied settings:', {
147
+ collectInterval: this.settings.collectInterval,
148
+ displayName: this.settings.displayName,
149
+ statsEnabled: this.settings.statsEnabled,
150
+ changes
151
+ });
152
+
153
+ return { changed: changes, settings: this.settings };
154
+ }
155
+
156
+ /**
157
+ * Parse collection interval (seconds)
158
+ * @param {*} value - Raw value from CMS
159
+ * @returns {number} Collection interval in seconds
160
+ */
161
+ parseCollectInterval(value) {
162
+ const interval = parseInt(value, 10);
163
+
164
+ // Validate range (minimum 60s, maximum 86400s = 24h)
165
+ if (isNaN(interval) || interval < 60) {
166
+ return 300; // 5 minutes default
167
+ }
168
+
169
+ if (interval > 86400) {
170
+ return 86400; // 24 hours max
171
+ }
172
+
173
+ return interval;
174
+ }
175
+
176
+ /**
177
+ * Parse boolean setting
178
+ * @param {*} value - Raw value from CMS (string '1' or '0', or boolean)
179
+ * @param {boolean} defaultValue - Default if not set
180
+ * @returns {boolean}
181
+ */
182
+ parseBoolean(value, defaultValue = false) {
183
+ if (value === true || value === false) {
184
+ return value;
185
+ }
186
+
187
+ if (value === '1' || value === 1) {
188
+ return true;
189
+ }
190
+
191
+ if (value === '0' || value === 0) {
192
+ return false;
193
+ }
194
+
195
+ return defaultValue;
196
+ }
197
+
198
+ /**
199
+ * Get collection interval in seconds
200
+ * @returns {number}
201
+ */
202
+ getCollectInterval() {
203
+ return this.settings.collectInterval;
204
+ }
205
+
206
+ /**
207
+ * Get display name
208
+ * @returns {string}
209
+ */
210
+ getDisplayName() {
211
+ return this.settings.displayName;
212
+ }
213
+
214
+ /**
215
+ * Get display size
216
+ * @returns {{ width: number, height: number }}
217
+ */
218
+ getDisplaySize() {
219
+ return {
220
+ width: this.settings.sizeX,
221
+ height: this.settings.sizeY
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Check if stats are enabled
227
+ * @returns {boolean}
228
+ */
229
+ isStatsEnabled() {
230
+ return this.settings.statsEnabled;
231
+ }
232
+
233
+ /**
234
+ * Get all settings
235
+ * @returns {Object}
236
+ */
237
+ getAllSettings() {
238
+ return { ...this.settings };
239
+ }
240
+
241
+ /**
242
+ * Get a specific setting by key
243
+ * @param {string} key - Setting key
244
+ * @param {*} defaultValue - Default value if not set
245
+ * @returns {*}
246
+ */
247
+ getSetting(key, defaultValue = null) {
248
+ return this.settings[key] !== undefined ? this.settings[key] : defaultValue;
249
+ }
250
+
251
+ /**
252
+ * Check if current time is within download window
253
+ * @returns {boolean}
254
+ */
255
+ isInDownloadWindow() {
256
+ // If no download window configured, always allow
257
+ if (!this.settings.downloadStartWindow || !this.settings.downloadEndWindow) {
258
+ return true;
259
+ }
260
+
261
+ try {
262
+ const now = new Date();
263
+ const currentTime = now.getHours() * 60 + now.getMinutes();
264
+
265
+ const start = this.parseTimeWindow(this.settings.downloadStartWindow);
266
+ const end = this.parseTimeWindow(this.settings.downloadEndWindow);
267
+
268
+ // Handle overnight window (e.g., 22:00 - 06:00)
269
+ if (start > end) {
270
+ // Overnight: allow if AFTER start OR BEFORE end
271
+ return currentTime >= start || currentTime < end;
272
+ } else {
273
+ // Same day: allow if AFTER start AND BEFORE end
274
+ return currentTime >= start && currentTime < end;
275
+ }
276
+ } catch (error) {
277
+ console.warn('[DisplaySettings] Failed to parse download window:', error);
278
+ return true; // Allow downloads if parsing fails
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Parse time window string to minutes since midnight
284
+ * @param {string} timeStr - Time string (e.g., "14:30", "22:00")
285
+ * @returns {number} Minutes since midnight
286
+ */
287
+ parseTimeWindow(timeStr) {
288
+ if (!timeStr || typeof timeStr !== 'string') {
289
+ throw new Error('Invalid time window format');
290
+ }
291
+
292
+ const parts = timeStr.split(':');
293
+ if (parts.length !== 2) {
294
+ throw new Error('Invalid time window format (expected HH:MM)');
295
+ }
296
+
297
+ const hours = parseInt(parts[0], 10);
298
+ const minutes = parseInt(parts[1], 10);
299
+
300
+ if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
301
+ throw new Error('Invalid time window values');
302
+ }
303
+
304
+ return hours * 60 + minutes;
305
+ }
306
+
307
+ /**
308
+ * Get next download window start time
309
+ * @returns {Date|null} Next window start, or null if always allowed
310
+ */
311
+ getNextDownloadWindow() {
312
+ if (!this.settings.downloadStartWindow || !this.settings.downloadEndWindow) {
313
+ return null;
314
+ }
315
+
316
+ try {
317
+ const now = new Date();
318
+ const currentTime = now.getHours() * 60 + now.getMinutes();
319
+ const start = this.parseTimeWindow(this.settings.downloadStartWindow);
320
+
321
+ const nextWindow = new Date(now);
322
+
323
+ if (currentTime < start) {
324
+ // Window is later today
325
+ nextWindow.setHours(Math.floor(start / 60), start % 60, 0, 0);
326
+ } else {
327
+ // Window is tomorrow
328
+ nextWindow.setDate(nextWindow.getDate() + 1);
329
+ nextWindow.setHours(Math.floor(start / 60), start % 60, 0, 0);
330
+ }
331
+
332
+ return nextWindow;
333
+ } catch (error) {
334
+ console.warn('[DisplaySettings] Failed to calculate next download window:', error);
335
+ return null;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Check if screenshot interval has elapsed
341
+ * @param {Date} lastScreenshot - Last screenshot timestamp
342
+ * @returns {boolean}
343
+ */
344
+ shouldTakeScreenshot(lastScreenshot) {
345
+ if (!lastScreenshot) {
346
+ return true;
347
+ }
348
+
349
+ const elapsed = (Date.now() - lastScreenshot.getTime()) / 1000;
350
+ return elapsed >= this.settings.screenshotInterval;
351
+ }
352
+ }
@@ -0,0 +1,480 @@
1
+ /**
2
+ * DisplaySettings tests
3
+ * Comprehensive test suite covering all settings parsing and validation
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
7
+ import { DisplaySettings } from './settings.js';
8
+
9
+ describe('DisplaySettings', () => {
10
+ let settings;
11
+
12
+ beforeEach(() => {
13
+ settings = new DisplaySettings();
14
+ });
15
+
16
+ describe('constructor', () => {
17
+ it('should initialize with default settings', () => {
18
+ expect(settings.settings.collectInterval).toBe(300);
19
+ expect(settings.settings.displayName).toBe('Unknown Display');
20
+ expect(settings.settings.sizeX).toBe(1920);
21
+ expect(settings.settings.sizeY).toBe(1080);
22
+ expect(settings.settings.statsEnabled).toBe(false);
23
+ expect(settings.settings.logLevel).toBe('error');
24
+ });
25
+ });
26
+
27
+ describe('applySettings', () => {
28
+ it('should handle null settings gracefully', () => {
29
+ const result = settings.applySettings(null);
30
+ expect(result.changed).toEqual([]);
31
+ expect(result.settings.collectInterval).toBe(300);
32
+ });
33
+
34
+ it('should apply all settings from CMS response', () => {
35
+ const cmsSettings = {
36
+ collectInterval: 600,
37
+ displayName: 'Test Display',
38
+ sizeX: '3840',
39
+ sizeY: '2160',
40
+ statsEnabled: '1',
41
+ logLevel: 'debug',
42
+ xmrWebSocketAddress: 'ws://xmr.example.com:9505',
43
+ preventSleep: '1',
44
+ screenshotInterval: '180'
45
+ };
46
+
47
+ const result = settings.applySettings(cmsSettings);
48
+
49
+ expect(result.settings.collectInterval).toBe(600);
50
+ expect(result.settings.displayName).toBe('Test Display');
51
+ expect(result.settings.sizeX).toBe(3840);
52
+ expect(result.settings.sizeY).toBe(2160);
53
+ expect(result.settings.statsEnabled).toBe(true);
54
+ expect(result.settings.logLevel).toBe('debug');
55
+ expect(result.settings.xmrWebSocketAddress).toBe('ws://xmr.example.com:9505');
56
+ expect(result.settings.preventSleep).toBe(true);
57
+ expect(result.settings.screenshotInterval).toBe(180);
58
+ });
59
+
60
+ it('should detect collection interval changes', () => {
61
+ const listener = vi.fn();
62
+ settings.on('interval-changed', listener);
63
+
64
+ settings.applySettings({ collectInterval: 600 });
65
+
66
+ expect(listener).toHaveBeenCalledWith(600);
67
+ });
68
+
69
+ it('should emit settings-applied event', () => {
70
+ const listener = vi.fn();
71
+ settings.on('settings-applied', listener);
72
+
73
+ const cmsSettings = { collectInterval: 600 };
74
+ settings.applySettings(cmsSettings);
75
+
76
+ expect(listener).toHaveBeenCalled();
77
+ expect(listener.mock.calls[0][1]).toContain('collectInterval');
78
+ });
79
+
80
+ it('should handle CamelCase setting names (uppercase first letter)', () => {
81
+ const cmsSettings = {
82
+ CollectInterval: 600,
83
+ DisplayName: 'CamelCase Display',
84
+ SizeX: '1920',
85
+ SizeY: '1080',
86
+ StatsEnabled: '1',
87
+ LogLevel: 'info'
88
+ };
89
+
90
+ settings.applySettings(cmsSettings);
91
+
92
+ expect(settings.settings.collectInterval).toBe(600);
93
+ expect(settings.settings.displayName).toBe('CamelCase Display');
94
+ expect(settings.settings.sizeX).toBe(1920);
95
+ expect(settings.settings.sizeY).toBe(1080);
96
+ expect(settings.settings.statsEnabled).toBe(true);
97
+ expect(settings.settings.logLevel).toBe('info');
98
+ });
99
+ });
100
+
101
+ describe('parseCollectInterval', () => {
102
+ it('should parse valid interval', () => {
103
+ expect(settings.parseCollectInterval(600)).toBe(600);
104
+ expect(settings.parseCollectInterval('900')).toBe(900);
105
+ });
106
+
107
+ it('should enforce minimum interval (60 seconds)', () => {
108
+ expect(settings.parseCollectInterval(30)).toBe(300);
109
+ expect(settings.parseCollectInterval(0)).toBe(300);
110
+ expect(settings.parseCollectInterval(-100)).toBe(300);
111
+ });
112
+
113
+ it('should enforce maximum interval (24 hours)', () => {
114
+ expect(settings.parseCollectInterval(100000)).toBe(86400);
115
+ expect(settings.parseCollectInterval(999999)).toBe(86400);
116
+ });
117
+
118
+ it('should handle invalid input', () => {
119
+ expect(settings.parseCollectInterval('invalid')).toBe(300);
120
+ expect(settings.parseCollectInterval(null)).toBe(300);
121
+ expect(settings.parseCollectInterval(undefined)).toBe(300);
122
+ expect(settings.parseCollectInterval(NaN)).toBe(300);
123
+ });
124
+ });
125
+
126
+ describe('parseBoolean', () => {
127
+ it('should parse boolean values', () => {
128
+ expect(settings.parseBoolean(true)).toBe(true);
129
+ expect(settings.parseBoolean(false)).toBe(false);
130
+ });
131
+
132
+ it('should parse string values', () => {
133
+ expect(settings.parseBoolean('1')).toBe(true);
134
+ expect(settings.parseBoolean('0')).toBe(false);
135
+ });
136
+
137
+ it('should parse numeric values', () => {
138
+ expect(settings.parseBoolean(1)).toBe(true);
139
+ expect(settings.parseBoolean(0)).toBe(false);
140
+ });
141
+
142
+ it('should use default value for invalid input', () => {
143
+ expect(settings.parseBoolean(null)).toBe(false);
144
+ expect(settings.parseBoolean(undefined)).toBe(false);
145
+ expect(settings.parseBoolean('yes')).toBe(false);
146
+ expect(settings.parseBoolean('no')).toBe(false);
147
+ expect(settings.parseBoolean(null, true)).toBe(true);
148
+ });
149
+ });
150
+
151
+ describe('getters', () => {
152
+ beforeEach(() => {
153
+ settings.applySettings({
154
+ collectInterval: 900,
155
+ displayName: 'My Display',
156
+ sizeX: '2560',
157
+ sizeY: '1440',
158
+ statsEnabled: '1'
159
+ });
160
+ });
161
+
162
+ it('should get collection interval', () => {
163
+ expect(settings.getCollectInterval()).toBe(900);
164
+ });
165
+
166
+ it('should get display name', () => {
167
+ expect(settings.getDisplayName()).toBe('My Display');
168
+ });
169
+
170
+ it('should get display size', () => {
171
+ const size = settings.getDisplaySize();
172
+ expect(size.width).toBe(2560);
173
+ expect(size.height).toBe(1440);
174
+ });
175
+
176
+ it('should check if stats enabled', () => {
177
+ expect(settings.isStatsEnabled()).toBe(true);
178
+ });
179
+
180
+ it('should get all settings', () => {
181
+ const allSettings = settings.getAllSettings();
182
+ expect(allSettings.collectInterval).toBe(900);
183
+ expect(allSettings.displayName).toBe('My Display');
184
+ expect(allSettings.statsEnabled).toBe(true);
185
+ });
186
+
187
+ it('should get specific setting', () => {
188
+ expect(settings.getSetting('collectInterval')).toBe(900);
189
+ expect(settings.getSetting('displayName')).toBe('My Display');
190
+ expect(settings.getSetting('unknownSetting', 'default')).toBe('default');
191
+ });
192
+ });
193
+
194
+ describe('download windows', () => {
195
+ describe('isInDownloadWindow', () => {
196
+ it('should return true if no download window configured', () => {
197
+ expect(settings.isInDownloadWindow()).toBe(true);
198
+ });
199
+
200
+ it('should return true if only start window configured', () => {
201
+ settings.applySettings({ downloadStartWindow: '14:00' });
202
+ expect(settings.isInDownloadWindow()).toBe(true);
203
+ });
204
+
205
+ it('should return true if only end window configured', () => {
206
+ settings.applySettings({ downloadEndWindow: '18:00' });
207
+ expect(settings.isInDownloadWindow()).toBe(true);
208
+ });
209
+
210
+ it('should check normal same-day window', () => {
211
+ settings.applySettings({
212
+ downloadStartWindow: '09:00',
213
+ downloadEndWindow: '17:00'
214
+ });
215
+
216
+ // Mock current time to 12:00 (within window)
217
+ vi.useFakeTimers();
218
+ vi.setSystemTime(new Date(2026, 0, 15, 12, 0, 0));
219
+
220
+ expect(settings.isInDownloadWindow()).toBe(true);
221
+
222
+ // Mock current time to 08:00 (before window)
223
+ vi.setSystemTime(new Date(2026, 0, 15, 8, 0, 0));
224
+ expect(settings.isInDownloadWindow()).toBe(false);
225
+
226
+ // Mock current time to 18:00 (after window)
227
+ vi.setSystemTime(new Date(2026, 0, 15, 18, 0, 0));
228
+ expect(settings.isInDownloadWindow()).toBe(false);
229
+
230
+ vi.useRealTimers();
231
+ });
232
+
233
+ it('should check overnight window (crosses midnight)', () => {
234
+ settings.applySettings({
235
+ downloadStartWindow: '22:00',
236
+ downloadEndWindow: '06:00'
237
+ });
238
+
239
+ // Mock current time to 23:00 (within overnight window)
240
+ vi.useFakeTimers();
241
+ vi.setSystemTime(new Date(2026, 0, 15, 23, 0, 0));
242
+
243
+ expect(settings.isInDownloadWindow()).toBe(true);
244
+
245
+ // Mock current time to 02:00 (within overnight window)
246
+ vi.setSystemTime(new Date(2026, 0, 15, 2, 0, 0));
247
+ expect(settings.isInDownloadWindow()).toBe(true);
248
+
249
+ // Mock current time to 12:00 (outside overnight window)
250
+ vi.setSystemTime(new Date(2026, 0, 15, 12, 0, 0));
251
+ expect(settings.isInDownloadWindow()).toBe(false);
252
+
253
+ vi.useRealTimers();
254
+ });
255
+
256
+ it('should handle invalid time window gracefully', () => {
257
+ settings.applySettings({
258
+ downloadStartWindow: 'invalid',
259
+ downloadEndWindow: '17:00'
260
+ });
261
+
262
+ // Should return true (allow downloads) if parsing fails
263
+ expect(settings.isInDownloadWindow()).toBe(true);
264
+ });
265
+ });
266
+
267
+ describe('parseTimeWindow', () => {
268
+ it('should parse valid time strings', () => {
269
+ expect(settings.parseTimeWindow('00:00')).toBe(0);
270
+ expect(settings.parseTimeWindow('12:30')).toBe(750);
271
+ expect(settings.parseTimeWindow('23:59')).toBe(1439);
272
+ });
273
+
274
+ it('should reject invalid formats', () => {
275
+ expect(() => settings.parseTimeWindow('12')).toThrow('Invalid time window format');
276
+ expect(() => settings.parseTimeWindow('12:30:00')).toThrow('Invalid time window format');
277
+ expect(() => settings.parseTimeWindow('invalid')).toThrow('Invalid time window format');
278
+ expect(() => settings.parseTimeWindow(null)).toThrow('Invalid time window format');
279
+ expect(() => settings.parseTimeWindow(undefined)).toThrow('Invalid time window format');
280
+ });
281
+
282
+ it('should reject invalid values', () => {
283
+ expect(() => settings.parseTimeWindow('24:00')).toThrow('Invalid time window values');
284
+ expect(() => settings.parseTimeWindow('12:60')).toThrow('Invalid time window values');
285
+ expect(() => settings.parseTimeWindow('-1:30')).toThrow('Invalid time window values');
286
+ expect(() => settings.parseTimeWindow('12:-1')).toThrow('Invalid time window values');
287
+ });
288
+ });
289
+
290
+ describe('getNextDownloadWindow', () => {
291
+ it('should return null if no download window configured', () => {
292
+ expect(settings.getNextDownloadWindow()).toBeNull();
293
+ });
294
+
295
+ it('should calculate next window later today', () => {
296
+ settings.applySettings({
297
+ downloadStartWindow: '15:00',
298
+ downloadEndWindow: '18:00'
299
+ });
300
+
301
+ // Mock current time to 10:00
302
+ vi.useFakeTimers();
303
+ vi.setSystemTime(new Date(2026, 0, 15, 10, 0, 0));
304
+
305
+ const nextWindow = settings.getNextDownloadWindow();
306
+ expect(nextWindow).toBeTruthy();
307
+ expect(nextWindow.getHours()).toBe(15);
308
+ expect(nextWindow.getMinutes()).toBe(0);
309
+
310
+ vi.useRealTimers();
311
+ });
312
+
313
+ it('should calculate next window tomorrow', () => {
314
+ settings.applySettings({
315
+ downloadStartWindow: '09:00',
316
+ downloadEndWindow: '17:00'
317
+ });
318
+
319
+ // Mock current time to 20:00 (after window)
320
+ vi.useFakeTimers();
321
+ vi.setSystemTime(new Date(2026, 0, 15, 20, 0, 0));
322
+
323
+ const nextWindow = settings.getNextDownloadWindow();
324
+ expect(nextWindow).toBeTruthy();
325
+ expect(nextWindow.getDate()).toBe(16); // Tomorrow
326
+ expect(nextWindow.getHours()).toBe(9);
327
+ expect(nextWindow.getMinutes()).toBe(0);
328
+
329
+ vi.useRealTimers();
330
+ });
331
+
332
+ it('should handle invalid time window gracefully', () => {
333
+ settings.applySettings({
334
+ downloadStartWindow: 'invalid',
335
+ downloadEndWindow: '17:00'
336
+ });
337
+
338
+ expect(settings.getNextDownloadWindow()).toBeNull();
339
+ });
340
+ });
341
+ });
342
+
343
+ describe('screenshot', () => {
344
+ beforeEach(() => {
345
+ settings.applySettings({ screenshotInterval: 120 });
346
+ });
347
+
348
+ it('should return true if no last screenshot', () => {
349
+ expect(settings.shouldTakeScreenshot(null)).toBe(true);
350
+ });
351
+
352
+ it('should return false if interval not elapsed', () => {
353
+ vi.useFakeTimers();
354
+ const now = new Date(2026, 0, 15, 12, 0, 0);
355
+ vi.setSystemTime(now);
356
+
357
+ const lastScreenshot = new Date(now.getTime() - 60000); // 60 seconds ago
358
+ expect(settings.shouldTakeScreenshot(lastScreenshot)).toBe(false);
359
+
360
+ vi.useRealTimers();
361
+ });
362
+
363
+ it('should return true if interval elapsed', () => {
364
+ vi.useFakeTimers();
365
+ const now = new Date(2026, 0, 15, 12, 0, 0);
366
+ vi.setSystemTime(now);
367
+
368
+ const lastScreenshot = new Date(now.getTime() - 130000); // 130 seconds ago
369
+ expect(settings.shouldTakeScreenshot(lastScreenshot)).toBe(true);
370
+
371
+ vi.useRealTimers();
372
+ });
373
+
374
+ it('should handle exact interval boundary', () => {
375
+ vi.useFakeTimers();
376
+ const now = new Date(2026, 0, 15, 12, 0, 0);
377
+ vi.setSystemTime(now);
378
+
379
+ const lastScreenshot = new Date(now.getTime() - 120000); // Exactly 120 seconds ago
380
+ expect(settings.shouldTakeScreenshot(lastScreenshot)).toBe(true);
381
+
382
+ vi.useRealTimers();
383
+ });
384
+ });
385
+
386
+ describe('edge cases and integration', () => {
387
+ it('should handle empty settings object', () => {
388
+ const result = settings.applySettings({});
389
+ expect(result.changed).toEqual([]);
390
+ expect(result.settings.collectInterval).toBe(300); // Default
391
+ });
392
+
393
+ it('should handle mixed case and missing values', () => {
394
+ const cmsSettings = {
395
+ CollectInterval: '450',
396
+ displayName: null,
397
+ SizeX: undefined,
398
+ statsEnabled: ''
399
+ };
400
+
401
+ settings.applySettings(cmsSettings);
402
+
403
+ expect(settings.settings.collectInterval).toBe(450);
404
+ expect(settings.settings.displayName).toBe('Unknown Display'); // Default
405
+ expect(settings.settings.sizeX).toBe(1920); // Default
406
+ expect(settings.settings.statsEnabled).toBe(false); // Default
407
+ });
408
+
409
+ it('should handle multiple consecutive applies', () => {
410
+ // First apply
411
+ settings.applySettings({ collectInterval: 600, displayName: 'Display 1' });
412
+ expect(settings.settings.collectInterval).toBe(600);
413
+ expect(settings.settings.displayName).toBe('Display 1');
414
+
415
+ // Second apply (different interval)
416
+ settings.applySettings({ collectInterval: 900, displayName: 'Display 2' });
417
+ expect(settings.settings.collectInterval).toBe(900);
418
+ expect(settings.settings.displayName).toBe('Display 2');
419
+
420
+ // Third apply (same interval, different name)
421
+ settings.applySettings({ collectInterval: 900, displayName: 'Display 3' });
422
+ expect(settings.settings.collectInterval).toBe(900);
423
+ expect(settings.settings.displayName).toBe('Display 3');
424
+ });
425
+
426
+ it('should preserve settings across multiple applies', () => {
427
+ // Apply initial settings
428
+ settings.applySettings({
429
+ collectInterval: 600,
430
+ displayName: 'Test',
431
+ statsEnabled: '1'
432
+ });
433
+
434
+ expect(settings.settings.statsEnabled).toBe(true);
435
+ expect(settings.settings.displayName).toBe('Test');
436
+
437
+ // Apply partial update (NOTE: settings are re-applied from scratch, not preserved)
438
+ // This matches upstream behavior where each RegisterDisplay overwrites settings
439
+ settings.applySettings({ collectInterval: 900 });
440
+
441
+ // After partial update, missing values revert to defaults (not preserved)
442
+ expect(settings.settings.statsEnabled).toBe(false); // Reverts to default
443
+ expect(settings.settings.collectInterval).toBe(900);
444
+ });
445
+
446
+ it('should handle SSP (ad space) settings', () => {
447
+ settings.applySettings({ isAdspaceEnabled: '1' });
448
+ expect(settings.settings.isSspEnabled).toBe(true);
449
+
450
+ settings.applySettings({ isAdspaceEnabled: '0' });
451
+ expect(settings.settings.isSspEnabled).toBe(false);
452
+
453
+ settings.applySettings({ IsAdspaceEnabled: '1' });
454
+ expect(settings.settings.isSspEnabled).toBe(true);
455
+ });
456
+
457
+ it('should handle XMR settings', () => {
458
+ const cmsSettings = {
459
+ xmrNetworkAddress: 'tcp://xmr.example.com:9505',
460
+ xmrWebSocketAddress: 'ws://xmr.example.com:9505',
461
+ xmrCmsKey: 'test-key-12345'
462
+ };
463
+
464
+ settings.applySettings(cmsSettings);
465
+
466
+ expect(settings.settings.xmrNetworkAddress).toBe('tcp://xmr.example.com:9505');
467
+ expect(settings.settings.xmrWebSocketAddress).toBe('ws://xmr.example.com:9505');
468
+ expect(settings.settings.xmrCmsKey).toBe('test-key-12345');
469
+ });
470
+
471
+ it('should handle all supported log levels', () => {
472
+ const levels = ['error', 'audit', 'info', 'debug'];
473
+
474
+ for (const level of levels) {
475
+ settings.applySettings({ logLevel: level });
476
+ expect(settings.settings.logLevel).toBe(level);
477
+ }
478
+ });
479
+ });
480
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'jsdom',
7
+ setupFiles: ['./vitest.setup.js'],
8
+ },
9
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Vitest setup file
3
+ * Mocks browser APIs that are not available in test environment
4
+ */
5
+
6
+ // Mock localStorage
7
+ const localStorageMock = {
8
+ store: {},
9
+ getItem(key) {
10
+ return this.store[key] || null;
11
+ },
12
+ setItem(key, value) {
13
+ this.store[key] = String(value);
14
+ },
15
+ removeItem(key) {
16
+ delete this.store[key];
17
+ },
18
+ clear() {
19
+ this.store = {};
20
+ },
21
+ };
22
+
23
+ global.localStorage = localStorageMock;