signalk-vessels-to-ais 1.6.1 → 2.0.0-beta.1

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.
@@ -0,0 +1,609 @@
1
+ const assert = require('assert')
2
+ const {
3
+ stateMapping,
4
+ getValue,
5
+ getTimestamp,
6
+ radToDegrees,
7
+ msToKnots,
8
+ toHexString,
9
+ createTagBlock,
10
+ getNavStatus,
11
+ extractVesselData,
12
+ buildAisMessage3,
13
+ buildAisMessage5,
14
+ buildAisMessage18,
15
+ buildAisMessage24A,
16
+ buildAisMessage24B,
17
+ isDataFresh
18
+ } = require('../lib/helpers')
19
+
20
+ describe('signalk-vessels-to-ais-ws helpers', function () {
21
+ // ============================================================
22
+ // getValue tests
23
+ // ============================================================
24
+ describe('getValue', function () {
25
+ it('returns null for null input', function () {
26
+ assert.strictEqual(getValue(null, 'any.path'), null)
27
+ })
28
+
29
+ it('returns null for undefined input', function () {
30
+ assert.strictEqual(getValue(undefined, 'any.path'), null)
31
+ })
32
+
33
+ it('returns direct value for simple path', function () {
34
+ const obj = { mmsi: '123456789' }
35
+ assert.strictEqual(getValue(obj, 'mmsi'), '123456789')
36
+ })
37
+
38
+ it('returns nested value for dotted path', function () {
39
+ const obj = {
40
+ navigation: {
41
+ position: {
42
+ latitude: 60.1
43
+ }
44
+ }
45
+ }
46
+ assert.strictEqual(getValue(obj, 'navigation.position.latitude'), 60.1)
47
+ })
48
+
49
+ it('extracts value from SignalK value wrapper', function () {
50
+ const obj = {
51
+ navigation: {
52
+ speedOverGround: {
53
+ value: 5.5,
54
+ timestamp: '2024-01-01T00:00:00Z'
55
+ }
56
+ }
57
+ }
58
+ assert.strictEqual(getValue(obj, 'navigation.speedOverGround'), 5.5)
59
+ })
60
+
61
+ it('returns null for non-existent path', function () {
62
+ const obj = { navigation: {} }
63
+ assert.strictEqual(getValue(obj, 'navigation.position.latitude'), null)
64
+ })
65
+
66
+ it('handles path with missing intermediate object', function () {
67
+ const obj = { navigation: null }
68
+ assert.strictEqual(getValue(obj, 'navigation.position.latitude'), null)
69
+ })
70
+
71
+ it('returns object if value property not present', function () {
72
+ const obj = {
73
+ design: {
74
+ length: {
75
+ overall: 15.5
76
+ }
77
+ }
78
+ }
79
+ assert.deepStrictEqual(getValue(obj, 'design.length'), { overall: 15.5 })
80
+ })
81
+ })
82
+
83
+ // ============================================================
84
+ // getTimestamp tests
85
+ // ============================================================
86
+ describe('getTimestamp', function () {
87
+ it('returns null for null input', function () {
88
+ assert.strictEqual(getTimestamp(null, 'any.path'), null)
89
+ })
90
+
91
+ it('returns null for undefined input', function () {
92
+ assert.strictEqual(getTimestamp(undefined, 'any.path'), null)
93
+ })
94
+
95
+ it('extracts timestamp from SignalK object', function () {
96
+ const timestamp = '2024-01-01T12:00:00Z'
97
+ const obj = {
98
+ navigation: {
99
+ position: {
100
+ value: { latitude: 60.1, longitude: 24.9 },
101
+ timestamp: timestamp
102
+ }
103
+ }
104
+ }
105
+ assert.strictEqual(getTimestamp(obj, 'navigation.position'), timestamp)
106
+ })
107
+
108
+ it('returns null if no timestamp property', function () {
109
+ const obj = {
110
+ navigation: {
111
+ position: {
112
+ value: { latitude: 60.1, longitude: 24.9 }
113
+ }
114
+ }
115
+ }
116
+ assert.strictEqual(getTimestamp(obj, 'navigation.position'), null)
117
+ })
118
+
119
+ it('returns null for non-existent path', function () {
120
+ const obj = { navigation: {} }
121
+ assert.strictEqual(getTimestamp(obj, 'navigation.position'), null)
122
+ })
123
+ })
124
+
125
+ // ============================================================
126
+ // radToDegrees tests
127
+ // ============================================================
128
+ describe('radToDegrees', function () {
129
+ it('returns null for null input', function () {
130
+ assert.strictEqual(radToDegrees(null), null)
131
+ })
132
+
133
+ it('returns null for undefined input', function () {
134
+ assert.strictEqual(radToDegrees(undefined), null)
135
+ })
136
+
137
+ it('converts 0 radians to 0 degrees', function () {
138
+ assert.strictEqual(radToDegrees(0), 0)
139
+ })
140
+
141
+ it('converts PI radians to 180 degrees', function () {
142
+ assert.strictEqual(radToDegrees(Math.PI), 180)
143
+ })
144
+
145
+ it('converts PI/2 radians to 90 degrees', function () {
146
+ assert.strictEqual(radToDegrees(Math.PI / 2), 90)
147
+ })
148
+
149
+ it('converts 2*PI radians to 360 degrees', function () {
150
+ assert.strictEqual(radToDegrees(2 * Math.PI), 360)
151
+ })
152
+
153
+ it('converts negative radians correctly', function () {
154
+ assert.strictEqual(radToDegrees(-Math.PI), -180)
155
+ })
156
+
157
+ it('handles fractional radians', function () {
158
+ const result = radToDegrees(1)
159
+ assert(Math.abs(result - 57.29577951308232) < 0.0001)
160
+ })
161
+ })
162
+
163
+ // ============================================================
164
+ // msToKnots tests
165
+ // ============================================================
166
+ describe('msToKnots', function () {
167
+ it('returns null for null input', function () {
168
+ assert.strictEqual(msToKnots(null), null)
169
+ })
170
+
171
+ it('returns null for undefined input', function () {
172
+ assert.strictEqual(msToKnots(undefined), null)
173
+ })
174
+
175
+ it('converts 0 m/s to 0 knots', function () {
176
+ assert.strictEqual(msToKnots(0), 0)
177
+ })
178
+
179
+ it('converts 1 m/s to approximately 1.94 knots', function () {
180
+ const result = msToKnots(1)
181
+ assert(Math.abs(result - 1.9438) < 0.001)
182
+ })
183
+
184
+ it('converts 10 m/s to approximately 19.44 knots', function () {
185
+ const result = msToKnots(10)
186
+ assert(Math.abs(result - 19.438) < 0.01)
187
+ })
188
+
189
+ it('converts 0.5144 m/s (1 knot) back to approximately 1 knot', function () {
190
+ const result = msToKnots(0.5144)
191
+ assert(Math.abs(result - 1.0) < 0.01)
192
+ })
193
+ })
194
+
195
+ // ============================================================
196
+ // toHexString tests
197
+ // ============================================================
198
+ describe('toHexString', function () {
199
+ it('converts 0 to 00', function () {
200
+ assert.strictEqual(toHexString(0), '00')
201
+ })
202
+
203
+ it('converts 15 to 0F', function () {
204
+ assert.strictEqual(toHexString(15), '0F')
205
+ })
206
+
207
+ it('converts 16 to 10', function () {
208
+ assert.strictEqual(toHexString(16), '10')
209
+ })
210
+
211
+ it('converts 255 to FF', function () {
212
+ assert.strictEqual(toHexString(255), 'FF')
213
+ })
214
+
215
+ it('converts 170 to AA', function () {
216
+ assert.strictEqual(toHexString(170), 'AA')
217
+ })
218
+
219
+ it('converts 100 to 64', function () {
220
+ assert.strictEqual(toHexString(100), '64')
221
+ })
222
+ })
223
+
224
+ // ============================================================
225
+ // createTagBlock tests
226
+ // ============================================================
227
+ describe('createTagBlock', function () {
228
+ it('creates valid tag block format', function () {
229
+ const tag = createTagBlock(1234567890123)
230
+ assert(tag.startsWith('\\'))
231
+ assert(tag.endsWith('\\'))
232
+ assert(tag.includes('s:SK0001'))
233
+ assert(tag.includes('c:1234567890123'))
234
+ assert(tag.includes('*'))
235
+ })
236
+
237
+ it('includes checksum in hex format', function () {
238
+ const tag = createTagBlock(1234567890123)
239
+ const match = tag.match(/\*([0-9A-F]{2})\\$/)
240
+ assert(match !== null, 'Tag block should end with *XX\\ format')
241
+ })
242
+
243
+ it('generates consistent checksum for same input', function () {
244
+ const tag1 = createTagBlock(1234567890123)
245
+ const tag2 = createTagBlock(1234567890123)
246
+ assert.strictEqual(tag1, tag2)
247
+ })
248
+
249
+ it('generates different checksum for different timestamp', function () {
250
+ const tag1 = createTagBlock(1234567890123)
251
+ const tag2 = createTagBlock(9876543210987)
252
+ assert.notStrictEqual(tag1, tag2)
253
+ })
254
+ })
255
+
256
+ // ============================================================
257
+ // stateMapping tests
258
+ // ============================================================
259
+ describe('stateMapping', function () {
260
+ it('maps motoring to 0', function () {
261
+ assert.strictEqual(stateMapping['motoring'], 0)
262
+ })
263
+
264
+ it('maps anchored to 1', function () {
265
+ assert.strictEqual(stateMapping['anchored'], 1)
266
+ })
267
+
268
+ it('maps moored to 5', function () {
269
+ assert.strictEqual(stateMapping['moored'], 5)
270
+ })
271
+
272
+ it('maps sailing to 8', function () {
273
+ assert.strictEqual(stateMapping['sailing'], 8)
274
+ })
275
+
276
+ it('maps multiple variations of same state', function () {
277
+ assert.strictEqual(stateMapping['UnderWayUsingEngine'], 0)
278
+ assert.strictEqual(stateMapping['under way using engine'], 0)
279
+ assert.strictEqual(stateMapping['underway using engine'], 0)
280
+ })
281
+
282
+ it('maps undefined to 15', function () {
283
+ assert.strictEqual(stateMapping['undefined'], 15)
284
+ assert.strictEqual(stateMapping['UnDefined'], 15)
285
+ })
286
+ })
287
+
288
+ // ============================================================
289
+ // getNavStatus tests
290
+ // ============================================================
291
+ describe('getNavStatus', function () {
292
+ it('returns empty string for null', function () {
293
+ assert.strictEqual(getNavStatus(null), '')
294
+ })
295
+
296
+ it('returns empty string for undefined', function () {
297
+ assert.strictEqual(getNavStatus(undefined), '')
298
+ })
299
+
300
+ it('returns 0 for motoring', function () {
301
+ assert.strictEqual(getNavStatus('motoring'), 0)
302
+ })
303
+
304
+ it('returns 8 for sailing', function () {
305
+ assert.strictEqual(getNavStatus('sailing'), 8)
306
+ })
307
+
308
+ it('returns empty string for unknown state', function () {
309
+ assert.strictEqual(getNavStatus('unknown_state'), '')
310
+ })
311
+ })
312
+
313
+ // ============================================================
314
+ // extractVesselData tests
315
+ // ============================================================
316
+ describe('extractVesselData', function () {
317
+ it('extracts basic vessel data', function () {
318
+ // Using real SignalK structure: position.value contains {latitude, longitude}
319
+ const vessel = {
320
+ mmsi: '123456789',
321
+ name: 'Test Vessel',
322
+ navigation: {
323
+ position: {
324
+ value: { latitude: 60.1, longitude: 24.9 }
325
+ },
326
+ speedOverGround: { value: 5.0 },
327
+ courseOverGroundTrue: { value: Math.PI / 2 }
328
+ },
329
+ sensors: {
330
+ ais: {
331
+ class: { value: 'A' }
332
+ }
333
+ }
334
+ }
335
+
336
+ const data = extractVesselData(vessel)
337
+
338
+ assert.strictEqual(data.mmsi, '123456789')
339
+ assert.strictEqual(data.shipName, 'Test Vessel')
340
+ assert.strictEqual(data.lat, 60.1)
341
+ assert.strictEqual(data.lon, 24.9)
342
+ assert(Math.abs(data.sog - 9.719) < 0.01) // 5 m/s in knots
343
+ assert.strictEqual(data.cog, 90) // PI/2 in degrees
344
+ assert.strictEqual(data.aisClass, 'A')
345
+ })
346
+
347
+ it('handles missing optional fields', function () {
348
+ const vessel = {
349
+ mmsi: '123456789',
350
+ navigation: {
351
+ position: {
352
+ value: { latitude: 60.1, longitude: 24.9 }
353
+ }
354
+ }
355
+ }
356
+
357
+ const data = extractVesselData(vessel)
358
+
359
+ assert.strictEqual(data.mmsi, '123456789')
360
+ assert.strictEqual(data.lat, 60.1)
361
+ assert.strictEqual(data.lon, 24.9)
362
+ assert.strictEqual(data.sog, null)
363
+ assert.strictEqual(data.cog, null)
364
+ assert.strictEqual(data.shipName, null)
365
+ })
366
+
367
+ it('converts numeric shipName to empty string', function () {
368
+ const vessel = {
369
+ mmsi: '123456789',
370
+ name: 12345
371
+ }
372
+
373
+ const data = extractVesselData(vessel)
374
+ assert.strictEqual(data.shipName, '')
375
+ })
376
+
377
+ it('extracts IMO with prefix removed', function () {
378
+ const vessel = {
379
+ mmsi: '123456789',
380
+ registrations: {
381
+ imo: { value: 'IMO 9876543' }
382
+ }
383
+ }
384
+
385
+ const data = extractVesselData(vessel)
386
+ assert.strictEqual(data.imo, '9876543')
387
+ })
388
+
389
+ it('handles beam division by 2', function () {
390
+ const vessel = {
391
+ mmsi: '123456789',
392
+ design: {
393
+ beam: { value: 10 }
394
+ }
395
+ }
396
+
397
+ const data = extractVesselData(vessel)
398
+ assert.strictEqual(data.beam, 5)
399
+ })
400
+
401
+ it('handles draft in meters (no division)', function () {
402
+ const vessel = {
403
+ mmsi: '123456789',
404
+ design: {
405
+ draft: {
406
+ current: { value: 3.5 }
407
+ }
408
+ }
409
+ }
410
+
411
+ const data = extractVesselData(vessel)
412
+ // Draft is passed through as-is in meters - ggencoder handles conversion
413
+ assert.strictEqual(data.draftCur, 3.5)
414
+ })
415
+ })
416
+
417
+ // ============================================================
418
+ // AIS Message builders tests
419
+ // ============================================================
420
+ describe('buildAisMessage3 (Class A position)', function () {
421
+ const testData = {
422
+ mmsi: '123456789',
423
+ lat: 60.1,
424
+ lon: 24.9,
425
+ sog: 10.5,
426
+ cog: 180,
427
+ hdg: 175,
428
+ rot: 0.5,
429
+ navStat: 0
430
+ }
431
+
432
+ it('builds message with correct aistype', function () {
433
+ const msg = buildAisMessage3(testData, false)
434
+ assert.strictEqual(msg.aistype, 3)
435
+ })
436
+
437
+ it('sets own flag correctly', function () {
438
+ const msgOwn = buildAisMessage3(testData, true)
439
+ const msgOther = buildAisMessage3(testData, false)
440
+ assert.strictEqual(msgOwn.own, true)
441
+ assert.strictEqual(msgOther.own, false)
442
+ })
443
+
444
+ it('includes all required fields', function () {
445
+ const msg = buildAisMessage3(testData, false)
446
+ assert.strictEqual(msg.mmsi, '123456789')
447
+ assert.strictEqual(msg.lat, 60.1)
448
+ assert.strictEqual(msg.lon, 24.9)
449
+ assert.strictEqual(msg.sog, 10.5)
450
+ assert.strictEqual(msg.cog, 180)
451
+ assert.strictEqual(msg.hdg, 175)
452
+ assert.strictEqual(msg.rot, 0.5)
453
+ assert.strictEqual(msg.navstatus, 0)
454
+ assert.strictEqual(msg.repeat, 0)
455
+ })
456
+ })
457
+
458
+ describe('buildAisMessage5 (Class A static)', function () {
459
+ const testData = {
460
+ mmsi: '123456789',
461
+ imo: '9876543',
462
+ id: 70,
463
+ callSign: 'ABCD',
464
+ shipName: 'Test Ship',
465
+ draftCur: 3.5,
466
+ dst: 'Helsinki',
467
+ length: 50,
468
+ beam: 8
469
+ }
470
+
471
+ it('builds message with correct aistype', function () {
472
+ const msg = buildAisMessage5(testData, false)
473
+ assert.strictEqual(msg.aistype, 5)
474
+ })
475
+
476
+ it('includes all required fields', function () {
477
+ const msg = buildAisMessage5(testData, false)
478
+ assert.strictEqual(msg.mmsi, '123456789')
479
+ assert.strictEqual(msg.imo, '9876543')
480
+ assert.strictEqual(msg.cargo, 70)
481
+ assert.strictEqual(msg.callsign, 'ABCD')
482
+ assert.strictEqual(msg.shipname, 'Test Ship')
483
+ assert.strictEqual(msg.draught, 3.5)
484
+ assert.strictEqual(msg.destination, 'Helsinki')
485
+ assert.strictEqual(msg.dimA, 0)
486
+ assert.strictEqual(msg.dimB, 50)
487
+ assert.strictEqual(msg.dimC, 8)
488
+ assert.strictEqual(msg.dimD, 8)
489
+ })
490
+ })
491
+
492
+ describe('buildAisMessage18 (Class B position)', function () {
493
+ const testData = {
494
+ mmsi: '123456789',
495
+ lat: 60.1,
496
+ lon: 24.9,
497
+ sog: 8.0,
498
+ cog: 90,
499
+ hdg: 85
500
+ }
501
+
502
+ it('builds message with correct aistype', function () {
503
+ const msg = buildAisMessage18(testData, false)
504
+ assert.strictEqual(msg.aistype, 18)
505
+ })
506
+
507
+ it('includes accuracy field set to 0', function () {
508
+ const msg = buildAisMessage18(testData, false)
509
+ assert.strictEqual(msg.accuracy, 0)
510
+ })
511
+
512
+ it('includes all required fields', function () {
513
+ const msg = buildAisMessage18(testData, false)
514
+ assert.strictEqual(msg.mmsi, '123456789')
515
+ assert.strictEqual(msg.lat, 60.1)
516
+ assert.strictEqual(msg.lon, 24.9)
517
+ assert.strictEqual(msg.sog, 8.0)
518
+ assert.strictEqual(msg.cog, 90)
519
+ assert.strictEqual(msg.hdg, 85)
520
+ })
521
+ })
522
+
523
+ describe('buildAisMessage24A (Class B static part A)', function () {
524
+ const testData = {
525
+ mmsi: '123456789',
526
+ shipName: 'Test Boat'
527
+ }
528
+
529
+ it('builds message with correct aistype', function () {
530
+ const msg = buildAisMessage24A(testData, false)
531
+ assert.strictEqual(msg.aistype, 24)
532
+ })
533
+
534
+ it('sets part to 0', function () {
535
+ const msg = buildAisMessage24A(testData, false)
536
+ assert.strictEqual(msg.part, 0)
537
+ })
538
+
539
+ it('includes ship name', function () {
540
+ const msg = buildAisMessage24A(testData, false)
541
+ assert.strictEqual(msg.shipname, 'Test Boat')
542
+ })
543
+ })
544
+
545
+ describe('buildAisMessage24B (Class B static part B)', function () {
546
+ const testData = {
547
+ mmsi: '123456789',
548
+ id: 36,
549
+ callSign: 'XYZ',
550
+ length: 12,
551
+ beam: 4
552
+ }
553
+
554
+ it('builds message with correct aistype', function () {
555
+ const msg = buildAisMessage24B(testData, false)
556
+ assert.strictEqual(msg.aistype, 24)
557
+ })
558
+
559
+ it('sets part to 1', function () {
560
+ const msg = buildAisMessage24B(testData, false)
561
+ assert.strictEqual(msg.part, 1)
562
+ })
563
+
564
+ it('includes all required fields', function () {
565
+ const msg = buildAisMessage24B(testData, false)
566
+ assert.strictEqual(msg.mmsi, '123456789')
567
+ assert.strictEqual(msg.cargo, 36)
568
+ assert.strictEqual(msg.callsign, 'XYZ')
569
+ assert.strictEqual(msg.dimA, 0)
570
+ assert.strictEqual(msg.dimB, 12)
571
+ assert.strictEqual(msg.dimC, 4)
572
+ assert.strictEqual(msg.dimD, 4)
573
+ })
574
+ })
575
+
576
+ // ============================================================
577
+ // isDataFresh tests
578
+ // ============================================================
579
+ describe('isDataFresh', function () {
580
+ it('returns false for null timestamp', function () {
581
+ assert.strictEqual(isDataFresh(null, 60), false)
582
+ })
583
+
584
+ it('returns false for undefined timestamp', function () {
585
+ assert.strictEqual(isDataFresh(undefined, 60), false)
586
+ })
587
+
588
+ it('returns true for recent timestamp', function () {
589
+ const recentTime = new Date(Date.now() - 10000).toISOString() // 10 seconds ago
590
+ assert.strictEqual(isDataFresh(recentTime, 60), true)
591
+ })
592
+
593
+ it('returns false for old timestamp', function () {
594
+ const oldTime = new Date(Date.now() - 120000).toISOString() // 2 minutes ago
595
+ assert.strictEqual(isDataFresh(oldTime, 60), false)
596
+ })
597
+
598
+ it('returns true for timestamp exactly at boundary', function () {
599
+ const boundaryTime = new Date(Date.now() - 30000).toISOString() // 30 seconds ago
600
+ assert.strictEqual(isDataFresh(boundaryTime, 60), true)
601
+ })
602
+
603
+ it('respects different maxAge values', function () {
604
+ const time = new Date(Date.now() - 45000).toISOString() // 45 seconds ago
605
+ assert.strictEqual(isDataFresh(time, 60), true) // 60s max -> fresh
606
+ assert.strictEqual(isDataFresh(time, 30), false) // 30s max -> stale
607
+ })
608
+ })
609
+ })