gameglue 4.0.0 → 4.0.2
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/LICENSE +21 -21
- package/README.md +275 -275
- package/babel.config.cjs +5 -5
- package/coverage/auth.js.html +525 -525
- package/coverage/base.css +224 -224
- package/coverage/block-navigation.js +87 -87
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +175 -175
- package/coverage/index.js.html +309 -309
- package/coverage/lcov-report/auth.js.html +525 -525
- package/coverage/lcov-report/base.css +224 -224
- package/coverage/lcov-report/block-navigation.js +87 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +175 -175
- package/coverage/lcov-report/index.js.html +309 -309
- package/coverage/lcov-report/listener.js.html +528 -528
- package/coverage/lcov-report/prettify.css +1 -1
- package/coverage/lcov-report/prettify.js +2 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -210
- package/coverage/lcov-report/user.js.html +117 -117
- package/coverage/lcov-report/utils.js.html +117 -117
- package/coverage/lcov.info +391 -391
- package/coverage/listener.js.html +528 -528
- package/coverage/prettify.css +1 -1
- package/coverage/prettify.js +2 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -210
- package/coverage/user.js.html +117 -117
- package/coverage/utils.js.html +117 -117
- package/dist/gg.cjs.js +1 -1
- package/dist/gg.cjs.js.map +1 -1
- package/dist/gg.esm.js +1 -1
- package/dist/gg.esm.js.map +1 -1
- package/dist/gg.umd.js +1 -1
- package/dist/gg.umd.js.map +1 -1
- package/examples/certs/cert.pem +19 -19
- package/examples/certs/key.pem +28 -28
- package/examples/flight-dashboard.html +431 -431
- package/examples/server.js +99 -99
- package/examples/telemetry-validator.html +1410 -1410
- package/jest.config.cjs +33 -33
- package/package.json +56 -56
- package/rollup.config.js +57 -57
- package/src/auth.js +255 -255
- package/src/auth.spec.js +481 -481
- package/src/index.js +168 -168
- package/src/listener.js +196 -193
- package/src/listener.spec.js +598 -598
- package/src/presence_listener.js +112 -112
- package/src/test/fixtures.js +106 -106
- package/src/test/setup.js +51 -51
- package/src/utils.js +63 -63
- package/src/utils.spec.js +78 -78
- package/types/index.d.ts +338 -338
- package/webpack.config.js +15 -15
package/src/listener.spec.js
CHANGED
|
@@ -1,598 +1,598 @@
|
|
|
1
|
-
const { Listener } = require('./listener');
|
|
2
|
-
const { createMockSocket, mockListenerConfig } = require('./test/fixtures');
|
|
3
|
-
|
|
4
|
-
// Mock the schemas module with an identity mapping (passes through field names unchanged)
|
|
5
|
-
jest.mock('@gameglue/schemas', () => ({
|
|
6
|
-
getGameSchema: jest.fn(() => ({
|
|
7
|
-
gameId: 'msfs',
|
|
8
|
-
fieldMappings: {}, // Empty means no transformation, fields pass through
|
|
9
|
-
commandMappings: {
|
|
10
|
-
gear_up: { gameCommand: 'GEAR_UP', type: 'command' }
|
|
11
|
-
},
|
|
12
|
-
extraFields: {}
|
|
13
|
-
})),
|
|
14
|
-
normalizeTelemetry: jest.fn((raw) => raw) // Identity function - return raw data as-is
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
describe('Listener', () => {
|
|
18
|
-
let mockSocket;
|
|
19
|
-
let listener;
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
mockSocket = createMockSocket();
|
|
23
|
-
listener = new Listener(mockSocket, mockListenerConfig);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
describe('constructor', () => {
|
|
27
|
-
it('should initialize with config and socket', () => {
|
|
28
|
-
expect(listener._config).toEqual(mockListenerConfig);
|
|
29
|
-
expect(listener._socket).toBe(mockSocket);
|
|
30
|
-
expect(listener._callbacks).toEqual([]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('should copy fields array from config', () => {
|
|
34
|
-
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
35
|
-
expect(listener._fields).not.toBe(mockListenerConfig.fields); // Should be a copy
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('should handle config without fields', () => {
|
|
39
|
-
const configWithoutFields = { gameId: 'msfs', userId: 'user-123' };
|
|
40
|
-
const listenerNoFields = new Listener(mockSocket, configWithoutFields);
|
|
41
|
-
|
|
42
|
-
expect(listenerNoFields._fields).toBeNull();
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe('establishConnection', () => {
|
|
47
|
-
it('should emit listen event with object payload when fields are specified', async () => {
|
|
48
|
-
const result = await listener.establishConnection();
|
|
49
|
-
|
|
50
|
-
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
51
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
52
|
-
'listen',
|
|
53
|
-
{
|
|
54
|
-
userId: 'user-123',
|
|
55
|
-
gameId: 'msfs',
|
|
56
|
-
fields: ['altitude', 'airspeed', 'heading']
|
|
57
|
-
},
|
|
58
|
-
expect.any(Function)
|
|
59
|
-
);
|
|
60
|
-
expect(result).toEqual({ status: 'success' });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should emit listen event with legacy string format when no fields', async () => {
|
|
64
|
-
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
65
|
-
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
66
|
-
|
|
67
|
-
await listenerNoFields.establishConnection();
|
|
68
|
-
|
|
69
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
70
|
-
'listen',
|
|
71
|
-
'user-123:msfs',
|
|
72
|
-
expect.any(Function)
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should throw error when socket is missing', async () => {
|
|
77
|
-
const invalidListener = new Listener(null, mockListenerConfig);
|
|
78
|
-
|
|
79
|
-
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
80
|
-
'Missing arguments in establishConnection'
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('should throw error when userId is missing', async () => {
|
|
85
|
-
const invalidConfig = { gameId: 'msfs' };
|
|
86
|
-
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
87
|
-
|
|
88
|
-
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
89
|
-
'Missing arguments in establishConnection'
|
|
90
|
-
);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('should throw error when gameId is missing', async () => {
|
|
94
|
-
const invalidConfig = { userId: 'user-123' };
|
|
95
|
-
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
96
|
-
|
|
97
|
-
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
98
|
-
'Missing arguments in establishConnection'
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('should handle timeout error', async () => {
|
|
103
|
-
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
104
|
-
callback(new Error('timeout'), null);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const result = await listener.establishConnection();
|
|
108
|
-
|
|
109
|
-
expect(result).toEqual({
|
|
110
|
-
status: 'failed',
|
|
111
|
-
reason: 'Listen request timed out.'
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('should handle failure response from server', async () => {
|
|
116
|
-
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
117
|
-
callback(null, { status: 'failed', reason: 'Unauthorized' });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const result = await listener.establishConnection();
|
|
121
|
-
|
|
122
|
-
expect(result).toEqual({
|
|
123
|
-
status: 'failed',
|
|
124
|
-
reason: 'Unauthorized'
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe('setupEventListener', () => {
|
|
130
|
-
it('should register update event handler and return self', () => {
|
|
131
|
-
const result = listener.setupEventListener();
|
|
132
|
-
|
|
133
|
-
expect(mockSocket.on).toHaveBeenCalledWith('update', expect.any(Function));
|
|
134
|
-
expect(result).toBe(listener);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('should filter update payload to subscribed fields only', () => {
|
|
138
|
-
const emitSpy = jest.fn();
|
|
139
|
-
listener.emit = emitSpy;
|
|
140
|
-
listener.setupEventListener();
|
|
141
|
-
|
|
142
|
-
// Simulate server sending full telemetry payload
|
|
143
|
-
const fullPayload = {
|
|
144
|
-
data: {
|
|
145
|
-
altitude: 35000,
|
|
146
|
-
airspeed: 250,
|
|
147
|
-
heading: 180,
|
|
148
|
-
fuel: 5000,
|
|
149
|
-
temperature: 25
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Trigger the update event
|
|
154
|
-
mockSocket._trigger('update', fullPayload);
|
|
155
|
-
|
|
156
|
-
// Should only receive subscribed fields: altitude, airspeed, heading (plus raw data)
|
|
157
|
-
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
158
|
-
raw: fullPayload.data,
|
|
159
|
-
data: {
|
|
160
|
-
altitude: 35000,
|
|
161
|
-
airspeed: 250,
|
|
162
|
-
heading: 180
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('should pass through full payload when no fields specified', () => {
|
|
168
|
-
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
169
|
-
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
170
|
-
const emitSpy = jest.fn();
|
|
171
|
-
listenerNoFields.emit = emitSpy;
|
|
172
|
-
listenerNoFields.setupEventListener();
|
|
173
|
-
|
|
174
|
-
const fullPayload = {
|
|
175
|
-
data: {
|
|
176
|
-
altitude: 35000,
|
|
177
|
-
airspeed: 250,
|
|
178
|
-
fuel: 5000
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
mockSocket._trigger('update', fullPayload);
|
|
183
|
-
|
|
184
|
-
// Should receive all fields (with raw data)
|
|
185
|
-
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
186
|
-
raw: fullPayload.data,
|
|
187
|
-
data: fullPayload.data
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should handle payload with missing subscribed fields', () => {
|
|
192
|
-
const emitSpy = jest.fn();
|
|
193
|
-
listener.emit = emitSpy;
|
|
194
|
-
listener.setupEventListener();
|
|
195
|
-
|
|
196
|
-
// Server sends partial data (missing some subscribed fields)
|
|
197
|
-
const partialPayload = {
|
|
198
|
-
data: {
|
|
199
|
-
altitude: 35000
|
|
200
|
-
// airspeed and heading missing
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
mockSocket._trigger('update', partialPayload);
|
|
205
|
-
|
|
206
|
-
// Should only include fields that exist in payload (plus raw data)
|
|
207
|
-
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
208
|
-
raw: partialPayload.data,
|
|
209
|
-
data: {
|
|
210
|
-
altitude: 35000
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('should handle empty data payload', () => {
|
|
216
|
-
const emitSpy = jest.fn();
|
|
217
|
-
listener.emit = emitSpy;
|
|
218
|
-
listener.setupEventListener();
|
|
219
|
-
|
|
220
|
-
const emptyPayload = { data: {} };
|
|
221
|
-
mockSocket._trigger('update', emptyPayload);
|
|
222
|
-
|
|
223
|
-
expect(emitSpy).toHaveBeenCalledWith('update', { raw: {}, data: {} });
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('should handle null/undefined data gracefully', () => {
|
|
227
|
-
const emitSpy = jest.fn();
|
|
228
|
-
listener.emit = emitSpy;
|
|
229
|
-
listener.setupEventListener();
|
|
230
|
-
|
|
231
|
-
const nullPayload = { data: null };
|
|
232
|
-
mockSocket._trigger('update', nullPayload);
|
|
233
|
-
|
|
234
|
-
// Should pass through when data is null (with raw)
|
|
235
|
-
expect(emitSpy).toHaveBeenCalledWith('update', { raw: null, data: null });
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('should preserve other payload properties when filtering', () => {
|
|
239
|
-
const emitSpy = jest.fn();
|
|
240
|
-
listener.emit = emitSpy;
|
|
241
|
-
listener.setupEventListener();
|
|
242
|
-
|
|
243
|
-
const payloadWithMeta = {
|
|
244
|
-
data: { altitude: 35000, fuel: 5000 },
|
|
245
|
-
timestamp: 1234567890,
|
|
246
|
-
sequence: 42
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
mockSocket._trigger('update', payloadWithMeta);
|
|
250
|
-
|
|
251
|
-
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
252
|
-
raw: payloadWithMeta.data,
|
|
253
|
-
data: { altitude: 35000 },
|
|
254
|
-
timestamp: 1234567890,
|
|
255
|
-
sequence: 42
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should reflect field changes after subscribe/unsubscribe', async () => {
|
|
260
|
-
const emitSpy = jest.fn();
|
|
261
|
-
listener.emit = emitSpy;
|
|
262
|
-
listener.setupEventListener();
|
|
263
|
-
|
|
264
|
-
// Add a new field
|
|
265
|
-
await listener.subscribe(['fuel']);
|
|
266
|
-
|
|
267
|
-
const payload = {
|
|
268
|
-
data: { altitude: 35000, fuel: 5000, temperature: 25 }
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
mockSocket._trigger('update', payload);
|
|
272
|
-
|
|
273
|
-
// Should now include fuel (plus raw data)
|
|
274
|
-
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
275
|
-
raw: payload.data,
|
|
276
|
-
data: {
|
|
277
|
-
altitude: 35000,
|
|
278
|
-
fuel: 5000
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
describe('subscribe', () => {
|
|
285
|
-
it('should add new fields to subscription', async () => {
|
|
286
|
-
await listener.subscribe(['fuel', 'temperature']);
|
|
287
|
-
|
|
288
|
-
expect(listener._fields).toContain('altitude');
|
|
289
|
-
expect(listener._fields).toContain('fuel');
|
|
290
|
-
expect(listener._fields).toContain('temperature');
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('should not add duplicate fields', async () => {
|
|
294
|
-
await listener.subscribe(['altitude', 'fuel']);
|
|
295
|
-
|
|
296
|
-
const altitudeCount = listener._fields.filter(f => f === 'altitude').length;
|
|
297
|
-
expect(altitudeCount).toBe(1);
|
|
298
|
-
expect(listener._fields).toContain('fuel');
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it('should emit listen-update event', async () => {
|
|
302
|
-
await listener.subscribe(['fuel']);
|
|
303
|
-
|
|
304
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
305
|
-
'listen-update',
|
|
306
|
-
expect.objectContaining({
|
|
307
|
-
userId: 'user-123',
|
|
308
|
-
gameId: 'msfs',
|
|
309
|
-
fields: expect.arrayContaining(['altitude', 'airspeed', 'heading', 'fuel'])
|
|
310
|
-
}),
|
|
311
|
-
expect.any(Function)
|
|
312
|
-
);
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('should initialize fields array if none existed', async () => {
|
|
316
|
-
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
317
|
-
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
318
|
-
|
|
319
|
-
await listenerNoFields.subscribe(['fuel', 'temperature']);
|
|
320
|
-
|
|
321
|
-
expect(listenerNoFields._fields).toEqual(['fuel', 'temperature']);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('should throw error for non-array input', async () => {
|
|
325
|
-
await expect(listener.subscribe('fuel')).rejects.toThrow(
|
|
326
|
-
'fields must be a non-empty array'
|
|
327
|
-
);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('should throw error for empty array', async () => {
|
|
331
|
-
await expect(listener.subscribe([])).rejects.toThrow(
|
|
332
|
-
'fields must be a non-empty array'
|
|
333
|
-
);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
describe('unsubscribe', () => {
|
|
338
|
-
it('should remove fields from subscription', async () => {
|
|
339
|
-
await listener.unsubscribe(['altitude']);
|
|
340
|
-
|
|
341
|
-
expect(listener._fields).not.toContain('altitude');
|
|
342
|
-
expect(listener._fields).toContain('airspeed');
|
|
343
|
-
expect(listener._fields).toContain('heading');
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it('should emit listen-update event with updated fields', async () => {
|
|
347
|
-
await listener.unsubscribe(['altitude', 'airspeed']);
|
|
348
|
-
|
|
349
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
350
|
-
'listen-update',
|
|
351
|
-
{
|
|
352
|
-
userId: 'user-123',
|
|
353
|
-
gameId: 'msfs',
|
|
354
|
-
fields: ['heading']
|
|
355
|
-
},
|
|
356
|
-
expect.any(Function)
|
|
357
|
-
);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it('should throw error when no explicit fields exist', async () => {
|
|
361
|
-
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
362
|
-
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
363
|
-
|
|
364
|
-
await expect(listenerNoFields.unsubscribe(['fuel'])).rejects.toThrow(
|
|
365
|
-
'Cannot unsubscribe when receiving all fields'
|
|
366
|
-
);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('should throw error for non-array input', async () => {
|
|
370
|
-
await expect(listener.unsubscribe('altitude')).rejects.toThrow(
|
|
371
|
-
'fields must be a non-empty array'
|
|
372
|
-
);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('should throw error for empty array', async () => {
|
|
376
|
-
await expect(listener.unsubscribe([])).rejects.toThrow(
|
|
377
|
-
'fields must be a non-empty array'
|
|
378
|
-
);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it('should handle unsubscribing non-existent field gracefully', async () => {
|
|
382
|
-
await listener.unsubscribe(['nonexistent']);
|
|
383
|
-
|
|
384
|
-
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
describe('getFields', () => {
|
|
389
|
-
it('should return copy of fields array', () => {
|
|
390
|
-
const fields = listener.getFields();
|
|
391
|
-
|
|
392
|
-
expect(fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
393
|
-
expect(fields).not.toBe(listener._fields); // Should be a copy
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('should return null when no explicit fields', () => {
|
|
397
|
-
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
398
|
-
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
399
|
-
|
|
400
|
-
expect(listenerNoFields.getFields()).toBeNull();
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
describe('sendCommand', () => {
|
|
405
|
-
it('should emit set event with correct payload', async () => {
|
|
406
|
-
const result = await listener.sendCommand('autopilot', true);
|
|
407
|
-
|
|
408
|
-
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
409
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
410
|
-
'set',
|
|
411
|
-
{
|
|
412
|
-
userId: 'user-123',
|
|
413
|
-
gameId: 'msfs',
|
|
414
|
-
data: {
|
|
415
|
-
fieldName: 'autopilot',
|
|
416
|
-
value: true
|
|
417
|
-
}
|
|
418
|
-
},
|
|
419
|
-
expect.any(Function)
|
|
420
|
-
);
|
|
421
|
-
expect(result).toEqual({ status: 'success' });
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
it('should send canonical command without denormalizing', async () => {
|
|
425
|
-
await listener.sendCommand('gear_up', true);
|
|
426
|
-
|
|
427
|
-
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
428
|
-
'set',
|
|
429
|
-
expect.objectContaining({
|
|
430
|
-
data: {
|
|
431
|
-
fieldName: 'gear_up',
|
|
432
|
-
value: true
|
|
433
|
-
}
|
|
434
|
-
}),
|
|
435
|
-
expect.any(Function)
|
|
436
|
-
);
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it('should handle various value types', async () => {
|
|
440
|
-
await listener.sendCommand('altitude', 35000);
|
|
441
|
-
await listener.sendCommand('flaps', 0.5);
|
|
442
|
-
await listener.sendCommand('status', 'active');
|
|
443
|
-
await listener.sendCommand('config', { mode: 'auto' });
|
|
444
|
-
|
|
445
|
-
expect(mockSocket.emit).toHaveBeenCalledTimes(4);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
it('should throw error for invalid command', async () => {
|
|
449
|
-
await expect(listener.sendCommand('', true)).rejects.toThrow(
|
|
450
|
-
'command must be a non-empty string'
|
|
451
|
-
);
|
|
452
|
-
await expect(listener.sendCommand(null, true)).rejects.toThrow(
|
|
453
|
-
'command must be a non-empty string'
|
|
454
|
-
);
|
|
455
|
-
await expect(listener.sendCommand(123, true)).rejects.toThrow(
|
|
456
|
-
'command must be a non-empty string'
|
|
457
|
-
);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it('should handle timeout error', async () => {
|
|
461
|
-
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
462
|
-
callback(new Error('timeout'), null);
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
const result = await listener.sendCommand('autopilot', true);
|
|
466
|
-
|
|
467
|
-
expect(result).toEqual({
|
|
468
|
-
status: 'failed',
|
|
469
|
-
reason: 'Command request timed out.'
|
|
470
|
-
});
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
describe('key events', () => {
|
|
475
|
-
it('should register handler for key-events', () => {
|
|
476
|
-
listener.setupEventListener();
|
|
477
|
-
|
|
478
|
-
expect(mockSocket.on).toHaveBeenCalledWith('key-events', expect.any(Function));
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it('should emit landing event when received', () => {
|
|
482
|
-
const emitSpy = jest.fn();
|
|
483
|
-
listener.emit = emitSpy;
|
|
484
|
-
listener.setupEventListener();
|
|
485
|
-
|
|
486
|
-
const landingPayload = {
|
|
487
|
-
gameId: 'msfs',
|
|
488
|
-
eventType: 'landing',
|
|
489
|
-
data: {
|
|
490
|
-
landing_rate: -150,
|
|
491
|
-
quality: 'normal',
|
|
492
|
-
pitch_at_touchdown: 3.5,
|
|
493
|
-
roll_at_touchdown: 0.5,
|
|
494
|
-
speed_at_touchdown: 135,
|
|
495
|
-
heading_at_touchdown: 270,
|
|
496
|
-
position: { lat: 47.5, lon: -122.3 },
|
|
497
|
-
bounce_count: 0,
|
|
498
|
-
bounces: [],
|
|
499
|
-
timestamp: 1234567890
|
|
500
|
-
}
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
mockSocket._trigger('key-events', landingPayload);
|
|
504
|
-
|
|
505
|
-
expect(emitSpy).toHaveBeenCalledWith('landing', landingPayload.data);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('should emit takeoff event when received', () => {
|
|
509
|
-
const emitSpy = jest.fn();
|
|
510
|
-
listener.emit = emitSpy;
|
|
511
|
-
listener.setupEventListener();
|
|
512
|
-
|
|
513
|
-
const takeoffPayload = {
|
|
514
|
-
gameId: 'msfs',
|
|
515
|
-
eventType: 'takeoff',
|
|
516
|
-
data: {
|
|
517
|
-
rotation_speed: 145,
|
|
518
|
-
pitch_at_liftoff: 10,
|
|
519
|
-
heading_at_liftoff: 270,
|
|
520
|
-
position: { lat: 47.5, lon: -122.3 },
|
|
521
|
-
flaps_setting: 0.2,
|
|
522
|
-
timestamp: 1234567890
|
|
523
|
-
}
|
|
524
|
-
};
|
|
525
|
-
|
|
526
|
-
mockSocket._trigger('key-events', takeoffPayload);
|
|
527
|
-
|
|
528
|
-
expect(emitSpy).toHaveBeenCalledWith('takeoff', takeoffPayload.data);
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it('should emit flight_phase event when received', () => {
|
|
532
|
-
const emitSpy = jest.fn();
|
|
533
|
-
listener.emit = emitSpy;
|
|
534
|
-
listener.setupEventListener();
|
|
535
|
-
|
|
536
|
-
const phasePayload = {
|
|
537
|
-
gameId: 'msfs',
|
|
538
|
-
eventType: 'flight_phase',
|
|
539
|
-
data: {
|
|
540
|
-
phase: 'cruise',
|
|
541
|
-
previous_phase: 'climb',
|
|
542
|
-
altitude_agl: 34500,
|
|
543
|
-
altitude_msl: 35000,
|
|
544
|
-
speed: 280,
|
|
545
|
-
timestamp: 1234567890
|
|
546
|
-
}
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
mockSocket._trigger('key-events', phasePayload);
|
|
550
|
-
|
|
551
|
-
expect(emitSpy).toHaveBeenCalledWith('flight_phase', phasePayload.data);
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it('should not emit event for different gameId', () => {
|
|
555
|
-
const emitSpy = jest.fn();
|
|
556
|
-
listener.emit = emitSpy;
|
|
557
|
-
listener.setupEventListener();
|
|
558
|
-
|
|
559
|
-
const otherGamePayload = {
|
|
560
|
-
gameId: 'xplane', // Different from listener's 'msfs'
|
|
561
|
-
eventType: 'landing',
|
|
562
|
-
data: { landing_rate: -100 }
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
mockSocket._trigger('key-events', otherGamePayload);
|
|
566
|
-
|
|
567
|
-
expect(emitSpy).not.toHaveBeenCalledWith('landing', expect.anything());
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it('should handle null payload gracefully', () => {
|
|
571
|
-
const emitSpy = jest.fn();
|
|
572
|
-
listener.emit = emitSpy;
|
|
573
|
-
listener.setupEventListener();
|
|
574
|
-
|
|
575
|
-
mockSocket._trigger('key-events', null);
|
|
576
|
-
|
|
577
|
-
expect(emitSpy).not.toHaveBeenCalledWith('landing', expect.anything());
|
|
578
|
-
expect(emitSpy).not.toHaveBeenCalledWith('takeoff', expect.anything());
|
|
579
|
-
expect(emitSpy).not.toHaveBeenCalledWith('flight_phase', expect.anything());
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
it('should handle payload with missing eventType', () => {
|
|
583
|
-
const emitSpy = jest.fn();
|
|
584
|
-
listener.emit = emitSpy;
|
|
585
|
-
listener.setupEventListener();
|
|
586
|
-
|
|
587
|
-
const invalidPayload = {
|
|
588
|
-
gameId: 'msfs',
|
|
589
|
-
data: { landing_rate: -100 }
|
|
590
|
-
// eventType missing
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
mockSocket._trigger('key-events', invalidPayload);
|
|
594
|
-
|
|
595
|
-
expect(emitSpy).not.toHaveBeenCalledWith(undefined, expect.anything());
|
|
596
|
-
});
|
|
597
|
-
});
|
|
598
|
-
});
|
|
1
|
+
const { Listener } = require('./listener');
|
|
2
|
+
const { createMockSocket, mockListenerConfig } = require('./test/fixtures');
|
|
3
|
+
|
|
4
|
+
// Mock the schemas module with an identity mapping (passes through field names unchanged)
|
|
5
|
+
jest.mock('@gameglue/schemas', () => ({
|
|
6
|
+
getGameSchema: jest.fn(() => ({
|
|
7
|
+
gameId: 'msfs',
|
|
8
|
+
fieldMappings: {}, // Empty means no transformation, fields pass through
|
|
9
|
+
commandMappings: {
|
|
10
|
+
gear_up: { gameCommand: 'GEAR_UP', type: 'command' }
|
|
11
|
+
},
|
|
12
|
+
extraFields: {}
|
|
13
|
+
})),
|
|
14
|
+
normalizeTelemetry: jest.fn((raw) => raw) // Identity function - return raw data as-is
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe('Listener', () => {
|
|
18
|
+
let mockSocket;
|
|
19
|
+
let listener;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockSocket = createMockSocket();
|
|
23
|
+
listener = new Listener(mockSocket, mockListenerConfig);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('constructor', () => {
|
|
27
|
+
it('should initialize with config and socket', () => {
|
|
28
|
+
expect(listener._config).toEqual(mockListenerConfig);
|
|
29
|
+
expect(listener._socket).toBe(mockSocket);
|
|
30
|
+
expect(listener._callbacks).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should copy fields array from config', () => {
|
|
34
|
+
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
35
|
+
expect(listener._fields).not.toBe(mockListenerConfig.fields); // Should be a copy
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should handle config without fields', () => {
|
|
39
|
+
const configWithoutFields = { gameId: 'msfs', userId: 'user-123' };
|
|
40
|
+
const listenerNoFields = new Listener(mockSocket, configWithoutFields);
|
|
41
|
+
|
|
42
|
+
expect(listenerNoFields._fields).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('establishConnection', () => {
|
|
47
|
+
it('should emit listen event with object payload when fields are specified', async () => {
|
|
48
|
+
const result = await listener.establishConnection();
|
|
49
|
+
|
|
50
|
+
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
51
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
52
|
+
'listen',
|
|
53
|
+
{
|
|
54
|
+
userId: 'user-123',
|
|
55
|
+
gameId: 'msfs',
|
|
56
|
+
fields: ['altitude', 'airspeed', 'heading']
|
|
57
|
+
},
|
|
58
|
+
expect.any(Function)
|
|
59
|
+
);
|
|
60
|
+
expect(result).toEqual({ status: 'success' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should emit listen event with legacy string format when no fields', async () => {
|
|
64
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
65
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
66
|
+
|
|
67
|
+
await listenerNoFields.establishConnection();
|
|
68
|
+
|
|
69
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
70
|
+
'listen',
|
|
71
|
+
'user-123:msfs',
|
|
72
|
+
expect.any(Function)
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should throw error when socket is missing', async () => {
|
|
77
|
+
const invalidListener = new Listener(null, mockListenerConfig);
|
|
78
|
+
|
|
79
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
80
|
+
'Missing arguments in establishConnection'
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw error when userId is missing', async () => {
|
|
85
|
+
const invalidConfig = { gameId: 'msfs' };
|
|
86
|
+
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
87
|
+
|
|
88
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
89
|
+
'Missing arguments in establishConnection'
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should throw error when gameId is missing', async () => {
|
|
94
|
+
const invalidConfig = { userId: 'user-123' };
|
|
95
|
+
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
96
|
+
|
|
97
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
98
|
+
'Missing arguments in establishConnection'
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle timeout error', async () => {
|
|
103
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
104
|
+
callback(new Error('timeout'), null);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await listener.establishConnection();
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
status: 'failed',
|
|
111
|
+
reason: 'Listen request timed out.'
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should handle failure response from server', async () => {
|
|
116
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
117
|
+
callback(null, { status: 'failed', reason: 'Unauthorized' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await listener.establishConnection();
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
status: 'failed',
|
|
124
|
+
reason: 'Unauthorized'
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('setupEventListener', () => {
|
|
130
|
+
it('should register update event handler and return self', () => {
|
|
131
|
+
const result = listener.setupEventListener();
|
|
132
|
+
|
|
133
|
+
expect(mockSocket.on).toHaveBeenCalledWith('update', expect.any(Function));
|
|
134
|
+
expect(result).toBe(listener);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should filter update payload to subscribed fields only', () => {
|
|
138
|
+
const emitSpy = jest.fn();
|
|
139
|
+
listener.emit = emitSpy;
|
|
140
|
+
listener.setupEventListener();
|
|
141
|
+
|
|
142
|
+
// Simulate server sending full telemetry payload
|
|
143
|
+
const fullPayload = {
|
|
144
|
+
data: {
|
|
145
|
+
altitude: 35000,
|
|
146
|
+
airspeed: 250,
|
|
147
|
+
heading: 180,
|
|
148
|
+
fuel: 5000,
|
|
149
|
+
temperature: 25
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Trigger the update event
|
|
154
|
+
mockSocket._trigger('update', fullPayload);
|
|
155
|
+
|
|
156
|
+
// Should only receive subscribed fields: altitude, airspeed, heading (plus raw data)
|
|
157
|
+
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
158
|
+
raw: fullPayload.data,
|
|
159
|
+
data: {
|
|
160
|
+
altitude: 35000,
|
|
161
|
+
airspeed: 250,
|
|
162
|
+
heading: 180
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should pass through full payload when no fields specified', () => {
|
|
168
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
169
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
170
|
+
const emitSpy = jest.fn();
|
|
171
|
+
listenerNoFields.emit = emitSpy;
|
|
172
|
+
listenerNoFields.setupEventListener();
|
|
173
|
+
|
|
174
|
+
const fullPayload = {
|
|
175
|
+
data: {
|
|
176
|
+
altitude: 35000,
|
|
177
|
+
airspeed: 250,
|
|
178
|
+
fuel: 5000
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
mockSocket._trigger('update', fullPayload);
|
|
183
|
+
|
|
184
|
+
// Should receive all fields (with raw data)
|
|
185
|
+
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
186
|
+
raw: fullPayload.data,
|
|
187
|
+
data: fullPayload.data
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle payload with missing subscribed fields', () => {
|
|
192
|
+
const emitSpy = jest.fn();
|
|
193
|
+
listener.emit = emitSpy;
|
|
194
|
+
listener.setupEventListener();
|
|
195
|
+
|
|
196
|
+
// Server sends partial data (missing some subscribed fields)
|
|
197
|
+
const partialPayload = {
|
|
198
|
+
data: {
|
|
199
|
+
altitude: 35000
|
|
200
|
+
// airspeed and heading missing
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
mockSocket._trigger('update', partialPayload);
|
|
205
|
+
|
|
206
|
+
// Should only include fields that exist in payload (plus raw data)
|
|
207
|
+
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
208
|
+
raw: partialPayload.data,
|
|
209
|
+
data: {
|
|
210
|
+
altitude: 35000
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should handle empty data payload', () => {
|
|
216
|
+
const emitSpy = jest.fn();
|
|
217
|
+
listener.emit = emitSpy;
|
|
218
|
+
listener.setupEventListener();
|
|
219
|
+
|
|
220
|
+
const emptyPayload = { data: {} };
|
|
221
|
+
mockSocket._trigger('update', emptyPayload);
|
|
222
|
+
|
|
223
|
+
expect(emitSpy).toHaveBeenCalledWith('update', { raw: {}, data: {} });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should handle null/undefined data gracefully', () => {
|
|
227
|
+
const emitSpy = jest.fn();
|
|
228
|
+
listener.emit = emitSpy;
|
|
229
|
+
listener.setupEventListener();
|
|
230
|
+
|
|
231
|
+
const nullPayload = { data: null };
|
|
232
|
+
mockSocket._trigger('update', nullPayload);
|
|
233
|
+
|
|
234
|
+
// Should pass through when data is null (with raw)
|
|
235
|
+
expect(emitSpy).toHaveBeenCalledWith('update', { raw: null, data: null });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should preserve other payload properties when filtering', () => {
|
|
239
|
+
const emitSpy = jest.fn();
|
|
240
|
+
listener.emit = emitSpy;
|
|
241
|
+
listener.setupEventListener();
|
|
242
|
+
|
|
243
|
+
const payloadWithMeta = {
|
|
244
|
+
data: { altitude: 35000, fuel: 5000 },
|
|
245
|
+
timestamp: 1234567890,
|
|
246
|
+
sequence: 42
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
mockSocket._trigger('update', payloadWithMeta);
|
|
250
|
+
|
|
251
|
+
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
252
|
+
raw: payloadWithMeta.data,
|
|
253
|
+
data: { altitude: 35000 },
|
|
254
|
+
timestamp: 1234567890,
|
|
255
|
+
sequence: 42
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should reflect field changes after subscribe/unsubscribe', async () => {
|
|
260
|
+
const emitSpy = jest.fn();
|
|
261
|
+
listener.emit = emitSpy;
|
|
262
|
+
listener.setupEventListener();
|
|
263
|
+
|
|
264
|
+
// Add a new field
|
|
265
|
+
await listener.subscribe(['fuel']);
|
|
266
|
+
|
|
267
|
+
const payload = {
|
|
268
|
+
data: { altitude: 35000, fuel: 5000, temperature: 25 }
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
mockSocket._trigger('update', payload);
|
|
272
|
+
|
|
273
|
+
// Should now include fuel (plus raw data)
|
|
274
|
+
expect(emitSpy).toHaveBeenCalledWith('update', {
|
|
275
|
+
raw: payload.data,
|
|
276
|
+
data: {
|
|
277
|
+
altitude: 35000,
|
|
278
|
+
fuel: 5000
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('subscribe', () => {
|
|
285
|
+
it('should add new fields to subscription', async () => {
|
|
286
|
+
await listener.subscribe(['fuel', 'temperature']);
|
|
287
|
+
|
|
288
|
+
expect(listener._fields).toContain('altitude');
|
|
289
|
+
expect(listener._fields).toContain('fuel');
|
|
290
|
+
expect(listener._fields).toContain('temperature');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should not add duplicate fields', async () => {
|
|
294
|
+
await listener.subscribe(['altitude', 'fuel']);
|
|
295
|
+
|
|
296
|
+
const altitudeCount = listener._fields.filter(f => f === 'altitude').length;
|
|
297
|
+
expect(altitudeCount).toBe(1);
|
|
298
|
+
expect(listener._fields).toContain('fuel');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should emit listen-update event', async () => {
|
|
302
|
+
await listener.subscribe(['fuel']);
|
|
303
|
+
|
|
304
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
305
|
+
'listen-update',
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
userId: 'user-123',
|
|
308
|
+
gameId: 'msfs',
|
|
309
|
+
fields: expect.arrayContaining(['altitude', 'airspeed', 'heading', 'fuel'])
|
|
310
|
+
}),
|
|
311
|
+
expect.any(Function)
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should initialize fields array if none existed', async () => {
|
|
316
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
317
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
318
|
+
|
|
319
|
+
await listenerNoFields.subscribe(['fuel', 'temperature']);
|
|
320
|
+
|
|
321
|
+
expect(listenerNoFields._fields).toEqual(['fuel', 'temperature']);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should throw error for non-array input', async () => {
|
|
325
|
+
await expect(listener.subscribe('fuel')).rejects.toThrow(
|
|
326
|
+
'fields must be a non-empty array'
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should throw error for empty array', async () => {
|
|
331
|
+
await expect(listener.subscribe([])).rejects.toThrow(
|
|
332
|
+
'fields must be a non-empty array'
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('unsubscribe', () => {
|
|
338
|
+
it('should remove fields from subscription', async () => {
|
|
339
|
+
await listener.unsubscribe(['altitude']);
|
|
340
|
+
|
|
341
|
+
expect(listener._fields).not.toContain('altitude');
|
|
342
|
+
expect(listener._fields).toContain('airspeed');
|
|
343
|
+
expect(listener._fields).toContain('heading');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should emit listen-update event with updated fields', async () => {
|
|
347
|
+
await listener.unsubscribe(['altitude', 'airspeed']);
|
|
348
|
+
|
|
349
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
350
|
+
'listen-update',
|
|
351
|
+
{
|
|
352
|
+
userId: 'user-123',
|
|
353
|
+
gameId: 'msfs',
|
|
354
|
+
fields: ['heading']
|
|
355
|
+
},
|
|
356
|
+
expect.any(Function)
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should throw error when no explicit fields exist', async () => {
|
|
361
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
362
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
363
|
+
|
|
364
|
+
await expect(listenerNoFields.unsubscribe(['fuel'])).rejects.toThrow(
|
|
365
|
+
'Cannot unsubscribe when receiving all fields'
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should throw error for non-array input', async () => {
|
|
370
|
+
await expect(listener.unsubscribe('altitude')).rejects.toThrow(
|
|
371
|
+
'fields must be a non-empty array'
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should throw error for empty array', async () => {
|
|
376
|
+
await expect(listener.unsubscribe([])).rejects.toThrow(
|
|
377
|
+
'fields must be a non-empty array'
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should handle unsubscribing non-existent field gracefully', async () => {
|
|
382
|
+
await listener.unsubscribe(['nonexistent']);
|
|
383
|
+
|
|
384
|
+
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('getFields', () => {
|
|
389
|
+
it('should return copy of fields array', () => {
|
|
390
|
+
const fields = listener.getFields();
|
|
391
|
+
|
|
392
|
+
expect(fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
393
|
+
expect(fields).not.toBe(listener._fields); // Should be a copy
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should return null when no explicit fields', () => {
|
|
397
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
398
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
399
|
+
|
|
400
|
+
expect(listenerNoFields.getFields()).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('sendCommand', () => {
|
|
405
|
+
it('should emit set event with correct payload', async () => {
|
|
406
|
+
const result = await listener.sendCommand('autopilot', true);
|
|
407
|
+
|
|
408
|
+
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
409
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
410
|
+
'set',
|
|
411
|
+
{
|
|
412
|
+
userId: 'user-123',
|
|
413
|
+
gameId: 'msfs',
|
|
414
|
+
data: {
|
|
415
|
+
fieldName: 'autopilot',
|
|
416
|
+
value: true
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
expect.any(Function)
|
|
420
|
+
);
|
|
421
|
+
expect(result).toEqual({ status: 'success' });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should send canonical command without denormalizing', async () => {
|
|
425
|
+
await listener.sendCommand('gear_up', true);
|
|
426
|
+
|
|
427
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
428
|
+
'set',
|
|
429
|
+
expect.objectContaining({
|
|
430
|
+
data: {
|
|
431
|
+
fieldName: 'gear_up',
|
|
432
|
+
value: true
|
|
433
|
+
}
|
|
434
|
+
}),
|
|
435
|
+
expect.any(Function)
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should handle various value types', async () => {
|
|
440
|
+
await listener.sendCommand('altitude', 35000);
|
|
441
|
+
await listener.sendCommand('flaps', 0.5);
|
|
442
|
+
await listener.sendCommand('status', 'active');
|
|
443
|
+
await listener.sendCommand('config', { mode: 'auto' });
|
|
444
|
+
|
|
445
|
+
expect(mockSocket.emit).toHaveBeenCalledTimes(4);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('should throw error for invalid command', async () => {
|
|
449
|
+
await expect(listener.sendCommand('', true)).rejects.toThrow(
|
|
450
|
+
'command must be a non-empty string'
|
|
451
|
+
);
|
|
452
|
+
await expect(listener.sendCommand(null, true)).rejects.toThrow(
|
|
453
|
+
'command must be a non-empty string'
|
|
454
|
+
);
|
|
455
|
+
await expect(listener.sendCommand(123, true)).rejects.toThrow(
|
|
456
|
+
'command must be a non-empty string'
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should handle timeout error', async () => {
|
|
461
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
462
|
+
callback(new Error('timeout'), null);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const result = await listener.sendCommand('autopilot', true);
|
|
466
|
+
|
|
467
|
+
expect(result).toEqual({
|
|
468
|
+
status: 'failed',
|
|
469
|
+
reason: 'Command request timed out.'
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('key events', () => {
|
|
475
|
+
it('should register handler for key-events', () => {
|
|
476
|
+
listener.setupEventListener();
|
|
477
|
+
|
|
478
|
+
expect(mockSocket.on).toHaveBeenCalledWith('key-events', expect.any(Function));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should emit landing event when received', () => {
|
|
482
|
+
const emitSpy = jest.fn();
|
|
483
|
+
listener.emit = emitSpy;
|
|
484
|
+
listener.setupEventListener();
|
|
485
|
+
|
|
486
|
+
const landingPayload = {
|
|
487
|
+
gameId: 'msfs',
|
|
488
|
+
eventType: 'landing',
|
|
489
|
+
data: {
|
|
490
|
+
landing_rate: -150,
|
|
491
|
+
quality: 'normal',
|
|
492
|
+
pitch_at_touchdown: 3.5,
|
|
493
|
+
roll_at_touchdown: 0.5,
|
|
494
|
+
speed_at_touchdown: 135,
|
|
495
|
+
heading_at_touchdown: 270,
|
|
496
|
+
position: { lat: 47.5, lon: -122.3 },
|
|
497
|
+
bounce_count: 0,
|
|
498
|
+
bounces: [],
|
|
499
|
+
timestamp: 1234567890
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
mockSocket._trigger('key-events', landingPayload);
|
|
504
|
+
|
|
505
|
+
expect(emitSpy).toHaveBeenCalledWith('landing', landingPayload.data);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should emit takeoff event when received', () => {
|
|
509
|
+
const emitSpy = jest.fn();
|
|
510
|
+
listener.emit = emitSpy;
|
|
511
|
+
listener.setupEventListener();
|
|
512
|
+
|
|
513
|
+
const takeoffPayload = {
|
|
514
|
+
gameId: 'msfs',
|
|
515
|
+
eventType: 'takeoff',
|
|
516
|
+
data: {
|
|
517
|
+
rotation_speed: 145,
|
|
518
|
+
pitch_at_liftoff: 10,
|
|
519
|
+
heading_at_liftoff: 270,
|
|
520
|
+
position: { lat: 47.5, lon: -122.3 },
|
|
521
|
+
flaps_setting: 0.2,
|
|
522
|
+
timestamp: 1234567890
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
mockSocket._trigger('key-events', takeoffPayload);
|
|
527
|
+
|
|
528
|
+
expect(emitSpy).toHaveBeenCalledWith('takeoff', takeoffPayload.data);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should emit flight_phase event when received', () => {
|
|
532
|
+
const emitSpy = jest.fn();
|
|
533
|
+
listener.emit = emitSpy;
|
|
534
|
+
listener.setupEventListener();
|
|
535
|
+
|
|
536
|
+
const phasePayload = {
|
|
537
|
+
gameId: 'msfs',
|
|
538
|
+
eventType: 'flight_phase',
|
|
539
|
+
data: {
|
|
540
|
+
phase: 'cruise',
|
|
541
|
+
previous_phase: 'climb',
|
|
542
|
+
altitude_agl: 34500,
|
|
543
|
+
altitude_msl: 35000,
|
|
544
|
+
speed: 280,
|
|
545
|
+
timestamp: 1234567890
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
mockSocket._trigger('key-events', phasePayload);
|
|
550
|
+
|
|
551
|
+
expect(emitSpy).toHaveBeenCalledWith('flight_phase', phasePayload.data);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should not emit event for different gameId', () => {
|
|
555
|
+
const emitSpy = jest.fn();
|
|
556
|
+
listener.emit = emitSpy;
|
|
557
|
+
listener.setupEventListener();
|
|
558
|
+
|
|
559
|
+
const otherGamePayload = {
|
|
560
|
+
gameId: 'xplane', // Different from listener's 'msfs'
|
|
561
|
+
eventType: 'landing',
|
|
562
|
+
data: { landing_rate: -100 }
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
mockSocket._trigger('key-events', otherGamePayload);
|
|
566
|
+
|
|
567
|
+
expect(emitSpy).not.toHaveBeenCalledWith('landing', expect.anything());
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should handle null payload gracefully', () => {
|
|
571
|
+
const emitSpy = jest.fn();
|
|
572
|
+
listener.emit = emitSpy;
|
|
573
|
+
listener.setupEventListener();
|
|
574
|
+
|
|
575
|
+
mockSocket._trigger('key-events', null);
|
|
576
|
+
|
|
577
|
+
expect(emitSpy).not.toHaveBeenCalledWith('landing', expect.anything());
|
|
578
|
+
expect(emitSpy).not.toHaveBeenCalledWith('takeoff', expect.anything());
|
|
579
|
+
expect(emitSpy).not.toHaveBeenCalledWith('flight_phase', expect.anything());
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should handle payload with missing eventType', () => {
|
|
583
|
+
const emitSpy = jest.fn();
|
|
584
|
+
listener.emit = emitSpy;
|
|
585
|
+
listener.setupEventListener();
|
|
586
|
+
|
|
587
|
+
const invalidPayload = {
|
|
588
|
+
gameId: 'msfs',
|
|
589
|
+
data: { landing_rate: -100 }
|
|
590
|
+
// eventType missing
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
mockSocket._trigger('key-events', invalidPayload);
|
|
594
|
+
|
|
595
|
+
expect(emitSpy).not.toHaveBeenCalledWith(undefined, expect.anything());
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
});
|