speedandcurrent 1.7.9 → 2.0.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.
package/public/index.html CHANGED
@@ -91,12 +91,7 @@
91
91
  <div class="card">
92
92
  <div class="card-header fw-bold text-uppercase">Inputs</div>
93
93
  <div class="card-body">
94
- <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">Signal K Sources</h6>
95
- <p class="text-muted small mb-2">Leave blank / select (any) to accept all sources.</p>
96
- <table class="table table-sm table-borderless mb-0" id="inputs-sources-table">
97
- <tbody></tbody>
98
- </table>
99
- <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Settings</h6>
94
+ <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">Settings</h6>
100
95
  <table class="table table-sm table-borderless mb-0" id="inputs-settings-table">
101
96
  <tbody></tbody>
102
97
  </table>
@@ -0,0 +1,414 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // App shim helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Minimal BaconJS-style reactive-stream bus mock.
15
+ * Every chaining method returns `this`; onValue returns an unsubscribe no-op.
16
+ * This satisfies the internal stream API used by signalkutilities.
17
+ */
18
+ function createMockBus() {
19
+ const bus = {};
20
+ const chainMethods = [
21
+ 'onError', 'onEnd', 'skipDuplicates', 'map', 'filter', 'take', 'first',
22
+ 'toPromise', 'flatMap', 'flatMapLatest', 'merge', 'debounce',
23
+ 'debounceImmediate', 'throttle', 'delay', 'bufferWithTime', 'bufferWithCount',
24
+ 'combine', 'sampledBy', 'scan', 'fold', 'zip', 'awaiting', 'not', 'log',
25
+ 'doAction', 'doLog', 'doError', 'doEnd', 'withHandler', 'name',
26
+ 'withDescription', 'skip', 'slidingWindow', 'startWith', 'mapEnd',
27
+ 'skipWhile', 'takeWhile', 'takeUntil', 'errors', 'mapError', 'subscribe',
28
+ ];
29
+ for (const m of chainMethods) bus[m] = () => bus;
30
+ bus.onValue = (_cb) => () => {};
31
+ bus.push = () => {};
32
+ bus.plug = () => () => {};
33
+ bus.end = () => {};
34
+ return bus;
35
+ }
36
+
37
+ /**
38
+ * Create a minimal SignalK app shim that satisfies the speedandcurrent plugin.
39
+ * A Proxy is used so that any method not explicitly stubbed returns a no-op
40
+ * instead of throwing a TypeError.
41
+ *
42
+ * @returns {{ app: object, cleanup: () => void }}
43
+ */
44
+ function createAppShim() {
45
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speedandcurrent-test-'));
46
+ const dataDir = path.join(tmpDir, 'data');
47
+ fs.mkdirSync(dataDir, { recursive: true });
48
+ const configFile = path.join(tmpDir, 'SpeedAndCurrent.json');
49
+
50
+ const base = {
51
+ // Logging
52
+ debug: () => {},
53
+ error: () => {},
54
+
55
+ // Plugin status
56
+ setPluginStatus: () => {},
57
+ setPluginError: () => {},
58
+
59
+ // Delta output
60
+ handleMessage: () => {},
61
+
62
+ // Data access
63
+ getSelfPath: () => undefined,
64
+ getPath: () => undefined,
65
+ getMetadata: () => undefined,
66
+ putSelfPath: (_p, _v, cb) => { if (cb) cb({ state: 'COMPLETED' }); },
67
+ putPath: (_p, _v, cb) => { if (cb) cb({ state: 'COMPLETED' }); },
68
+
69
+ // Plugin config persistence
70
+ readPluginOptions: () => {
71
+ try { return JSON.parse(fs.readFileSync(configFile, 'utf-8')); } catch { return {}; }
72
+ },
73
+ savePluginOptions: (config, cb) => {
74
+ fs.writeFileSync(configFile, JSON.stringify(config));
75
+ if (cb) cb();
76
+ },
77
+ getPluginOptions: () => ({}),
78
+ getDataDirPath: () => dataDir,
79
+
80
+ // Subscription infrastructure
81
+ registerDeltaInputHandler: () => () => {},
82
+ registerPutHandler: () => () => {},
83
+
84
+ streambundle: {
85
+ getSelfBus: () => createMockBus(),
86
+ getBus: () => createMockBus(),
87
+ getSelfStream: () => createMockBus(),
88
+ getAvailablePaths: () => [],
89
+ },
90
+
91
+ subscriptionmanager: {
92
+ subscribe: (_msg, unsubscribes, _errorCb, _deltaCb) => {
93
+ const unsub = () => {};
94
+ if (Array.isArray(unsubscribes)) unsubscribes.push(unsub);
95
+ },
96
+ },
97
+
98
+ // Event emitter API
99
+ on: () => {},
100
+ once: () => {},
101
+ emit: () => {},
102
+ removeListener: () => {},
103
+ removeAllListeners: () => {},
104
+
105
+ // Server identity
106
+ selfId: 'urn:mrn:signalk:uuid:00000000-0000-0000-0000-000000000000',
107
+ selfType: 'vessels',
108
+ selfContext: 'vessels.urn:mrn:signalk:uuid:00000000-0000-0000-0000-000000000000',
109
+
110
+ config: {
111
+ configPath: tmpDir,
112
+ appPath: tmpDir,
113
+ version: '2.24.0',
114
+ name: 'signalk-server',
115
+ basePath: '/signalk/v1',
116
+ defaults: {},
117
+ },
118
+
119
+ reportOutputMessages: () => {},
120
+
121
+ wrappedEmitter: {
122
+ bindMethodsById: () => ({ on: () => {}, removeListener: () => {} }),
123
+ },
124
+ };
125
+
126
+ // Proxy: any property not found in base returns a no-op function so unstubbed
127
+ // accesses from signalkutilities don't throw.
128
+ const app = new Proxy(base, {
129
+ get(target, prop) {
130
+ if (prop in target) return target[prop];
131
+ if (typeof prop === 'symbol') return undefined;
132
+ return () => {};
133
+ },
134
+ });
135
+
136
+ const cleanup = () => {
137
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
138
+ };
139
+
140
+ return { app, cleanup };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Tests
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('module export', () => {
148
+ it('exports a factory function', () => {
149
+ const factory = require('../index.js');
150
+ assert.strictEqual(typeof factory, 'function', 'module.exports must be a function');
151
+ });
152
+ });
153
+
154
+ describe('plugin object shape', () => {
155
+ let plugin;
156
+ let cleanup;
157
+
158
+ before(() => {
159
+ const shim = createAppShim();
160
+ cleanup = shim.cleanup;
161
+ plugin = require('../index.js')(shim.app);
162
+ });
163
+
164
+ after(() => cleanup());
165
+
166
+ it('has a non-empty string id', () => {
167
+ assert.strictEqual(typeof plugin.id, 'string');
168
+ assert.ok(plugin.id.length > 0, 'plugin.id must not be empty');
169
+ });
170
+
171
+ it('has a non-empty string name', () => {
172
+ assert.strictEqual(typeof plugin.name, 'string');
173
+ assert.ok(plugin.name.length > 0, 'plugin.name must not be empty');
174
+ });
175
+
176
+ it('has a string description', () => {
177
+ assert.strictEqual(typeof plugin.description, 'string');
178
+ });
179
+
180
+ it('exposes a valid JSON Schema object', () => {
181
+ assert.strictEqual(typeof plugin.schema, 'object', 'plugin.schema must be an object');
182
+ assert.ok(plugin.schema !== null);
183
+ assert.strictEqual(plugin.schema.type, 'object', 'schema.type must be "object"');
184
+ assert.strictEqual(typeof plugin.schema.properties, 'object', 'schema.properties must be an object');
185
+ });
186
+
187
+ it('has start and stop functions', () => {
188
+ assert.strictEqual(typeof plugin.start, 'function', 'plugin.start must be a function');
189
+ assert.strictEqual(typeof plugin.stop, 'function', 'plugin.stop must be a function');
190
+ });
191
+
192
+ it('has a registerWithRouter function', () => {
193
+ assert.strictEqual(typeof plugin.registerWithRouter, 'function', 'plugin.registerWithRouter must be a function');
194
+ });
195
+ });
196
+
197
+ describe('registerWithRouter', () => {
198
+ it('registers the expected routes', () => {
199
+ const { app, cleanup } = createAppShim();
200
+ try {
201
+ const plugin = require('../index.js')(app);
202
+ const registered = [];
203
+ const mockRouter = {
204
+ get: (p) => registered.push(`GET ${p}`),
205
+ put: (p) => registered.push(`PUT ${p}`),
206
+ post: (p) => registered.push(`POST ${p}`),
207
+ };
208
+ plugin.registerWithRouter(mockRouter);
209
+
210
+ assert.ok(registered.includes('GET /api/report'), 'missing GET /api/report');
211
+ assert.ok(registered.includes('GET /api/meta'), 'missing GET /api/meta');
212
+ assert.ok(registered.includes('GET /api/status'), 'missing GET /api/status');
213
+ assert.ok(registered.includes('GET /api/settings'), 'missing GET /api/settings');
214
+ assert.ok(registered.includes('PUT /api/settings'), 'missing PUT /api/settings');
215
+ assert.ok(registered.includes('GET /api/tables'), 'missing GET /api/tables');
216
+ assert.ok(registered.includes('POST /api/tables/create'), 'missing POST /api/tables/create');
217
+ assert.ok(registered.includes('POST /api/tables/load'), 'missing POST /api/tables/load');
218
+ assert.ok(registered.includes('POST /api/tables/copy'), 'missing POST /api/tables/copy');
219
+ assert.ok(registered.includes('POST /api/tables/resize'), 'missing POST /api/tables/resize');
220
+ } finally {
221
+ cleanup();
222
+ }
223
+ });
224
+
225
+ it('GET /api/settings returns options with expected default keys', () => {
226
+ const { app, cleanup } = createAppShim();
227
+ try {
228
+ const plugin = require('../index.js')(app);
229
+ const routes = {};
230
+ const mockRouter = {
231
+ get: (p, h) => { routes[`GET ${p}`] = h; },
232
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
233
+ post: (p, h) => { routes[`POST ${p}`] = h; },
234
+ };
235
+ plugin.registerWithRouter(mockRouter);
236
+
237
+ let response = null;
238
+ routes['GET /api/settings']({}, { json: (d) => { response = d; } });
239
+
240
+ assert.ok(response !== null, 'GET /api/settings returned no response');
241
+ assert.ok('estimateBoatSpeed' in response, 'missing estimateBoatSpeed');
242
+ assert.ok('updateCorrectionTable' in response, 'missing updateCorrectionTable');
243
+ assert.ok('smootherClass' in response, 'missing smootherClass');
244
+ assert.ok('stability' in response, 'missing stability');
245
+ } finally {
246
+ cleanup();
247
+ }
248
+ });
249
+
250
+ it('PUT /api/settings rejects blocked key "tableName" with 400', () => {
251
+ const { app, cleanup } = createAppShim();
252
+ try {
253
+ const plugin = require('../index.js')(app);
254
+ const routes = {};
255
+ const mockRouter = {
256
+ get: (p, h) => { routes[`GET ${p}`] = h; },
257
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
258
+ post: (p, h) => { routes[`POST ${p}`] = h; },
259
+ };
260
+ plugin.registerWithRouter(mockRouter);
261
+
262
+ let statusCode = null;
263
+ let response = null;
264
+ const res = {
265
+ status: (code) => { statusCode = code; return res; },
266
+ json: (d) => { response = d; },
267
+ };
268
+ routes['PUT /api/settings']({ body: { tableName: 'hack' } }, res);
269
+
270
+ assert.strictEqual(statusCode, 400, 'should respond 400 for blocked key "tableName"');
271
+ assert.ok(response && typeof response.error === 'string', 'should return an error message');
272
+ } finally {
273
+ cleanup();
274
+ }
275
+ });
276
+
277
+ it('PUT /api/settings rejects blocked key "correctionTable" with 400', () => {
278
+ const { app, cleanup } = createAppShim();
279
+ try {
280
+ const plugin = require('../index.js')(app);
281
+ const routes = {};
282
+ const mockRouter = {
283
+ get: (p, h) => { routes[`GET ${p}`] = h; },
284
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
285
+ post: (p, h) => { routes[`POST ${p}`] = h; },
286
+ };
287
+ plugin.registerWithRouter(mockRouter);
288
+
289
+ let statusCode = null;
290
+ let response = null;
291
+ const res = {
292
+ status: (code) => { statusCode = code; return res; },
293
+ json: (d) => { response = d; },
294
+ };
295
+ routes['PUT /api/settings']({ body: { correctionTable: {} } }, res);
296
+
297
+ assert.strictEqual(statusCode, 400, 'should respond 400 for blocked key "correctionTable"');
298
+ assert.ok(response && typeof response.error === 'string', 'should return an error message');
299
+ } finally {
300
+ cleanup();
301
+ }
302
+ });
303
+
304
+ it('PUT /api/settings accepts valid settings and reflects them back', () => {
305
+ const { app, cleanup } = createAppShim();
306
+ try {
307
+ const plugin = require('../index.js')(app);
308
+ const routes = {};
309
+ const mockRouter = {
310
+ get: (p, h) => { routes[`GET ${p}`] = h; },
311
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
312
+ post: (p, h) => { routes[`POST ${p}`] = h; },
313
+ };
314
+ plugin.registerWithRouter(mockRouter);
315
+
316
+ let response = null;
317
+ const res = { json: (d) => { response = d; } };
318
+ routes['PUT /api/settings']({ body: { estimateBoatSpeed: true } }, res);
319
+
320
+ assert.ok(response !== null, 'PUT /api/settings returned no response');
321
+ assert.strictEqual(response.estimateBoatSpeed, true, 'response should reflect the updated value');
322
+ } finally {
323
+ cleanup();
324
+ }
325
+ });
326
+
327
+ it('GET /api/status returns isRunning and status fields', () => {
328
+ const { app, cleanup } = createAppShim();
329
+ try {
330
+ const plugin = require('../index.js')(app);
331
+ const routes = {};
332
+ const mockRouter = {
333
+ get: (p, h) => { routes[`GET ${p}`] = h; },
334
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
335
+ post: (p, h) => { routes[`POST ${p}`] = h; },
336
+ };
337
+ plugin.registerWithRouter(mockRouter);
338
+
339
+ let response = null;
340
+ routes['GET /api/status']({}, { json: (d) => { response = d; } });
341
+
342
+ assert.ok(response !== null, 'GET /api/status returned no response');
343
+ assert.ok('isRunning' in response, 'missing isRunning field');
344
+ assert.ok('status' in response, 'missing status field');
345
+ assert.strictEqual(response.isRunning, false, 'plugin should not be running before start()');
346
+ } finally {
347
+ cleanup();
348
+ }
349
+ });
350
+ });
351
+
352
+ describe('plugin lifecycle', () => {
353
+ it('start() completes without throwing', () => {
354
+ const { app, cleanup } = createAppShim();
355
+ let plugin;
356
+ try {
357
+ plugin = require('../index.js')(app);
358
+ assert.doesNotThrow(() => plugin.start(), 'plugin.start() must not throw');
359
+ } finally {
360
+ if (plugin) plugin.stop();
361
+ cleanup();
362
+ }
363
+ });
364
+
365
+ it('stop() resolves cleanly after start()', async () => {
366
+ const { app, cleanup } = createAppShim();
367
+ try {
368
+ const plugin = require('../index.js')(app);
369
+ plugin.start();
370
+ await assert.doesNotReject(
371
+ () => plugin.stop(),
372
+ 'plugin.stop() must resolve without rejection'
373
+ );
374
+ } finally {
375
+ cleanup();
376
+ }
377
+ });
378
+
379
+ it('can be restarted (start → stop → start → stop)', async () => {
380
+ const { app, cleanup } = createAppShim();
381
+ try {
382
+ const plugin = require('../index.js')(app);
383
+ plugin.start();
384
+ await plugin.stop();
385
+ assert.doesNotThrow(() => plugin.start(), 'second start() must not throw');
386
+ await assert.doesNotReject(() => plugin.stop(), 'second stop() must resolve');
387
+ } finally {
388
+ cleanup();
389
+ }
390
+ });
391
+
392
+ it('GET /api/status reports isRunning=true after start()', async () => {
393
+ const { app, cleanup } = createAppShim();
394
+ try {
395
+ const plugin = require('../index.js')(app);
396
+ const routes = {};
397
+ const mockRouter = {
398
+ get: (p, h) => { routes[`GET ${p}`] = h; },
399
+ put: (p, h) => { routes[`PUT ${p}`] = h; },
400
+ post: (p, h) => { routes[`POST ${p}`] = h; },
401
+ };
402
+ plugin.registerWithRouter(mockRouter);
403
+ plugin.start();
404
+
405
+ let response = null;
406
+ routes['GET /api/status']({}, { json: (d) => { response = d; } });
407
+ assert.strictEqual(response.isRunning, true, 'plugin should be running after start()');
408
+
409
+ await plugin.stop();
410
+ } finally {
411
+ cleanup();
412
+ }
413
+ });
414
+ });