gameglue 4.0.1 → 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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +275 -275
  3. package/babel.config.cjs +5 -5
  4. package/coverage/auth.js.html +525 -525
  5. package/coverage/base.css +224 -224
  6. package/coverage/block-navigation.js +87 -87
  7. package/coverage/favicon.png +0 -0
  8. package/coverage/index.html +175 -175
  9. package/coverage/index.js.html +309 -309
  10. package/coverage/lcov-report/auth.js.html +525 -525
  11. package/coverage/lcov-report/base.css +224 -224
  12. package/coverage/lcov-report/block-navigation.js +87 -87
  13. package/coverage/lcov-report/favicon.png +0 -0
  14. package/coverage/lcov-report/index.html +175 -175
  15. package/coverage/lcov-report/index.js.html +309 -309
  16. package/coverage/lcov-report/listener.js.html +528 -528
  17. package/coverage/lcov-report/prettify.css +1 -1
  18. package/coverage/lcov-report/prettify.js +2 -2
  19. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  20. package/coverage/lcov-report/sorter.js +210 -210
  21. package/coverage/lcov-report/user.js.html +117 -117
  22. package/coverage/lcov-report/utils.js.html +117 -117
  23. package/coverage/lcov.info +391 -391
  24. package/coverage/listener.js.html +528 -528
  25. package/coverage/prettify.css +1 -1
  26. package/coverage/prettify.js +2 -2
  27. package/coverage/sort-arrow-sprite.png +0 -0
  28. package/coverage/sorter.js +210 -210
  29. package/coverage/user.js.html +117 -117
  30. package/coverage/utils.js.html +117 -117
  31. package/dist/gg.cjs.js +1 -1
  32. package/dist/gg.cjs.js.map +1 -1
  33. package/dist/gg.esm.js +1 -1
  34. package/dist/gg.esm.js.map +1 -1
  35. package/dist/gg.umd.js +1 -1
  36. package/dist/gg.umd.js.map +1 -1
  37. package/examples/certs/cert.pem +19 -19
  38. package/examples/certs/key.pem +28 -28
  39. package/examples/flight-dashboard.html +431 -431
  40. package/examples/server.js +99 -99
  41. package/examples/telemetry-validator.html +1410 -1410
  42. package/jest.config.cjs +33 -33
  43. package/package.json +56 -56
  44. package/rollup.config.js +57 -57
  45. package/src/auth.js +255 -255
  46. package/src/auth.spec.js +481 -481
  47. package/src/index.js +168 -168
  48. package/src/listener.js +196 -196
  49. package/src/listener.spec.js +598 -598
  50. package/src/presence_listener.js +112 -112
  51. package/src/test/fixtures.js +106 -106
  52. package/src/test/setup.js +51 -51
  53. package/src/utils.js +63 -63
  54. package/src/utils.spec.js +78 -78
  55. package/types/index.d.ts +338 -338
  56. package/webpack.config.js +15 -15
@@ -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
+ });