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,479 @@
1
+ const assert = require('assert')
2
+
3
+ /**
4
+ * Integration tests for signalk-vessels-to-ais-ws plugin
5
+ * Tests the plugin with mock SignalK app object
6
+ */
7
+
8
+ // Mock ggencoder module
9
+ const mockAisEncode = {
10
+ nmea: null
11
+ }
12
+
13
+ // Create mock for ggencoder
14
+ const mockGgencoder = {
15
+ AisEncode: function (msg) {
16
+ this.msg = msg
17
+ // Generate a simple mock NMEA sentence
18
+ this.nmea = `!AIVDM,1,1,,A,${msg.aistype}${msg.mmsi},0*00`
19
+ return this
20
+ }
21
+ }
22
+
23
+ // We need to mock the require before loading the plugin
24
+ // For now, we'll test the helpers directly and create mock-based integration tests
25
+
26
+ describe('signalk-vessels-to-ais-ws plugin integration', function () {
27
+ /**
28
+ * Create a mock SignalK app object
29
+ */
30
+ function createMockApp(options = {}) {
31
+ const debugMessages = []
32
+ const emittedMessages = []
33
+ const statusMessages = []
34
+
35
+ return {
36
+ // Data access methods
37
+ getSelfPath: function (path) {
38
+ if (options.selfPath && options.selfPath[path]) {
39
+ return options.selfPath[path]
40
+ }
41
+ if (path === 'navigation.position.value') {
42
+ return options.ownPosition || null
43
+ }
44
+ return null
45
+ },
46
+
47
+ getPath: function (path) {
48
+ if (path === 'vessels') {
49
+ return options.vessels || null
50
+ }
51
+ return null
52
+ },
53
+
54
+ // Logging and status
55
+ debug: function (...args) {
56
+ debugMessages.push(args.join(' '))
57
+ },
58
+
59
+ setPluginStatus: function (msg) {
60
+ statusMessages.push(msg)
61
+ },
62
+
63
+ setProviderStatus: function (msg) {
64
+ statusMessages.push(msg)
65
+ },
66
+
67
+ // Event emission
68
+ emit: function (eventName, data) {
69
+ emittedMessages.push({ eventName, data })
70
+ },
71
+
72
+ reportOutputMessages: function (count) {
73
+ // Track output message count
74
+ },
75
+
76
+ // Test helpers
77
+ _getDebugMessages: () => debugMessages,
78
+ _getEmittedMessages: () => emittedMessages,
79
+ _getStatusMessages: () => statusMessages,
80
+ _clearMessages: () => {
81
+ debugMessages.length = 0
82
+ emittedMessages.length = 0
83
+ statusMessages.length = 0
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Create a mock vessel object using real SignalK structure
90
+ */
91
+ function createMockVessel(options = {}) {
92
+ const now = new Date().toISOString()
93
+ return {
94
+ mmsi: options.mmsi || '123456789',
95
+ name: options.name || 'Test Vessel',
96
+ navigation: {
97
+ position: {
98
+ // Real SignalK structure: value contains {latitude, longitude}
99
+ value: {
100
+ latitude: options.lat || 60.1,
101
+ longitude: options.lon || 24.9
102
+ },
103
+ timestamp: options.timestamp || now
104
+ },
105
+ speedOverGround: { value: options.sog || 5.0 },
106
+ courseOverGroundTrue: { value: options.cog || Math.PI / 4 },
107
+ headingTrue: { value: options.hdg || Math.PI / 4 },
108
+ rateOfTurn: { value: options.rot || 0 },
109
+ state: { value: options.state || 'motoring' }
110
+ },
111
+ sensors: {
112
+ ais: {
113
+ class: { value: options.aisClass || 'A' }
114
+ }
115
+ },
116
+ design: {
117
+ length: { overall: { value: options.length || 15 } },
118
+ beam: { value: options.beam || 5 },
119
+ draft: { current: { value: options.draft || 2 } },
120
+ aisShipType: { id: { value: options.shipType || 36 } }
121
+ },
122
+ communication: {
123
+ callsignVhf: { value: options.callsign || 'TEST1' }
124
+ }
125
+ }
126
+ }
127
+
128
+ describe('plugin initialization', function () {
129
+ it('exports a function', function () {
130
+ // We can't easily test the full plugin due to ggencoder dependency
131
+ // but we can verify the export pattern
132
+ const helpers = require('../lib/helpers')
133
+ assert(typeof helpers.getValue === 'function')
134
+ assert(typeof helpers.extractVesselData === 'function')
135
+ })
136
+ })
137
+
138
+ describe('mock app integration', function () {
139
+ it('getSelfPath returns own position', function () {
140
+ const app = createMockApp({
141
+ ownPosition: { latitude: 60.0, longitude: 24.0 }
142
+ })
143
+
144
+ const pos = app.getSelfPath('navigation.position.value')
145
+ assert.strictEqual(pos.latitude, 60.0)
146
+ assert.strictEqual(pos.longitude, 24.0)
147
+ })
148
+
149
+ it('getPath returns vessels object', function () {
150
+ const vessels = {
151
+ 'urn:mrn:imo:mmsi:123456789': createMockVessel()
152
+ }
153
+ const app = createMockApp({ vessels })
154
+
155
+ const result = app.getPath('vessels')
156
+ assert.deepStrictEqual(result, vessels)
157
+ })
158
+
159
+ it('emit captures messages', function () {
160
+ const app = createMockApp()
161
+ app.emit('nmea0183out', '!AIVDM,1,1,,A,test,0*00')
162
+
163
+ const messages = app._getEmittedMessages()
164
+ assert.strictEqual(messages.length, 1)
165
+ assert.strictEqual(messages[0].eventName, 'nmea0183out')
166
+ assert.strictEqual(messages[0].data, '!AIVDM,1,1,,A,test,0*00')
167
+ })
168
+
169
+ it('debug captures log messages', function () {
170
+ const app = createMockApp()
171
+ app.debug('Test message', 123)
172
+
173
+ const messages = app._getDebugMessages()
174
+ assert.strictEqual(messages.length, 1)
175
+ assert.strictEqual(messages[0], 'Test message 123')
176
+ })
177
+
178
+ it('setPluginStatus captures status', function () {
179
+ const app = createMockApp()
180
+ app.setPluginStatus('Running')
181
+
182
+ const messages = app._getStatusMessages()
183
+ assert.strictEqual(messages.length, 1)
184
+ assert.strictEqual(messages[0], 'Running')
185
+ })
186
+ })
187
+
188
+ describe('vessel data extraction with helpers', function () {
189
+ const { extractVesselData, isDataFresh } = require('../lib/helpers')
190
+
191
+ it('extracts complete vessel data', function () {
192
+ const vessel = createMockVessel({
193
+ mmsi: '230123456',
194
+ name: 'Nordic Spirit',
195
+ lat: 60.15,
196
+ lon: 24.95,
197
+ sog: 8.0,
198
+ aisClass: 'A'
199
+ })
200
+
201
+ const data = extractVesselData(vessel)
202
+
203
+ assert.strictEqual(data.mmsi, '230123456')
204
+ assert.strictEqual(data.shipName, 'Nordic Spirit')
205
+ assert.strictEqual(data.lat, 60.15)
206
+ assert.strictEqual(data.lon, 24.95)
207
+ assert.strictEqual(data.aisClass, 'A')
208
+ // SOG converted from m/s to knots
209
+ assert(data.sog > 15 && data.sog < 16) // 8 m/s ≈ 15.55 knots
210
+ })
211
+
212
+ it('handles Class B vessel', function () {
213
+ const vessel = createMockVessel({
214
+ aisClass: 'B',
215
+ mmsi: '230654321'
216
+ })
217
+
218
+ const data = extractVesselData(vessel)
219
+ assert.strictEqual(data.aisClass, 'B')
220
+ })
221
+
222
+ it('handles BASE station', function () {
223
+ const vessel = createMockVessel({
224
+ aisClass: 'BASE',
225
+ mmsi: '002766140'
226
+ })
227
+
228
+ const data = extractVesselData(vessel)
229
+ assert.strictEqual(data.aisClass, 'BASE')
230
+ })
231
+
232
+ it('validates data freshness', function () {
233
+ const fresh = new Date().toISOString()
234
+ const stale = new Date(Date.now() - 120000).toISOString() // 2 minutes ago
235
+
236
+ assert.strictEqual(isDataFresh(fresh, 60), true)
237
+ assert.strictEqual(isDataFresh(stale, 60), false)
238
+ })
239
+ })
240
+
241
+ describe('AIS message building', function () {
242
+ const {
243
+ buildAisMessage3,
244
+ buildAisMessage5,
245
+ buildAisMessage18,
246
+ buildAisMessage24A,
247
+ buildAisMessage24B,
248
+ extractVesselData
249
+ } = require('../lib/helpers')
250
+
251
+ it('builds complete Class A message set', function () {
252
+ const vessel = createMockVessel({
253
+ mmsi: '230111222',
254
+ name: 'Class A Ship',
255
+ aisClass: 'A'
256
+ })
257
+
258
+ const data = extractVesselData(vessel)
259
+ const msg3 = buildAisMessage3(data, false)
260
+ const msg5 = buildAisMessage5(data, false)
261
+
262
+ // Message 3 - Position report
263
+ assert.strictEqual(msg3.aistype, 3)
264
+ assert.strictEqual(msg3.mmsi, '230111222')
265
+ assert.strictEqual(msg3.own, false)
266
+
267
+ // Message 5 - Static data
268
+ assert.strictEqual(msg5.aistype, 5)
269
+ assert.strictEqual(msg5.shipname, 'Class A Ship')
270
+ })
271
+
272
+ it('builds complete Class B message set', function () {
273
+ const vessel = createMockVessel({
274
+ mmsi: '230333444',
275
+ name: 'Class B Boat',
276
+ aisClass: 'B'
277
+ })
278
+
279
+ const data = extractVesselData(vessel)
280
+ const msg18 = buildAisMessage18(data, false)
281
+ const msg24a = buildAisMessage24A(data, false)
282
+ const msg24b = buildAisMessage24B(data, false)
283
+
284
+ // Message 18 - Position report
285
+ assert.strictEqual(msg18.aistype, 18)
286
+ assert.strictEqual(msg18.mmsi, '230333444')
287
+
288
+ // Message 24A - Name
289
+ assert.strictEqual(msg24a.aistype, 24)
290
+ assert.strictEqual(msg24a.part, 0)
291
+ assert.strictEqual(msg24a.shipname, 'Class B Boat')
292
+
293
+ // Message 24B - Call sign & dimensions
294
+ assert.strictEqual(msg24b.aistype, 24)
295
+ assert.strictEqual(msg24b.part, 1)
296
+ })
297
+
298
+ it('sets own flag for own vessel', function () {
299
+ const vessel = createMockVessel()
300
+ const data = extractVesselData(vessel)
301
+
302
+ const msgOwn = buildAisMessage3(data, true)
303
+ const msgOther = buildAisMessage3(data, false)
304
+
305
+ assert.strictEqual(msgOwn.own, true)
306
+ assert.strictEqual(msgOther.own, false)
307
+ })
308
+ })
309
+
310
+ describe('distance calculation scenario', function () {
311
+ const haversine = require('haversine-distance')
312
+
313
+ it('calculates distance between two positions', function () {
314
+ const ownPos = { lat: 60.0, lon: 24.0 }
315
+ const otherPos = { lat: 60.1, lon: 24.0 }
316
+
317
+ const distMeters = haversine(ownPos, otherPos)
318
+ const distKm = distMeters / 1000
319
+
320
+ // Should be approximately 11.1 km (1 degree latitude ≈ 111 km)
321
+ assert(distKm > 10 && distKm < 12)
322
+ })
323
+
324
+ it('identifies vessels within range', function () {
325
+ const ownPos = { lat: 60.0, lon: 24.0 }
326
+ const nearVessel = { lat: 60.05, lon: 24.0 } // ~5.5 km
327
+ const farVessel = { lat: 61.0, lon: 24.0 } // ~111 km
328
+
329
+ const nearDist = haversine(ownPos, nearVessel) / 1000
330
+ const farDist = haversine(ownPos, farVessel) / 1000
331
+
332
+ const maxDistance = 50 // km
333
+
334
+ assert(nearDist < maxDistance, 'Near vessel should be within range')
335
+ assert(farDist > maxDistance, 'Far vessel should be out of range')
336
+ })
337
+ })
338
+
339
+ describe('navigation state mapping', function () {
340
+ const { getNavStatus } = require('../lib/helpers')
341
+
342
+ it('maps all motoring variations', function () {
343
+ assert.strictEqual(getNavStatus('motoring'), 0)
344
+ assert.strictEqual(getNavStatus('UnderWayUsingEngine'), 0)
345
+ assert.strictEqual(getNavStatus('under way using engine'), 0)
346
+ })
347
+
348
+ it('maps all sailing variations', function () {
349
+ assert.strictEqual(getNavStatus('sailing'), 8)
350
+ assert.strictEqual(getNavStatus('UnderWaySailing'), 8)
351
+ assert.strictEqual(getNavStatus('under way sailing'), 8)
352
+ })
353
+
354
+ it('maps anchored state', function () {
355
+ assert.strictEqual(getNavStatus('anchored'), 1)
356
+ assert.strictEqual(getNavStatus('AtAnchor'), 1)
357
+ })
358
+
359
+ it('maps moored state', function () {
360
+ assert.strictEqual(getNavStatus('moored'), 5)
361
+ assert.strictEqual(getNavStatus('Moored'), 5)
362
+ })
363
+
364
+ it('returns empty string for unknown state', function () {
365
+ assert.strictEqual(getNavStatus('flying'), '')
366
+ })
367
+ })
368
+
369
+ describe('tag block generation', function () {
370
+ const { createTagBlock, toHexString } = require('../lib/helpers')
371
+
372
+ it('generates valid NMEA tag block', function () {
373
+ const tag = createTagBlock(1609459200000) // 2021-01-01 00:00:00 UTC
374
+
375
+ // Tag block format: \s:source,c:timestamp*checksum\
376
+ assert(tag.startsWith('\\'), 'Should start with backslash')
377
+ assert(tag.endsWith('\\'), 'Should end with backslash')
378
+ assert(tag.includes('s:SK0001'), 'Should include source')
379
+ assert(tag.includes('c:1609459200000'), 'Should include timestamp')
380
+ })
381
+
382
+ it('generates correct hex for checksum', function () {
383
+ assert.strictEqual(toHexString(0), '00')
384
+ assert.strictEqual(toHexString(255), 'FF')
385
+ assert.strictEqual(toHexString(16), '10')
386
+ })
387
+ })
388
+
389
+ describe('edge cases', function () {
390
+ const { getValue, extractVesselData } = require('../lib/helpers')
391
+
392
+ it('handles empty vessel object', function () {
393
+ const data = extractVesselData({})
394
+
395
+ assert.strictEqual(data.mmsi, null)
396
+ assert.strictEqual(data.lat, null)
397
+ assert.strictEqual(data.lon, null)
398
+ })
399
+
400
+ it('handles vessel with only MMSI', function () {
401
+ const data = extractVesselData({ mmsi: '123456789' })
402
+
403
+ assert.strictEqual(data.mmsi, '123456789')
404
+ assert.strictEqual(data.lat, null)
405
+ })
406
+
407
+ it('handles deeply nested null values', function () {
408
+ const vessel = {
409
+ navigation: {
410
+ position: null
411
+ }
412
+ }
413
+
414
+ const data = extractVesselData(vessel)
415
+ assert.strictEqual(data.lat, null)
416
+ })
417
+
418
+ it('handles numeric name (converts to empty string)', function () {
419
+ const vessel = {
420
+ mmsi: '123456789',
421
+ name: 12345
422
+ }
423
+
424
+ const data = extractVesselData(vessel)
425
+ assert.strictEqual(data.shipName, '')
426
+ })
427
+
428
+ it('handles IMO prefix removal', function () {
429
+ const vessel = {
430
+ mmsi: '123456789',
431
+ registrations: {
432
+ imo: { value: 'IMO 9876543' }
433
+ }
434
+ }
435
+
436
+ const data = extractVesselData(vessel)
437
+ assert.strictEqual(data.imo, '9876543')
438
+ })
439
+
440
+ it('handles IMO without prefix', function () {
441
+ const vessel = {
442
+ mmsi: '123456789',
443
+ registrations: {
444
+ imo: { value: '9876543' }
445
+ }
446
+ }
447
+
448
+ const data = extractVesselData(vessel)
449
+ assert.strictEqual(data.imo, '9876543')
450
+ })
451
+ })
452
+
453
+ describe('unit conversions', function () {
454
+ const { radToDegrees, msToKnots } = require('../lib/helpers')
455
+
456
+ it('converts common navigation angles', function () {
457
+ // North
458
+ assert.strictEqual(radToDegrees(0), 0)
459
+ // East
460
+ assert.strictEqual(radToDegrees(Math.PI / 2), 90)
461
+ // South
462
+ assert.strictEqual(radToDegrees(Math.PI), 180)
463
+ // West
464
+ assert.strictEqual(radToDegrees(3 * Math.PI / 2), 270)
465
+ })
466
+
467
+ it('converts common speeds', function () {
468
+ // 1 knot in m/s
469
+ const oneKnotInMs = 0.514444
470
+ const result = msToKnots(oneKnotInMs)
471
+ assert(Math.abs(result - 1.0) < 0.01, `Expected ~1 knot, got ${result}`)
472
+
473
+ // 10 knots
474
+ const tenKnotsInMs = 5.14444
475
+ const result10 = msToKnots(tenKnotsInMs)
476
+ assert(Math.abs(result10 - 10.0) < 0.1, `Expected ~10 knots, got ${result10}`)
477
+ })
478
+ })
479
+ })