@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/src/state.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Centralized player state
3
+ *
4
+ * Single source of truth for player status, dimensions, and display info.
5
+ * Avoids scattered state across PlayerCore, renderer, and platform layer.
6
+ */
7
+
8
+ import { EventEmitter } from '@xiboplayer/utils';
9
+
10
+ export class PlayerState extends EventEmitter {
11
+ constructor() {
12
+ super();
13
+ this.currentLayoutId = null;
14
+ this.currentScheduleId = null;
15
+ this.displayName = '';
16
+ this.hardwareKey = '';
17
+ this.playerType = 'pwa';
18
+ this.displayStatus = 'idle'; // idle | collecting | rendering | error
19
+ this.screenWidth = 0;
20
+ this.screenHeight = 0;
21
+ this.lastCollectionTime = null;
22
+ this.lastHeartbeat = null;
23
+ this.isRegistered = false;
24
+ }
25
+
26
+ /**
27
+ * Update a state property and emit change event
28
+ */
29
+ set(key, value) {
30
+ if (this[key] === value) return;
31
+ const old = this[key];
32
+ this[key] = value;
33
+ this.emit('change', key, value, old);
34
+ }
35
+
36
+ /**
37
+ * Get snapshot of current state (for status reporting)
38
+ */
39
+ toJSON() {
40
+ return {
41
+ currentLayoutId: this.currentLayoutId,
42
+ currentScheduleId: this.currentScheduleId,
43
+ displayName: this.displayName,
44
+ hardwareKey: this.hardwareKey,
45
+ playerType: this.playerType,
46
+ displayStatus: this.displayStatus,
47
+ screenWidth: this.screenWidth,
48
+ screenHeight: this.screenHeight,
49
+ lastCollectionTime: this.lastCollectionTime,
50
+ lastHeartbeat: this.lastHeartbeat,
51
+ isRegistered: this.isRegistered
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * PlayerState Tests
3
+ *
4
+ * Tests for centralized player state management
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import { PlayerState } from './state.js';
9
+
10
+ describe('PlayerState', () => {
11
+ let state;
12
+
13
+ beforeEach(() => {
14
+ state = new PlayerState();
15
+ });
16
+
17
+ describe('Initial State', () => {
18
+ it('should start with null currentLayoutId', () => {
19
+ expect(state.currentLayoutId).toBeNull();
20
+ });
21
+
22
+ it('should start with null currentScheduleId', () => {
23
+ expect(state.currentScheduleId).toBeNull();
24
+ });
25
+
26
+ it('should start with empty displayName', () => {
27
+ expect(state.displayName).toBe('');
28
+ });
29
+
30
+ it('should start with empty hardwareKey', () => {
31
+ expect(state.hardwareKey).toBe('');
32
+ });
33
+
34
+ it('should start with pwa playerType', () => {
35
+ expect(state.playerType).toBe('pwa');
36
+ });
37
+
38
+ it('should start with idle displayStatus', () => {
39
+ expect(state.displayStatus).toBe('idle');
40
+ });
41
+
42
+ it('should start with zero screen dimensions', () => {
43
+ expect(state.screenWidth).toBe(0);
44
+ expect(state.screenHeight).toBe(0);
45
+ });
46
+
47
+ it('should start with null lastCollectionTime', () => {
48
+ expect(state.lastCollectionTime).toBeNull();
49
+ });
50
+
51
+ it('should start with null lastHeartbeat', () => {
52
+ expect(state.lastHeartbeat).toBeNull();
53
+ });
54
+
55
+ it('should start with isRegistered false', () => {
56
+ expect(state.isRegistered).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('set()', () => {
61
+ it('should update a property value', () => {
62
+ state.set('displayName', 'Test Display');
63
+
64
+ expect(state.displayName).toBe('Test Display');
65
+ });
66
+
67
+ it('should emit change event with key, new value, and old value', () => {
68
+ const spy = vi.fn();
69
+ state.on('change', spy);
70
+
71
+ state.set('displayStatus', 'rendering');
72
+
73
+ expect(spy).toHaveBeenCalledTimes(1);
74
+ expect(spy).toHaveBeenCalledWith('displayStatus', 'rendering', 'idle');
75
+ });
76
+
77
+ it('should not emit change event when value is the same', () => {
78
+ const spy = vi.fn();
79
+ state.on('change', spy);
80
+
81
+ state.set('playerType', 'pwa'); // Same as initial value
82
+
83
+ expect(spy).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('should not emit change event for same null value', () => {
87
+ const spy = vi.fn();
88
+ state.on('change', spy);
89
+
90
+ state.set('currentLayoutId', null); // Same as initial value
91
+
92
+ expect(spy).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('should emit change event when changing from null to a value', () => {
96
+ const spy = vi.fn();
97
+ state.on('change', spy);
98
+
99
+ state.set('currentLayoutId', 42);
100
+
101
+ expect(spy).toHaveBeenCalledWith('currentLayoutId', 42, null);
102
+ });
103
+
104
+ it('should emit change event when changing from a value to null', () => {
105
+ state.set('currentLayoutId', 42);
106
+
107
+ const spy = vi.fn();
108
+ state.on('change', spy);
109
+
110
+ state.set('currentLayoutId', null);
111
+
112
+ expect(spy).toHaveBeenCalledWith('currentLayoutId', null, 42);
113
+ });
114
+
115
+ it('should handle multiple sequential updates', () => {
116
+ const spy = vi.fn();
117
+ state.on('change', spy);
118
+
119
+ state.set('displayStatus', 'collecting');
120
+ state.set('displayStatus', 'rendering');
121
+ state.set('displayStatus', 'idle');
122
+
123
+ expect(spy).toHaveBeenCalledTimes(3);
124
+ expect(spy).toHaveBeenNthCalledWith(1, 'displayStatus', 'collecting', 'idle');
125
+ expect(spy).toHaveBeenNthCalledWith(2, 'displayStatus', 'rendering', 'collecting');
126
+ expect(spy).toHaveBeenNthCalledWith(3, 'displayStatus', 'idle', 'rendering');
127
+ });
128
+
129
+ it('should update numeric properties', () => {
130
+ state.set('screenWidth', 1920);
131
+ state.set('screenHeight', 1080);
132
+
133
+ expect(state.screenWidth).toBe(1920);
134
+ expect(state.screenHeight).toBe(1080);
135
+ });
136
+
137
+ it('should update boolean properties', () => {
138
+ const spy = vi.fn();
139
+ state.on('change', spy);
140
+
141
+ state.set('isRegistered', true);
142
+
143
+ expect(state.isRegistered).toBe(true);
144
+ expect(spy).toHaveBeenCalledWith('isRegistered', true, false);
145
+ });
146
+
147
+ it('should not emit when setting same boolean value', () => {
148
+ const spy = vi.fn();
149
+ state.on('change', spy);
150
+
151
+ state.set('isRegistered', false); // Same as initial
152
+
153
+ expect(spy).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe('toJSON()', () => {
158
+ it('should return a snapshot of all state properties', () => {
159
+ const json = state.toJSON();
160
+
161
+ expect(json).toEqual({
162
+ currentLayoutId: null,
163
+ currentScheduleId: null,
164
+ displayName: '',
165
+ hardwareKey: '',
166
+ playerType: 'pwa',
167
+ displayStatus: 'idle',
168
+ screenWidth: 0,
169
+ screenHeight: 0,
170
+ lastCollectionTime: null,
171
+ lastHeartbeat: null,
172
+ isRegistered: false
173
+ });
174
+ });
175
+
176
+ it('should reflect updated values', () => {
177
+ state.set('displayName', 'My Display');
178
+ state.set('currentLayoutId', 123);
179
+ state.set('isRegistered', true);
180
+ state.set('screenWidth', 1920);
181
+ state.set('screenHeight', 1080);
182
+
183
+ const json = state.toJSON();
184
+
185
+ expect(json.displayName).toBe('My Display');
186
+ expect(json.currentLayoutId).toBe(123);
187
+ expect(json.isRegistered).toBe(true);
188
+ expect(json.screenWidth).toBe(1920);
189
+ expect(json.screenHeight).toBe(1080);
190
+ });
191
+
192
+ it('should return a plain object (not the state instance)', () => {
193
+ const json = state.toJSON();
194
+
195
+ expect(json).not.toBe(state);
196
+ expect(json.constructor).toBe(Object);
197
+ });
198
+
199
+ it('should return independent snapshot (mutations do not affect original)', () => {
200
+ const json = state.toJSON();
201
+ json.displayName = 'Modified';
202
+
203
+ expect(state.displayName).toBe(''); // Original unchanged
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Test Utilities for Xibo Player Core
3
+ *
4
+ * Provides mocking utilities, test helpers, and fixtures for unit tests.
5
+ */
6
+
7
+ import { vi } from 'vitest';
8
+
9
+ /**
10
+ * Mock fetch with controllable responses
11
+ *
12
+ * Usage:
13
+ * mockFetch({
14
+ * 'http://example.com/file.mp4': {
15
+ * blob: createTestBlob(1024),
16
+ * headers: { 'Content-Length': '1024' }
17
+ * }
18
+ * });
19
+ */
20
+ export function mockFetch(responses = {}) {
21
+ global.fetch = vi.fn((url, options) => {
22
+ const method = options?.method || 'GET';
23
+ const key = `${method} ${url}`;
24
+ const response = responses[key] || responses[url];
25
+
26
+ if (!response) {
27
+ return Promise.resolve({
28
+ ok: false,
29
+ status: 404,
30
+ statusText: 'Not Found',
31
+ headers: {
32
+ get: () => null
33
+ }
34
+ });
35
+ }
36
+
37
+ return Promise.resolve({
38
+ ok: response.ok !== false,
39
+ status: response.status || 200,
40
+ statusText: response.statusText || 'OK',
41
+ headers: {
42
+ get: (name) => response.headers?.[name] || null
43
+ },
44
+ blob: () => Promise.resolve(response.blob || new Blob()),
45
+ text: () => Promise.resolve(response.text || ''),
46
+ json: () => Promise.resolve(response.json || {}),
47
+ arrayBuffer: () => Promise.resolve(response.arrayBuffer || new ArrayBuffer(0))
48
+ });
49
+ });
50
+
51
+ return global.fetch;
52
+ }
53
+
54
+ /**
55
+ * Mock Service Worker navigator
56
+ *
57
+ * Usage:
58
+ * mockServiceWorker({ ready: true, controller: {} });
59
+ * mockServiceWorker({ supported: false }); // Simulate no SW support
60
+ */
61
+ export function mockServiceWorker(config = {}) {
62
+ const {
63
+ ready = true,
64
+ controller = {},
65
+ supported = true
66
+ } = config;
67
+
68
+ if (supported) {
69
+ global.navigator.serviceWorker = {
70
+ ready: ready ? Promise.resolve({ active: {} }) : Promise.reject(new Error('Not ready')),
71
+ controller: controller,
72
+ register: vi.fn(() => Promise.resolve({ scope: '/' })),
73
+ addEventListener: vi.fn(),
74
+ removeEventListener: vi.fn()
75
+ };
76
+ } else {
77
+ delete global.navigator.serviceWorker;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Mock CacheManager for DirectCacheBackend tests
83
+ *
84
+ * Returns a mock object with all cache.js methods
85
+ */
86
+ export function mockCacheManager() {
87
+ return {
88
+ init: vi.fn(() => Promise.resolve()),
89
+ getCachedFile: vi.fn(() => Promise.resolve(null)),
90
+ getCachedResponse: vi.fn(() => Promise.resolve(null)),
91
+ downloadFile: vi.fn(() => Promise.resolve({ id: '1', size: 100 })),
92
+ getFile: vi.fn(() => Promise.resolve(null)),
93
+ getCacheKey: vi.fn((type, id) => `/cache/${type}/${id}`),
94
+ cache: {
95
+ match: vi.fn(() => Promise.resolve(null)),
96
+ put: vi.fn(() => Promise.resolve()),
97
+ delete: vi.fn(() => Promise.resolve(true))
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Create test blob
104
+ *
105
+ * Usage:
106
+ * const blob = createTestBlob(1024); // 1KB blob
107
+ * const blob = createTestBlob(1024, 'video/mp4'); // Typed blob
108
+ */
109
+ export function createTestBlob(size = 1024, type = 'application/octet-stream') {
110
+ const buffer = new ArrayBuffer(size);
111
+ return new Blob([buffer], { type });
112
+ }
113
+
114
+ /**
115
+ * Wait for condition to be true
116
+ *
117
+ * Usage:
118
+ * await waitFor(() => task.state === 'complete', 5000);
119
+ */
120
+ export async function waitFor(condition, timeout = 5000) {
121
+ const start = Date.now();
122
+ while (!condition()) {
123
+ if (Date.now() - start > timeout) {
124
+ throw new Error('waitFor timeout');
125
+ }
126
+ await new Promise(resolve => setTimeout(resolve, 50));
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Wait for a specific time (for testing timing-dependent logic)
132
+ *
133
+ * Usage:
134
+ * await wait(100); // Wait 100ms
135
+ */
136
+ export async function wait(ms) {
137
+ return new Promise(resolve => setTimeout(resolve, ms));
138
+ }
139
+
140
+ /**
141
+ * Create a spy that tracks calls but doesn't interfere
142
+ *
143
+ * Usage:
144
+ * const spy = createSpy();
145
+ * obj.on('event', spy);
146
+ * // ... trigger event
147
+ * expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
148
+ */
149
+ export function createSpy() {
150
+ return vi.fn();
151
+ }
152
+
153
+ /**
154
+ * Mock MessageChannel for Service Worker tests
155
+ */
156
+ export function mockMessageChannel() {
157
+ class MockMessagePort {
158
+ constructor() {
159
+ this.onmessage = null;
160
+ this._listeners = [];
161
+ }
162
+
163
+ addEventListener(event, callback) {
164
+ if (event === 'message') {
165
+ this._listeners.push(callback);
166
+ }
167
+ }
168
+
169
+ removeEventListener(event, callback) {
170
+ if (event === 'message') {
171
+ const index = this._listeners.indexOf(callback);
172
+ if (index !== -1) {
173
+ this._listeners.splice(index, 1);
174
+ }
175
+ }
176
+ }
177
+
178
+ postMessage(data) {
179
+ // Simulate async message passing
180
+ setTimeout(() => {
181
+ if (this.paired) {
182
+ const event = { data };
183
+
184
+ // Call onmessage if set
185
+ if (this.paired.onmessage) {
186
+ this.paired.onmessage(event);
187
+ }
188
+
189
+ // Call event listeners
190
+ this.paired._listeners.forEach(listener => listener(event));
191
+ }
192
+ }, 0);
193
+ }
194
+ }
195
+
196
+ class MockMessageChannel {
197
+ constructor() {
198
+ this.port1 = new MockMessagePort();
199
+ this.port2 = new MockMessagePort();
200
+ this.port1.paired = this.port2;
201
+ this.port2.paired = this.port1;
202
+ }
203
+ }
204
+
205
+ global.MessageChannel = MockMessageChannel;
206
+ return MockMessageChannel;
207
+ }
208
+
209
+ /**
210
+ * Reset all mocks
211
+ */
212
+ export function resetMocks() {
213
+ vi.clearAllMocks();
214
+ delete global.fetch;
215
+ delete global.navigator.serviceWorker;
216
+ delete global.MessageChannel;
217
+ }
@@ -0,0 +1,109 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>XMDS Campaign Parsing Tests</title>
6
+ <style>
7
+ body {
8
+ font-family: monospace;
9
+ padding: 20px;
10
+ background: #1e1e1e;
11
+ color: #d4d4d4;
12
+ }
13
+ .pass { color: #4ec9b0; }
14
+ .fail { color: #f48771; }
15
+ pre { background: #2d2d2d; padding: 10px; border-left: 3px solid #4ec9b0; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1>XMDS Campaign Parsing Tests</h1>
20
+ <div id="output"></div>
21
+
22
+ <script type="module">
23
+ import { XmdsClient } from './xmds.js';
24
+
25
+ const output = document.getElementById('output');
26
+
27
+ function log(msg, isPass = true) {
28
+ const div = document.createElement('div');
29
+ div.className = isPass ? 'pass' : 'fail';
30
+ div.textContent = msg;
31
+ output.appendChild(div);
32
+ }
33
+
34
+ // Test XML with campaigns
35
+ const scheduleXmlWithCampaigns = `
36
+ <schedule>
37
+ <default file="0"/>
38
+
39
+ <!-- Campaign with 3 layouts -->
40
+ <campaign id="5" priority="10" fromdt="2026-01-30 00:00:00" todt="2026-01-31 23:59:59" scheduleid="15">
41
+ <layout file="100"/>
42
+ <layout file="101"/>
43
+ <layout file="102"/>
44
+ </campaign>
45
+
46
+ <!-- Standalone layout -->
47
+ <layout file="200" priority="5" fromdt="2026-01-30 00:00:00" todt="2026-01-31 23:59:59" scheduleid="20"/>
48
+
49
+ <!-- Another campaign with 2 layouts -->
50
+ <campaign id="6" priority="8" fromdt="2026-01-30 00:00:00" todt="2026-01-31 23:59:59" scheduleid="25">
51
+ <layout file="300"/>
52
+ <layout file="301"/>
53
+ </campaign>
54
+ </schedule>
55
+ `;
56
+
57
+ // Test XML with only standalone layouts (backward compatibility)
58
+ const scheduleXmlStandalone = `
59
+ <schedule>
60
+ <default file="0"/>
61
+ <layout file="100" priority="10" fromdt="2026-01-30 00:00:00" todt="2026-01-31 23:59:59" scheduleid="10"/>
62
+ <layout file="101" priority="5" fromdt="2026-01-30 00:00:00" todt="2026-01-31 23:59:59" scheduleid="11"/>
63
+ </schedule>
64
+ `;
65
+
66
+ function testParseScheduleWithCampaigns() {
67
+ const client = new XmdsClient({ cmsAddress: 'http://test', cmsKey: 'key', hardwareKey: 'hw' });
68
+ const schedule = client.parseScheduleResponse(scheduleXmlWithCampaigns);
69
+
70
+ console.log('Parsed schedule:', schedule);
71
+
72
+ console.assert(schedule.default === '0', 'Default should be 0');
73
+ console.assert(schedule.campaigns.length === 2, 'Should have 2 campaigns');
74
+
75
+ const campaign1 = schedule.campaigns[0];
76
+ console.assert(campaign1.id === '5', 'Campaign 1 ID should be 5');
77
+ console.assert(campaign1.priority === 10, 'Campaign 1 priority should be 10');
78
+ console.assert(campaign1.layouts.length === 3, 'Campaign 1 should have 3 layouts');
79
+ console.assert(campaign1.layouts[0].file === '100', 'Campaign 1 layout 1 file should be 100');
80
+
81
+ console.assert(schedule.layouts.length === 1, 'Should have 1 standalone layout');
82
+ console.assert(schedule.layouts[0].file === '200', 'Standalone layout file should be 200');
83
+
84
+ log('✓ Test 1 passed: Parse schedule with campaigns');
85
+ }
86
+
87
+ function testParseScheduleStandalone() {
88
+ const client = new XmdsClient({ cmsAddress: 'http://test', cmsKey: 'key', hardwareKey: 'hw' });
89
+ const schedule = client.parseScheduleResponse(scheduleXmlStandalone);
90
+
91
+ console.assert(schedule.campaigns.length === 0, 'Should have 0 campaigns');
92
+ console.assert(schedule.layouts.length === 2, 'Should have 2 standalone layouts');
93
+
94
+ log('✓ Test 2 passed: Parse schedule with standalone layouts (backward compatible)');
95
+ }
96
+
97
+ // Run tests
98
+ log('\n=== Running XMDS Campaign Parsing Tests ===\n');
99
+ try {
100
+ testParseScheduleWithCampaigns();
101
+ testParseScheduleStandalone();
102
+ log('\n=== All XMDS tests passed! ===\n');
103
+ } catch (e) {
104
+ log('✗ Test failed: ' + e.message, false);
105
+ console.error(e);
106
+ }
107
+ </script>
108
+ </body>
109
+ </html>