@meri-imperiumi/signalk-triplogger 1.3.1 → 1.4.0

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/.eslintrc.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "extends": "airbnb-base",
3
+ "parserOptions": {
4
+ "ecmaVersion": 2022
5
+ },
3
6
  "rules": {
4
7
  "linebreak-style": "warn"
5
8
  }
@@ -5,14 +5,19 @@ on:
5
5
  tags:
6
6
  - "*"
7
7
 
8
+ permissions:
9
+ id-token: write # Required for OIDC
10
+ contents: read
11
+
8
12
  jobs:
9
13
  build:
10
14
  runs-on: ubuntu-latest
11
15
  steps:
12
16
  - uses: actions/checkout@v3.0.2
13
- - uses: actions/setup-node@v3.4.1
17
+ - uses: actions/setup-node@v3.1.1
14
18
  with:
15
- node-version: 12
19
+ node-version: 24
20
+ package-manager-cache: false # never use caching in release builds
16
21
  - run: npm install
17
22
  - run: npm test
18
23
 
@@ -21,10 +26,9 @@ jobs:
21
26
  runs-on: ubuntu-latest
22
27
  steps:
23
28
  - uses: actions/checkout@v3.0.2
24
- - uses: actions/setup-node@v3.4.1
29
+ - uses: actions/setup-node@v3.1.1
25
30
  with:
26
- node-version: 12
31
+ node-version: 24
27
32
  registry-url: https://registry.npmjs.org/
33
+ package-manager-cache: false # never use caching in release builds
28
34
  - run: npm publish
29
- env:
30
- NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
@@ -8,22 +8,14 @@ jobs:
8
8
  runs-on: ubuntu-latest
9
9
  strategy:
10
10
  matrix:
11
- node-version: [12.x]
11
+ node-version: [24.x]
12
12
  steps:
13
13
  - uses: actions/checkout@v3.0.2
14
14
  - name: Use Node.js ${{ matrix.node-version }}
15
- uses: actions/setup-node@v3.4.1
15
+ uses: actions/setup-node@v3.1.1
16
16
  with:
17
17
  node-version: ${{ matrix.node-version }}
18
18
  - run: npm install
19
19
  - run: npm test
20
20
  env:
21
21
  CI: true
22
- merge-me:
23
- name: Auto-merge dependency updates
24
- needs: test
25
- runs-on: ubuntu-latest
26
- steps:
27
- - uses: ridedott/merge-me-action@v2.10.22
28
- with:
29
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/CHANGELOG.md CHANGED
@@ -1,29 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.4.0] - 2026-09-09
6
+
7
+ ### Added
8
+ - Option to publish the current trip log (`log_path`) and the total log
9
+ (`totals_path`) under configurable Signal K paths, for setups where the
10
+ standard `navigation.trip.log`/`navigation.log` paths are owned by another
11
+ provider. Non-standard paths are announced with `units: m` metadata
12
+
13
+ ### Fixed
14
+ - Don't overwrite the current trip log at startup: log preparation is now
15
+ serialized so distance appends and state handling always run against the
16
+ persisted log once it has finished loading from disk
17
+
18
+ ## [1.3.2] - 2026-08-23
19
+
20
+ ### Added
21
+ - Added smoke tests for the plugin entry point (start/stop, subscription,
22
+ state-change resets, position logging, totals)
23
+
3
24
  ## [1.3.1] - 2026-06-16
4
25
 
26
+ ### Added
5
27
  - Added application icon for SK appstore
6
28
 
7
29
  ## [1.3.0] - 2026-06-10
8
30
 
31
+ ### Fixed
9
32
  - Don't start a new trip at startup if there's an ongoing one
10
33
 
11
34
  ## [1.2.0] - 2023-04-27
12
35
 
36
+ ### Added
13
37
  - Added support for populating `navigation.log` for totals
14
38
 
15
39
  ## [1.1.1] - 2023-02-28
16
40
 
41
+ ### Fixed
17
42
  - Fix issue when `navigation.position` doesn't contain coordinates
18
43
 
19
44
  ## [1.1.0] - 2022-03-04
20
45
 
46
+ ### Added
21
47
  - Persist logs for current day/month/year, also by sailing/motoring
22
48
 
23
49
  ## [1.0.1] - 2022-02-18
24
50
 
51
+ ### Fixed
25
52
  - Fix issue with reading lat and lon
26
53
 
27
54
  ## [1.0.0] - 2022-02-18
28
55
 
56
+ ### Added
29
57
  - Initial version, logging in-memory only
package/README.md CHANGED
@@ -6,4 +6,4 @@ The plugin monitors vessel `navigation.state` as set by the [signalk-autostate](
6
6
 
7
7
  ## Changes
8
8
 
9
- See <CHANGELOG.md>
9
+ See [Changelog](CHANGELOG.md)
package/index.js CHANGED
@@ -27,37 +27,56 @@ module.exports = (app) => {
27
27
  return join(app.getDataDirPath(), `${logName}.json`);
28
28
  }
29
29
 
30
+ let logsPrepared = Promise.resolve();
31
+
30
32
  function prepareLogs() {
31
- const newLogs = getLogNames();
32
- return Promise.all(Object.keys(logs).map((logName) => {
33
- // Close old logs
34
- if (newLogs.indexOf(logName) === -1) {
35
- // If log is not in the new log list, end it
36
- const oldLog = logs[logName];
37
- delete logs[logName];
38
- return oldLog.endTrip();
39
- }
40
- return Promise.resolve();
41
- }))
42
- .then(() => Promise.all(newLogs.map((logName) => {
43
- // Load new logs
44
- if (!logs[logName]) {
45
- // New log, or saved log?
46
- logs[logName] = new Logger(getLogPath(logName));
47
- return logs[logName].exists()
48
- .then((exists) => {
49
- if (!exists) {
50
- // New log, no need to load
51
- return Promise.resolve();
52
- }
53
- return logs[logName].load();
54
- });
33
+ // Serialize preparations: a Logger instance is created synchronously,
34
+ // but only loaded from disk asynchronously. Without serialization a
35
+ // second call (for example, a position delta right after a state
36
+ // delta at startup) would see the instance already in `logs` and
37
+ // proceed before `load()` has finished, appending to and saving a
38
+ // fresh zeroed log over the persisted one.
39
+ const preparation = logsPrepared.then(() => {
40
+ const newLogs = getLogNames();
41
+ return Promise.all(Object.keys(logs).map((logName) => {
42
+ // Close old logs
43
+ if (newLogs.indexOf(logName) === -1) {
44
+ // If log is not in the new log list, end it
45
+ const oldLog = logs[logName];
46
+ delete logs[logName];
47
+ return oldLog.endTrip();
55
48
  }
56
49
  return Promise.resolve();
57
- })));
50
+ }))
51
+ .then(() => Promise.all(newLogs.map((logName) => {
52
+ // Load new logs
53
+ if (!logs[logName]) {
54
+ // New log, or saved log?
55
+ const log = new Logger(getLogPath(logName));
56
+ logs[logName] = log;
57
+ return log.exists()
58
+ .then((exists) => {
59
+ if (!exists) {
60
+ // New log, no need to load
61
+ return Promise.resolve();
62
+ }
63
+ return log.load();
64
+ });
65
+ }
66
+ return Promise.resolve();
67
+ })));
68
+ });
69
+ // Keep the chain usable even if a single preparation fails
70
+ logsPrepared = preparation.catch(() => {});
71
+ return preparation;
58
72
  }
59
73
 
60
74
  plugin.start = (options) => {
75
+ // Paths the logs are published under. Configure these when another
76
+ // provider owns the standard paths, for example when a club's total
77
+ // log is maintained elsewhere for their maintenance schedule.
78
+ const tripLogPath = options.log_path || 'navigation.trip.log';
79
+ const totalLogPath = options.totals_path || 'navigation.log';
61
80
  const subscription = {
62
81
  context: 'vessels.self',
63
82
  subscribe: [
@@ -77,7 +96,7 @@ module.exports = (app) => {
77
96
  const resetTime = logs.current.log.started;
78
97
  const values = [
79
98
  {
80
- path: 'navigation.trip.log',
99
+ path: tripLogPath,
81
100
  value: logs.current.log.total,
82
101
  },
83
102
  {
@@ -88,7 +107,7 @@ module.exports = (app) => {
88
107
  if (options.totals) {
89
108
  const base = options.totals_base || 0;
90
109
  values.push({
91
- path: 'navigation.log',
110
+ path: totalLogPath,
92
111
  value: logs.total.log.total + base,
93
112
  });
94
113
  }
@@ -118,14 +137,14 @@ module.exports = (app) => {
118
137
  .then(() => {
119
138
  const values = [
120
139
  {
121
- path: 'navigation.trip.log',
140
+ path: tripLogPath,
122
141
  value: logs.current.log.total,
123
142
  },
124
143
  ];
125
144
  if (options.totals) {
126
145
  const base = options.totals_base || 0;
127
146
  values.push({
128
- path: 'navigation.log',
147
+ path: totalLogPath,
129
148
  value: logs.total.log.total + base,
130
149
  });
131
150
  }
@@ -141,6 +160,9 @@ module.exports = (app) => {
141
160
  },
142
161
  ],
143
162
  });
163
+ })
164
+ .catch((err) => {
165
+ app.error(`Error:${err}`);
144
166
  });
145
167
  }
146
168
 
@@ -158,9 +180,53 @@ module.exports = (app) => {
158
180
  resetTrip();
159
181
  setStatus('New trip has started. Log reset');
160
182
  }
183
+ })
184
+ .catch((err) => {
185
+ app.error(`Error:${err}`);
161
186
  });
162
187
  }
163
188
 
189
+ function sendMeta() {
190
+ // Non-standard paths are not in the Signal K schema, so consumers
191
+ // cannot know their units. Tell the server they are meters.
192
+ const meta = [];
193
+ if (tripLogPath !== 'navigation.trip.log') {
194
+ meta.push({
195
+ path: tripLogPath,
196
+ value: {
197
+ units: 'm',
198
+ displayName: 'Current trip log',
199
+ description: 'Distance travelled during the current trip',
200
+ },
201
+ });
202
+ }
203
+ if (options.totals && totalLogPath !== 'navigation.log') {
204
+ meta.push({
205
+ path: totalLogPath,
206
+ value: {
207
+ units: 'm',
208
+ displayName: 'Total log',
209
+ description: 'Total distance travelled',
210
+ },
211
+ });
212
+ }
213
+ if (meta.length === 0) {
214
+ return;
215
+ }
216
+ app.handleMessage(plugin.id, {
217
+ context: `vessels.${app.selfId}`,
218
+ updates: [
219
+ {
220
+ source: {
221
+ label: plugin.id,
222
+ },
223
+ timestamp: (new Date().toISOString()),
224
+ meta,
225
+ },
226
+ ],
227
+ });
228
+ }
229
+
164
230
  app.subscriptionmanager.subscribe(
165
231
  subscription,
166
232
  unsubscribes,
@@ -204,6 +270,15 @@ module.exports = (app) => {
204
270
  },
205
271
  );
206
272
 
273
+ // Load persisted logs eagerly so that a trip ongoing from a previous
274
+ // session is continued when deltas arrive, instead of being reset
275
+ prepareLogs().catch((err) => {
276
+ app.error(`Error:${err}`);
277
+ });
278
+
279
+ // Declare units for any non-standard paths we publish to
280
+ sendMeta();
281
+
207
282
  setStatus('Waiting for updates');
208
283
  };
209
284
 
@@ -220,10 +295,20 @@ module.exports = (app) => {
220
295
  default: 10000,
221
296
  title: 'How often to update log, in milliseconds',
222
297
  },
298
+ log_path: {
299
+ type: 'string',
300
+ default: 'navigation.trip.log',
301
+ title: 'Signal K path to publish the current trip log to',
302
+ },
223
303
  totals: {
224
304
  type: 'boolean',
225
305
  default: true,
226
- title: 'Publish a total number in navigation.log',
306
+ title: 'Publish a total number in the total log path',
307
+ },
308
+ totals_path: {
309
+ type: 'string',
310
+ default: 'navigation.log',
311
+ title: 'Signal K path to publish the total log to',
227
312
  },
228
313
  totals_base: {
229
314
  type: 'number',
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-triplogger",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Log the length of the current trip",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "lint": "eslint .",
8
- "test": "npm run lint"
7
+ "test": "node --test --test-force-exit \"tests/*.test.js\" && eslint .",
8
+ "lint": "eslint ."
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared test fakes: a mock Signal K app matching the patterns used by the
3
+ * signalk-aprsfi-ais-reporter test suite, adapted for the triplogger's
4
+ * needs (subscriptionmanager + handleMessage + setPluginStatus + data dir).
5
+ *
6
+ * @file fake-app.js
7
+ */
8
+
9
+ /* eslint-disable class-methods-use-this */
10
+
11
+ const { join } = require('node:path');
12
+ const { mkdtempSync } = require('node:fs');
13
+ const { tmpdir } = require('node:os');
14
+
15
+ /**
16
+ * Minimal Signal K app fake with subscriptionmanager + handleMessage +
17
+ * setPluginStatus + setProviderStatus + getDataDirPath + error.
18
+ *
19
+ * The data path is a fresh temp directory per instance so the Logger can
20
+ * write log files without touching the project tree.
21
+ */
22
+ class FakeSignalKApp {
23
+ constructor() {
24
+ this.selfId = 'urn:mrn:imo:mmsi:230999999';
25
+ this.subscriptionmanager = {
26
+ subscriptions: [],
27
+ subscribe(subscription, unsubscribes, onError, onDelta) {
28
+ this.subscriptions.push({ subscription, onDelta });
29
+ unsubscribes.push(() => {
30
+ const idx = this.subscriptions.findIndex(
31
+ (s) => s.subscription === subscription,
32
+ );
33
+ if (idx >= 0) this.subscriptions.splice(idx, 1);
34
+ });
35
+ },
36
+ };
37
+ this.statusMessages = [];
38
+ this.errors = [];
39
+ this.messages = [];
40
+ this.dataPath = mkdtempSync(join(tmpdir(), 'triplogger-test-'));
41
+ }
42
+
43
+ getDataDirPath() {
44
+ return this.dataPath;
45
+ }
46
+
47
+ setPluginStatus = (msg) => {
48
+ this.statusMessages.push({ type: 'status', msg });
49
+ };
50
+
51
+ setProviderStatus = (msg) => {
52
+ this.statusMessages.push({ type: 'status', msg });
53
+ };
54
+
55
+ handleMessage = (sourceId, message) => {
56
+ this.messages.push({ sourceId, message });
57
+ };
58
+
59
+ debug() {}
60
+
61
+ error(msg) {
62
+ this.errors.push(msg);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Emits a delta into the subscriptionmanager's registered handlers, for
68
+ * tests that want to feed navigation values.
69
+ *
70
+ * @param {FakeSignalKApp} app
71
+ * @param {object} delta
72
+ * @returns {void}
73
+ */
74
+ function emitDelta(app, delta) {
75
+ app.subscriptionmanager.subscriptions.forEach(({ onDelta }) => onDelta(delta));
76
+ }
77
+
78
+ module.exports = { FakeSignalKApp, emitDelta };
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Smoke tests for the plugin entry point (plugin shape, start/stop,
3
+ * subscription, state-change resets, position logging, totals).
4
+ * @file plugin.test.js
5
+ */
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const { readFileSync, writeFileSync } = require('node:fs');
10
+ const { join } = require('node:path');
11
+
12
+ const makePlugin = require('../index');
13
+ const { FakeSignalKApp, emitDelta } = require('./fake-app');
14
+
15
+ /**
16
+ * Poll until `predicate` returns truthy, retrying every ~10ms up to `timeout`
17
+ * milliseconds. Resolves true on success, false on timeout. Useful for
18
+ * awaiting async plugin side-effects (file I/O) without fixed sleeps.
19
+ *
20
+ * @param {() => boolean} predicate
21
+ * @param {number} [timeout]
22
+ * @returns {Promise<boolean>}
23
+ */
24
+ function waitFor(predicate, timeout = 500) {
25
+ return new Promise((resolve) => {
26
+ const start = Date.now();
27
+ const tick = () => {
28
+ if (predicate()) {
29
+ resolve(true);
30
+ return;
31
+ }
32
+ if (Date.now() - start >= timeout) {
33
+ resolve(false);
34
+ return;
35
+ }
36
+ setTimeout(tick, 10);
37
+ };
38
+ tick();
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Build a started plugin instance wired to a fresh fake app with the
44
+ * given settings (defaulted).
45
+ *
46
+ * @param {object} [overrides]
47
+ * @returns {{app: FakeSignalKApp, plugin: object}}
48
+ */
49
+ function makeStarted(overrides) {
50
+ const app = new FakeSignalKApp();
51
+ const plugin = makePlugin(app);
52
+ plugin.start({
53
+ update_interval: 10000,
54
+ totals: true,
55
+ totals_base: 0,
56
+ ...overrides,
57
+ });
58
+ return { app, plugin };
59
+ }
60
+
61
+ /**
62
+ * Read a log file written by the Logger from the app's data dir.
63
+ *
64
+ * @param {FakeSignalKApp} app
65
+ * @param {string} name
66
+ * @returns {object}
67
+ */
68
+ function readLog(app, name) {
69
+ return JSON.parse(readFileSync(join(app.dataPath, `${name}.json`), 'utf-8'));
70
+ }
71
+
72
+ /**
73
+ * Pre-seed a log file in the app's data dir before start, so the Logger
74
+ * loads an existing state instead of starting fresh. Used to make the
75
+ * "new trip" transition deterministic without racing two state emits.
76
+ *
77
+ * @param {FakeSignalKApp} app
78
+ * @param {string} name
79
+ * @param {object} contents
80
+ * @returns {void}
81
+ */
82
+ function seedLog(app, name, contents) {
83
+ writeFileSync(join(app.dataPath, `${name}.json`), JSON.stringify(contents, null, 2));
84
+ }
85
+
86
+ test('creates a plugin object with the right id/name', () => {
87
+ const app = new FakeSignalKApp();
88
+ const plugin = makePlugin(app);
89
+ assert.strictEqual(plugin.id, 'signalk-triplogger');
90
+ assert.strictEqual(plugin.name, 'Trip logger');
91
+ assert.ok(plugin.description);
92
+ });
93
+
94
+ test('schema is a JSON object with the expected options', () => {
95
+ const app = new FakeSignalKApp();
96
+ const plugin = makePlugin(app);
97
+ assert.strictEqual(plugin.schema.type, 'object');
98
+ assert.ok(plugin.schema.properties.update_interval);
99
+ assert.ok(plugin.schema.properties.totals);
100
+ assert.ok(plugin.schema.properties.totals_base);
101
+ });
102
+
103
+ test('start subscribes to navigation.state and navigation.position', () => {
104
+ const { app, plugin } = makeStarted();
105
+ assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);
106
+ const sub = app.subscriptionmanager.subscriptions[0].subscription;
107
+ assert.strictEqual(sub.context, 'vessels.self');
108
+ const paths = sub.subscribe.map((s) => s.path);
109
+ assert.ok(paths.includes('navigation.state'));
110
+ assert.ok(paths.includes('navigation.position'));
111
+ plugin.stop();
112
+ });
113
+
114
+ test('start sets an initial status message', () => {
115
+ const { app, plugin } = makeStarted();
116
+ assert.ok(
117
+ app.statusMessages.some(
118
+ (m) => m.type === 'status' && /waiting/i.test(m.msg),
119
+ ),
120
+ `got ${JSON.stringify(app.statusMessages)}`,
121
+ );
122
+ plugin.stop();
123
+ });
124
+
125
+ test('transitioning into a trip resets the log and posts a status', async () => {
126
+ const app = new FakeSignalKApp();
127
+ // A fresh current log is considered already in-trip. To make the
128
+ // transition into a trip deterministic (instead of racing two async
129
+ // state emits), seed an *ended* current log so inTrip() starts false,
130
+ // then emit a single trip-starting state.
131
+ seedLog(app, 'current', {
132
+ started: '2026-01-01T00:00:00.000Z',
133
+ ended: '2026-01-01T01:00:00.000Z',
134
+ total: 0,
135
+ states: {},
136
+ });
137
+ const plugin = makePlugin(app);
138
+ plugin.start({});
139
+ emitDelta(app, {
140
+ context: 'vessels.self',
141
+ updates: [
142
+ { values: [{ path: 'navigation.state', value: 'sailing' }] },
143
+ ],
144
+ });
145
+ await waitFor(
146
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
147
+ );
148
+ plugin.stop();
149
+ assert.ok(
150
+ app.statusMessages.some(
151
+ (m) => m.type === 'status' && /new trip/i.test(m.msg),
152
+ ),
153
+ `got ${JSON.stringify(app.statusMessages)}`,
154
+ );
155
+ });
156
+
157
+ test('position updates append distance to the trip log and emit deltas', async () => {
158
+ const app = new FakeSignalKApp();
159
+ // Seed an ended current log so start isn't considered in-trip, then emit
160
+ // a single trip-starting state. This avoids racing two async state emits.
161
+ seedLog(app, 'current', {
162
+ started: '2026-01-01T00:00:00.000Z',
163
+ ended: '2026-01-01T01:00:00.000Z',
164
+ total: 0,
165
+ states: {},
166
+ });
167
+ const plugin = makePlugin(app);
168
+ plugin.start({ totals: true });
169
+ emitDelta(app, {
170
+ context: 'vessels.self',
171
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
172
+ });
173
+ // Wait for the new-trip reset to settle before sending positions.
174
+ await waitFor(
175
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
176
+ );
177
+
178
+ // First position just establishes lastPosition; no distance yet.
179
+ emitDelta(app, {
180
+ context: 'vessels.self',
181
+ updates: [
182
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
183
+ ],
184
+ });
185
+
186
+ // Second position ~111m east of the first → appendTrip writes log files.
187
+ emitDelta(app, {
188
+ context: 'vessels.self',
189
+ updates: [
190
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
191
+ ],
192
+ });
193
+ await waitFor(() => {
194
+ const tripValue = app.messages
195
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
196
+ .find((v) => v.path === 'navigation.trip.log' && v.value > 0);
197
+ return !!tripValue;
198
+ });
199
+
200
+ plugin.stop();
201
+
202
+ // A trip.log delta should have been published to the app.
203
+ const tripMsgs = app.messages.filter(
204
+ ({ message }) => message.updates.some((u) => u.values.some((v) => v.path === 'navigation.trip.log')),
205
+ );
206
+ assert.ok(tripMsgs.length > 0, 'no navigation.trip.log delta was emitted');
207
+
208
+ // The current trip log file should be persisted with a positive total.
209
+ const current = readLog(app, 'current');
210
+ assert.ok(current.total > 0, `current.total was ${current.total}`);
211
+ });
212
+
213
+ test('totals option also publishes navigation.log', async () => {
214
+ const app = new FakeSignalKApp();
215
+ seedLog(app, 'current', {
216
+ started: '2026-01-01T00:00:00.000Z',
217
+ ended: '2026-01-01T01:00:00.000Z',
218
+ total: 0,
219
+ states: {},
220
+ });
221
+ const plugin = makePlugin(app);
222
+ plugin.start({ totals: true, totals_base: 100 });
223
+ emitDelta(app, {
224
+ context: 'vessels.self',
225
+ updates: [{ values: [{ path: 'navigation.state', value: 'motoring' }] }],
226
+ });
227
+ await waitFor(
228
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
229
+ );
230
+
231
+ emitDelta(app, {
232
+ context: 'vessels.self',
233
+ updates: [
234
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
235
+ ],
236
+ });
237
+ emitDelta(app, {
238
+ context: 'vessels.self',
239
+ updates: [
240
+ { values: [{ path: 'navigation.position', value: { latitude: 60.002, longitude: 24.0 } }] },
241
+ ],
242
+ });
243
+ await waitFor(() => {
244
+ const logValue = app.messages
245
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
246
+ .find((v) => v.path === 'navigation.log' && v.value > 100);
247
+ return !!logValue;
248
+ });
249
+ plugin.stop();
250
+
251
+ const logMsgs = app.messages.filter(
252
+ ({ message }) => message.updates.some((u) => u.values.some((v) => v.path === 'navigation.log')),
253
+ );
254
+ assert.ok(logMsgs.length > 0, 'no navigation.log delta was emitted');
255
+ const last = logMsgs[logMsgs.length - 1];
256
+ const { value } = last.message.updates
257
+ .flatMap((u) => u.values)
258
+ .find((v) => v.path === 'navigation.log');
259
+ // totals_base of 100 should be added on top of the logged distance.
260
+ assert.ok(value > 100, `navigation.log value was ${value}`);
261
+ });
262
+
263
+ test('startup with an ongoing trip continues it instead of resetting to zero', async () => {
264
+ const app = new FakeSignalKApp();
265
+ // An ongoing trip from a previous session: started, never ended.
266
+ seedLog(app, 'current', {
267
+ started: '2026-01-01T00:00:00.000Z',
268
+ ended: null,
269
+ total: 5000,
270
+ states: { sailing: 5000 },
271
+ });
272
+ const plugin = makePlugin(app);
273
+ plugin.start({ totals: false });
274
+
275
+ // Emit state and positions back-to-back, in the same tick, so they
276
+ // race against the async log loading from disk. This used to overwrite
277
+ // current.json with a fresh zeroed log before the saved one was loaded.
278
+ emitDelta(app, {
279
+ context: 'vessels.self',
280
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
281
+ });
282
+ emitDelta(app, {
283
+ context: 'vessels.self',
284
+ updates: [
285
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
286
+ ],
287
+ });
288
+ emitDelta(app, {
289
+ context: 'vessels.self',
290
+ updates: [
291
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
292
+ ],
293
+ });
294
+ await waitFor(() => app.messages
295
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
296
+ .some((v) => v.path === 'navigation.trip.log'));
297
+ plugin.stop();
298
+
299
+ // The persisted trip must survive startup: no reset to zero, and the
300
+ // newly logged distance accumulates on top of the existing total.
301
+ const current = readLog(app, 'current');
302
+ assert.ok(
303
+ current.total >= 5000,
304
+ `current.total was ${current.total}, expected the ongoing trip to continue`,
305
+ );
306
+ assert.ok(
307
+ !app.statusMessages.some((m) => /new trip/i.test(m.msg)),
308
+ `unexpected new-trip reset at startup: ${JSON.stringify(app.statusMessages)}`,
309
+ );
310
+
311
+ // The published trip log should also continue from the persisted total.
312
+ const tripValues = app.messages
313
+ .flatMap(({ message }) => message.updates.flatMap((u) => u.values))
314
+ .filter((v) => v.path === 'navigation.trip.log');
315
+ assert.ok(tripValues.length > 0, 'no navigation.trip.log delta was emitted');
316
+ assert.ok(
317
+ tripValues[tripValues.length - 1].value >= 5000,
318
+ `last trip.log value was ${tripValues[tripValues.length - 1].value}`,
319
+ );
320
+ });
321
+
322
+ /**
323
+ * Flatten all value objects the app has received from the plugin.
324
+ * Meta-only messages have no `values`, so those updates are skipped.
325
+ *
326
+ * @param {FakeSignalKApp} app
327
+ * @returns {Array<{path: string, value: number}>}
328
+ */
329
+ function emittedValues(app) {
330
+ return app.messages.flatMap(
331
+ ({ message }) => message.updates.filter((u) => u.values).flatMap((u) => u.values),
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Flatten all meta entries the app has received from the plugin.
337
+ *
338
+ * @param {FakeSignalKApp} app
339
+ * @returns {Array<{path: string, value: object}>}
340
+ */
341
+ function emittedMeta(app) {
342
+ return app.messages.flatMap(
343
+ ({ message }) => message.updates.filter((u) => u.meta).flatMap((u) => u.meta),
344
+ );
345
+ }
346
+
347
+ test('custom log_path publishes the trip log under the configured path', async () => {
348
+ const app = new FakeSignalKApp();
349
+ seedLog(app, 'current', {
350
+ started: '2026-01-01T00:00:00.000Z',
351
+ ended: '2026-01-01T01:00:00.000Z',
352
+ total: 0,
353
+ states: {},
354
+ });
355
+ const plugin = makePlugin(app);
356
+ plugin.start({
357
+ log_path: 'navigation.trip.currentTripLog',
358
+ totals: false,
359
+ });
360
+ emitDelta(app, {
361
+ context: 'vessels.self',
362
+ updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
363
+ });
364
+ await waitFor(
365
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
366
+ );
367
+ emitDelta(app, {
368
+ context: 'vessels.self',
369
+ updates: [
370
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
371
+ ],
372
+ });
373
+ emitDelta(app, {
374
+ context: 'vessels.self',
375
+ updates: [
376
+ { values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
377
+ ],
378
+ });
379
+ await waitFor(
380
+ () => emittedValues(app).some(
381
+ (v) => v.path === 'navigation.trip.currentTripLog' && v.value > 0,
382
+ ),
383
+ );
384
+ plugin.stop();
385
+
386
+ // The trip log is published under the configured custom path...
387
+ assert.ok(
388
+ emittedValues(app).some((v) => v.path === 'navigation.trip.currentTripLog'),
389
+ 'no delta was emitted to navigation.trip.currentTripLog',
390
+ );
391
+ // ...and not under the standard path
392
+ assert.ok(
393
+ !emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
394
+ 'navigation.trip.log should not be written when log_path is set',
395
+ );
396
+ // The non-standard path is announced with units metadata
397
+ const tripMeta = emittedMeta(app).find((m) => m.path === 'navigation.trip.currentTripLog');
398
+ assert.ok(tripMeta, 'no meta was sent for the custom trip log path');
399
+ assert.strictEqual(tripMeta.value.units, 'm');
400
+ });
401
+
402
+ test('custom totals_path publishes the total under the configured path', async () => {
403
+ const app = new FakeSignalKApp();
404
+ seedLog(app, 'current', {
405
+ started: '2026-01-01T00:00:00.000Z',
406
+ ended: '2026-01-01T01:00:00.000Z',
407
+ total: 0,
408
+ states: {},
409
+ });
410
+ const plugin = makePlugin(app);
411
+ plugin.start({
412
+ totals: true,
413
+ totals_base: 100,
414
+ totals_path: 'navigation.log.clubTotal',
415
+ });
416
+ emitDelta(app, {
417
+ context: 'vessels.self',
418
+ updates: [{ values: [{ path: 'navigation.state', value: 'motoring' }] }],
419
+ });
420
+ await waitFor(
421
+ () => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
422
+ );
423
+ emitDelta(app, {
424
+ context: 'vessels.self',
425
+ updates: [
426
+ { values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
427
+ ],
428
+ });
429
+ emitDelta(app, {
430
+ context: 'vessels.self',
431
+ updates: [
432
+ { values: [{ path: 'navigation.position', value: { latitude: 60.002, longitude: 24.0 } }] },
433
+ ],
434
+ });
435
+ await waitFor(
436
+ () => emittedValues(app).some(
437
+ (v) => v.path === 'navigation.log.clubTotal' && v.value > 100,
438
+ ),
439
+ );
440
+ plugin.stop();
441
+
442
+ // The total is published under the configured custom path...
443
+ assert.ok(
444
+ emittedValues(app).some((v) => v.path === 'navigation.log.clubTotal'),
445
+ 'no delta was emitted to navigation.log.clubTotal',
446
+ );
447
+ // ...and not under the standard path
448
+ assert.ok(
449
+ !emittedValues(app).some((v) => v.path === 'navigation.log'),
450
+ 'navigation.log should not be written when totals_path is set',
451
+ );
452
+ // The trip log keeps its standard path unless configured otherwise
453
+ assert.ok(
454
+ emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
455
+ 'navigation.trip.log should still be emitted with only totals_path set',
456
+ );
457
+ // The non-standard path is announced with units metadata
458
+ const totalMeta = emittedMeta(app).find((m) => m.path === 'navigation.log.clubTotal');
459
+ assert.ok(totalMeta, 'no meta was sent for the custom totals path');
460
+ assert.strictEqual(totalMeta.value.units, 'm');
461
+ // No meta is needed for the standard trip path
462
+ assert.ok(
463
+ !emittedMeta(app).some((m) => m.path === 'navigation.trip.log'),
464
+ 'standard paths should not get meta',
465
+ );
466
+ });
467
+
468
+ test('standard paths send no meta messages', () => {
469
+ const { app, plugin } = makeStarted();
470
+ assert.strictEqual(emittedMeta(app).length, 0);
471
+ plugin.stop();
472
+ });
473
+
474
+ test('stop clears subscriptions', () => {
475
+ const { app, plugin } = makeStarted();
476
+ assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);
477
+ plugin.stop();
478
+ assert.strictEqual(app.subscriptionmanager.subscriptions.length, 0);
479
+ });