@meri-imperiumi/signalk-passage-briefing 0.2.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/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
const { test, describe } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
} = require("node:fs");
|
|
10
|
+
const { tmpdir } = require("node:os");
|
|
11
|
+
const { join } = require("node:path");
|
|
12
|
+
|
|
13
|
+
const pluginFactory = require("../plugin/index.js");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mock Signal K app following the signalk-internet test pattern.
|
|
17
|
+
*/
|
|
18
|
+
function createMockApp() {
|
|
19
|
+
let status = "";
|
|
20
|
+
const errors = [];
|
|
21
|
+
const subscriptions = [];
|
|
22
|
+
const deltaHandlers = [];
|
|
23
|
+
const putHandlers = {};
|
|
24
|
+
const publishedDeltas = [];
|
|
25
|
+
const resourceProviders = [];
|
|
26
|
+
const mounts = [];
|
|
27
|
+
const routes = [];
|
|
28
|
+
const dataDir = mkdtempSync(join(tmpdir(), "passage-plugin-"));
|
|
29
|
+
return {
|
|
30
|
+
selfId: "urn:mrn:imo:mmsi:123456789",
|
|
31
|
+
debug: () => {},
|
|
32
|
+
error: (message) => errors.push(message),
|
|
33
|
+
setPluginStatus: (s) => {
|
|
34
|
+
status = s;
|
|
35
|
+
},
|
|
36
|
+
getPluginStatus: () => status,
|
|
37
|
+
getDataDirPath: () => dataDir,
|
|
38
|
+
subscriptionmanager: {
|
|
39
|
+
subscribe: (subscription, _unsub, _onError, onDelta) => {
|
|
40
|
+
subscriptions.push(subscription);
|
|
41
|
+
deltaHandlers.push(onDelta);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
handleMessage: (_source, delta) => publishedDeltas.push(delta),
|
|
45
|
+
registerResourceProvider: (provider) => resourceProviders.push(provider),
|
|
46
|
+
registerPutHandler: (context, path, handler) => {
|
|
47
|
+
putHandlers[path] = { context, handler };
|
|
48
|
+
},
|
|
49
|
+
use: (prefix, handler) => mounts.push({ prefix, handler }),
|
|
50
|
+
router: {
|
|
51
|
+
get(path, handler) {
|
|
52
|
+
routes.push({ method: "get", path, handler });
|
|
53
|
+
},
|
|
54
|
+
post(path, handler) {
|
|
55
|
+
routes.push({ method: "post", path, handler });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
dataDir,
|
|
59
|
+
getStatus: () => status,
|
|
60
|
+
getErrors: () => errors,
|
|
61
|
+
getSubscriptions: () => subscriptions,
|
|
62
|
+
getDeltaHandlers: () => deltaHandlers,
|
|
63
|
+
getRoutes: () => routes,
|
|
64
|
+
getPutHandlers: () => putHandlers,
|
|
65
|
+
getPublishedDeltas: () => publishedDeltas,
|
|
66
|
+
getResourceProviders: () => resourceProviders,
|
|
67
|
+
getMounts: () => mounts,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("plugin", () => {
|
|
72
|
+
test("has correct metadata and the spec configuration schema", () => {
|
|
73
|
+
const app = createMockApp();
|
|
74
|
+
const plugin = pluginFactory(app);
|
|
75
|
+
assert.equal(plugin.id, "signalk-passage-briefing");
|
|
76
|
+
assert.ok(plugin.name);
|
|
77
|
+
assert.ok(plugin.description);
|
|
78
|
+
|
|
79
|
+
const properties = plugin.schema.properties;
|
|
80
|
+
assert.equal(properties.motoring_tws_threshold.default, 3.5);
|
|
81
|
+
assert.equal(properties.drift_mode_enabled.default, true);
|
|
82
|
+
assert.equal(properties.waterline_length_m.default, 9.4);
|
|
83
|
+
assert.ok(properties.spool_directory);
|
|
84
|
+
assert.equal(properties.k_heel.default, 0.35);
|
|
85
|
+
assert.equal(properties.k_pitch.default, 0.4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("starts, creates the sqlite store, and stops cleanly", () => {
|
|
89
|
+
const app = createMockApp();
|
|
90
|
+
const plugin = pluginFactory(app);
|
|
91
|
+
plugin.start({});
|
|
92
|
+
assert.ok(existsSync(join(app.dataDir, "passage-outlook.sqlite")));
|
|
93
|
+
plugin.stop();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("subscribes to the state machine and active-route paths", () => {
|
|
97
|
+
const app = createMockApp();
|
|
98
|
+
const plugin = pluginFactory(app);
|
|
99
|
+
plugin.start({});
|
|
100
|
+
plugin.stop();
|
|
101
|
+
|
|
102
|
+
const [subscription] = app.getSubscriptions();
|
|
103
|
+
assert.equal(subscription.context, "vessels.self");
|
|
104
|
+
const paths = subscription.subscribe.map((s) => s.path);
|
|
105
|
+
assert.deepEqual(paths, [
|
|
106
|
+
"network.internet.state",
|
|
107
|
+
"navigation.state",
|
|
108
|
+
"electrical.batteries.house.capacity.stateOfCharge",
|
|
109
|
+
"navigation.course.activeRoute",
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Waits until the predicate holds (refreshes span several event
|
|
114
|
+
* loop turns once the physics module and synoptic fetches join the
|
|
115
|
+
* here path). */
|
|
116
|
+
async function waitFor(predicate, ms = 5000) {
|
|
117
|
+
const deadline = Date.now() + ms;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
if (predicate()) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
123
|
+
}
|
|
124
|
+
return predicate();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("internet transition with no route briefs conditions here", async () => {
|
|
128
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
129
|
+
const app = createMockApp();
|
|
130
|
+
app.getSelfPath = (path) =>
|
|
131
|
+
path === "navigation.position"
|
|
132
|
+
? { latitude: -21.1, longitude: -175.2 }
|
|
133
|
+
: null;
|
|
134
|
+
const plugin = pluginFactory(app);
|
|
135
|
+
plugin.start({});
|
|
136
|
+
|
|
137
|
+
const feed = app.getDeltaHandlers()[0];
|
|
138
|
+
feed({
|
|
139
|
+
updates: [
|
|
140
|
+
{
|
|
141
|
+
values: [
|
|
142
|
+
{ path: "network.internet.state", value: "online" },
|
|
143
|
+
{ path: "navigation.state", value: "moored" },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
const originalFetch = globalThis.fetch;
|
|
149
|
+
globalThis.fetch = mockOpenMeteo();
|
|
150
|
+
app.debug = (m) => console.error("DBG:", m);
|
|
151
|
+
app.error = (m) => console.error("PLUGIN-ERR:", m);
|
|
152
|
+
try {
|
|
153
|
+
await waitFor(() => /Conditions here cached at/.test(app.getStatus()));
|
|
154
|
+
assert.match(app.getStatus(), /Conditions here cached at/);
|
|
155
|
+
assert.ok(
|
|
156
|
+
existsSync(join(app.dataDir, "weather", "here.json")),
|
|
157
|
+
"here payload cached",
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Stable repeat: re-briefs rather than dead-ending.
|
|
161
|
+
feed({
|
|
162
|
+
updates: [
|
|
163
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
await waitFor(() => /Conditions here cached at/.test(app.getStatus()));
|
|
167
|
+
assert.match(app.getStatus(), /Conditions here cached at/);
|
|
168
|
+
} finally {
|
|
169
|
+
globalThis.fetch = originalFetch;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// No position: the here refresh degrades to a status note
|
|
173
|
+
const app2 = createMockApp();
|
|
174
|
+
const plugin2 = pluginFactory(app2);
|
|
175
|
+
plugin2.start({});
|
|
176
|
+
app2.getDeltaHandlers()[0]({
|
|
177
|
+
updates: [
|
|
178
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
182
|
+
// Failures land in the plugin error state, not the status line
|
|
183
|
+
assert.match(app2.getErrors().join("\n"), /Here refresh failed/);
|
|
184
|
+
plugin2.stop();
|
|
185
|
+
|
|
186
|
+
plugin.stop();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("here mode: served from cache with mode marker and refreshed online", async () => {
|
|
190
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
191
|
+
const app = createMockApp();
|
|
192
|
+
app.getSelfPath = (path) =>
|
|
193
|
+
path === "navigation.position"
|
|
194
|
+
? { latitude: -21.1, longitude: -175.2 }
|
|
195
|
+
: null;
|
|
196
|
+
const plugin = pluginFactory(app);
|
|
197
|
+
plugin.start({});
|
|
198
|
+
plugin.registerWithRouter(app.router);
|
|
199
|
+
const feed = app.getDeltaHandlers()[0];
|
|
200
|
+
const call = async (path, req = { query: {} }) => {
|
|
201
|
+
const route = app.getRoutes().find((r) => r.path === path);
|
|
202
|
+
const res = {
|
|
203
|
+
code: null,
|
|
204
|
+
payload: null,
|
|
205
|
+
status(code) {
|
|
206
|
+
this.code = code;
|
|
207
|
+
return this;
|
|
208
|
+
},
|
|
209
|
+
json(payload) {
|
|
210
|
+
this.payload = payload;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
await route.handler(req, res);
|
|
214
|
+
return res;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
feed({
|
|
218
|
+
updates: [
|
|
219
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Nothing cached yet: empty payload, not an error
|
|
224
|
+
let res = await call("/api/briefing");
|
|
225
|
+
assert.equal(res.code, null);
|
|
226
|
+
assert.equal(res.payload.mode, "here");
|
|
227
|
+
assert.equal(res.payload.payload, null);
|
|
228
|
+
|
|
229
|
+
const originalFetch = globalThis.fetch;
|
|
230
|
+
let forecastCalls = 0;
|
|
231
|
+
globalThis.fetch = async (url, opts) => {
|
|
232
|
+
if (String(url).includes("/v1/forecast")) {
|
|
233
|
+
forecastCalls += 1;
|
|
234
|
+
}
|
|
235
|
+
return mockOpenMeteo()(url, opts);
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
// Refresh without a route targets here mode
|
|
239
|
+
res = await call("/api/briefing/refresh");
|
|
240
|
+
assert.equal(res.code, null);
|
|
241
|
+
assert.equal(res.payload.mode, "here");
|
|
242
|
+
assert.ok(res.payload.cachedAt);
|
|
243
|
+
assert.ok(forecastCalls >= 1, "forecast fetched for here mode");
|
|
244
|
+
|
|
245
|
+
// Served with the here marker, one waypoint at the vessel
|
|
246
|
+
res = await call("/api/briefing");
|
|
247
|
+
assert.equal(res.code, null);
|
|
248
|
+
assert.equal(res.payload.mode, "here");
|
|
249
|
+
assert.equal(res.payload.stale, false);
|
|
250
|
+
assert.equal(res.payload.payload.waypoints.length, 1);
|
|
251
|
+
assert.equal(res.payload.payload.waypoints[0].lat, -21.1);
|
|
252
|
+
assert.equal(res.payload.payload.waypoints[0].lon, -175.2);
|
|
253
|
+
assert.equal(res.payload.payload.metadata.mode, "here");
|
|
254
|
+
|
|
255
|
+
// Offline: the cache still serves
|
|
256
|
+
feed({
|
|
257
|
+
updates: [
|
|
258
|
+
{ values: [{ path: "network.internet.state", value: "offline" }] },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
res = await call("/api/briefing");
|
|
262
|
+
assert.equal(res.code, null);
|
|
263
|
+
assert.equal(res.payload.online, false);
|
|
264
|
+
assert.equal(res.payload.mode, "here");
|
|
265
|
+
feed({
|
|
266
|
+
updates: [
|
|
267
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Route briefing still wins when requested explicitly
|
|
272
|
+
app.resourcesApi = {
|
|
273
|
+
async getResource(resType, resId) {
|
|
274
|
+
if (resType === "routes" && resId === "r1") {
|
|
275
|
+
return {
|
|
276
|
+
name: "Test",
|
|
277
|
+
feature: {
|
|
278
|
+
geometry: {
|
|
279
|
+
coordinates: [
|
|
280
|
+
[0, 0],
|
|
281
|
+
[0, 1.5],
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
throw new Error("not found");
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
res = await call("/api/briefing/refresh", { query: { route: "r1" } });
|
|
291
|
+
assert.equal(res.code, null);
|
|
292
|
+
res = await call("/api/briefing", { query: { route: "r1" } });
|
|
293
|
+
assert.equal(res.payload.mode, "route");
|
|
294
|
+
assert.equal(res.payload.routeId, "r1");
|
|
295
|
+
|
|
296
|
+
// The active route wins over here mode without an explicit one
|
|
297
|
+
feed({
|
|
298
|
+
updates: [
|
|
299
|
+
{
|
|
300
|
+
values: [
|
|
301
|
+
{
|
|
302
|
+
path: "navigation.course.activeRoute",
|
|
303
|
+
value: { href: "/resources/routes/r1" },
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
res = await call("/api/briefing");
|
|
310
|
+
assert.equal(res.payload.mode, "route");
|
|
311
|
+
assert.equal(res.payload.routeId, "r1");
|
|
312
|
+
} finally {
|
|
313
|
+
globalThis.fetch = originalFetch;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
plugin.stop();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("ignores unrelated paths and unexpected values", () => {
|
|
320
|
+
const app = createMockApp();
|
|
321
|
+
const plugin = pluginFactory(app);
|
|
322
|
+
plugin.start({});
|
|
323
|
+
|
|
324
|
+
const feed = app.getDeltaHandlers()[0];
|
|
325
|
+
feed({
|
|
326
|
+
updates: [
|
|
327
|
+
{ values: [{ path: "environment.wind.speedTrue", value: 5 }] },
|
|
328
|
+
{ values: [{ path: "network.internet.state", value: "offline" }] },
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
assert.match(app.getStatus(), /started/);
|
|
332
|
+
|
|
333
|
+
plugin.stop();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("active route: resolved from deltas and surfaced in status and routes", async () => {
|
|
337
|
+
const app = createMockApp();
|
|
338
|
+
app.resourcesApi = {
|
|
339
|
+
async listResources(resType) {
|
|
340
|
+
if (resType === "routes") {
|
|
341
|
+
return {
|
|
342
|
+
r1: {
|
|
343
|
+
name: "Crossing",
|
|
344
|
+
feature: {
|
|
345
|
+
geometry: {
|
|
346
|
+
coordinates: [
|
|
347
|
+
[0, 0],
|
|
348
|
+
[0, 1],
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
r2: {
|
|
354
|
+
name: "Other",
|
|
355
|
+
feature: {
|
|
356
|
+
geometry: {
|
|
357
|
+
coordinates: [
|
|
358
|
+
[0, 0],
|
|
359
|
+
[1, 0],
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {};
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
const plugin = pluginFactory(app);
|
|
370
|
+
plugin.start({});
|
|
371
|
+
plugin.registerWithRouter(app.router);
|
|
372
|
+
const feed = app.getDeltaHandlers()[0];
|
|
373
|
+
const call = async (path) => {
|
|
374
|
+
const route = app.getRoutes().find((r) => r.path === path);
|
|
375
|
+
const res = {
|
|
376
|
+
json(p) {
|
|
377
|
+
this.payload = p;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
await route.handler({}, res);
|
|
381
|
+
return res.payload;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
assert.equal((await call("/api/status")).activeRouteId, null);
|
|
385
|
+
|
|
386
|
+
// Whole-node wrapped delta form ({value: {href…}})
|
|
387
|
+
feed({
|
|
388
|
+
updates: [
|
|
389
|
+
{
|
|
390
|
+
values: [
|
|
391
|
+
{
|
|
392
|
+
path: "navigation.course.activeRoute",
|
|
393
|
+
value: { href: "/resources/routes/r2", pointIndex: 1 },
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
});
|
|
399
|
+
assert.equal((await call("/api/status")).activeRouteId, "r2");
|
|
400
|
+
const routes = await call("/api/routes");
|
|
401
|
+
assert.deepEqual(
|
|
402
|
+
routes.filter((r) => r.active).map((r) => r.id),
|
|
403
|
+
["r2"],
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
plugin.stop();
|
|
407
|
+
|
|
408
|
+
// Leaf-wrapped form ({href: {value…}}) via getSelfPath fallback
|
|
409
|
+
// on an app that has seen no delta for the path
|
|
410
|
+
const app2 = createMockApp();
|
|
411
|
+
app2.getSelfPath = (path) =>
|
|
412
|
+
path === "navigation.course.activeRoute"
|
|
413
|
+
? { href: { value: "/signalk/v1/api/resources/routes/r1" } }
|
|
414
|
+
: null;
|
|
415
|
+
const plugin2 = pluginFactory(app2);
|
|
416
|
+
plugin2.start({});
|
|
417
|
+
plugin2.registerWithRouter(app2.router);
|
|
418
|
+
const res = {
|
|
419
|
+
json(p) {
|
|
420
|
+
this.payload = p;
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
await app2
|
|
424
|
+
.getRoutes()
|
|
425
|
+
.find((r) => r.path === "/api/status")
|
|
426
|
+
.handler({}, res);
|
|
427
|
+
assert.equal(res.payload.activeRouteId, "r1");
|
|
428
|
+
|
|
429
|
+
plugin2.stop();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("polar route: resolves the active polar, null when none", async () => {
|
|
433
|
+
const TABLE = {
|
|
434
|
+
kind: "polarTable",
|
|
435
|
+
axes: { tws: [5.14], twa: [1.047] },
|
|
436
|
+
values: { boatSpeedMatrix: [[3.29]] },
|
|
437
|
+
symmetry: { portStarboardSymmetric: true },
|
|
438
|
+
};
|
|
439
|
+
const app = createMockApp();
|
|
440
|
+
app.resourcesApi = {
|
|
441
|
+
async getResource(resType, resId) {
|
|
442
|
+
if (resType === "polars" && resId === "lille-o") {
|
|
443
|
+
return TABLE;
|
|
444
|
+
}
|
|
445
|
+
throw new Error("not found");
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
app.getSelfPath = (path) => {
|
|
449
|
+
if (path === "polars.activePolar") {
|
|
450
|
+
return { value: { href: "/resources/polars/lille-o" } };
|
|
451
|
+
}
|
|
452
|
+
if (path === "polars.performanceFactor") {
|
|
453
|
+
return { value: 0.9 };
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
};
|
|
457
|
+
const plugin = pluginFactory(app);
|
|
458
|
+
plugin.start({});
|
|
459
|
+
plugin.registerWithRouter(app.router);
|
|
460
|
+
const route = app.getRoutes().find((r) => r.path === "/api/polar");
|
|
461
|
+
const res = {
|
|
462
|
+
json(payload) {
|
|
463
|
+
this.payload = payload;
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
await route.handler({}, res);
|
|
467
|
+
assert.equal(res.payload.id, "lille-o");
|
|
468
|
+
assert.equal(res.payload.table, TABLE);
|
|
469
|
+
assert.equal(res.payload.performanceFactor, 0.9);
|
|
470
|
+
|
|
471
|
+
// No active polar → null, not an error
|
|
472
|
+
app.getSelfPath = () => null;
|
|
473
|
+
const res2 = {
|
|
474
|
+
json(p) {
|
|
475
|
+
this.payload = p;
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
await route.handler({}, res2);
|
|
479
|
+
assert.equal(res2.payload, null);
|
|
480
|
+
|
|
481
|
+
plugin.stop();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("registers a status route on the plugin router", async () => {
|
|
485
|
+
const app = createMockApp();
|
|
486
|
+
const plugin = pluginFactory(app);
|
|
487
|
+
plugin.start({});
|
|
488
|
+
app.getDeltaHandlers()[0]({
|
|
489
|
+
updates: [
|
|
490
|
+
{
|
|
491
|
+
values: [
|
|
492
|
+
{ path: "network.internet.state", value: "metered" },
|
|
493
|
+
{ path: "navigation.state", value: "sailing" },
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
});
|
|
498
|
+
plugin.registerWithRouter(app.router);
|
|
499
|
+
|
|
500
|
+
const route = app.getRoutes().find((r) => r.path === "/api/status");
|
|
501
|
+
assert.ok(route, "status route registered");
|
|
502
|
+
|
|
503
|
+
const res = {
|
|
504
|
+
json(payload) {
|
|
505
|
+
this.payload = payload;
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
await route.handler({}, res);
|
|
509
|
+
assert.equal(res.payload.state, "STANDBY_OFFSHORE");
|
|
510
|
+
assert.equal(res.payload.nextCronRun, null);
|
|
511
|
+
|
|
512
|
+
plugin.stop();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("briefing routes: refresh caches, briefing serves the cache, offline refuses", async () => {
|
|
516
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
517
|
+
const app = createMockApp();
|
|
518
|
+
app.resourcesApi = {
|
|
519
|
+
async getResource(resType, resId) {
|
|
520
|
+
if (resType === "routes" && resId === "r1") {
|
|
521
|
+
return {
|
|
522
|
+
name: "Test crossing",
|
|
523
|
+
feature: {
|
|
524
|
+
geometry: {
|
|
525
|
+
coordinates: [
|
|
526
|
+
[0, 0],
|
|
527
|
+
[0, 1.5],
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
throw new Error("not found");
|
|
534
|
+
},
|
|
535
|
+
async listResources(resType) {
|
|
536
|
+
if (resType === "routes") {
|
|
537
|
+
return {
|
|
538
|
+
r1: {
|
|
539
|
+
name: "Test crossing",
|
|
540
|
+
feature: {
|
|
541
|
+
geometry: {
|
|
542
|
+
coordinates: [
|
|
543
|
+
[0, 0],
|
|
544
|
+
[0, 1.5],
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return {};
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
const plugin = pluginFactory(app);
|
|
555
|
+
plugin.start({});
|
|
556
|
+
plugin.registerWithRouter(app.router);
|
|
557
|
+
const feed = app.getDeltaHandlers()[0];
|
|
558
|
+
const routeCalls = app.getRoutes();
|
|
559
|
+
const call = async (path, req) => {
|
|
560
|
+
const route = routeCalls.find((r) => r.path === path);
|
|
561
|
+
assert.ok(route, `${path} registered`);
|
|
562
|
+
const res = {
|
|
563
|
+
code: null,
|
|
564
|
+
payload: null,
|
|
565
|
+
status(code) {
|
|
566
|
+
this.code = code;
|
|
567
|
+
return this;
|
|
568
|
+
},
|
|
569
|
+
json(payload) {
|
|
570
|
+
this.payload = payload;
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
await route.handler(req, res);
|
|
574
|
+
return res;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// No cache yet: empty payload, not an error
|
|
578
|
+
let res = await call("/api/briefing", { query: { route: "r1" } });
|
|
579
|
+
assert.equal(res.code, null);
|
|
580
|
+
assert.equal(res.payload.payload, null);
|
|
581
|
+
|
|
582
|
+
// Refresh refuses while offline
|
|
583
|
+
feed({
|
|
584
|
+
updates: [
|
|
585
|
+
{ values: [{ path: "network.internet.state", value: "offline" }] },
|
|
586
|
+
],
|
|
587
|
+
});
|
|
588
|
+
res = await call("/api/briefing/refresh", { query: { route: "r1" } });
|
|
589
|
+
assert.equal(res.code, 503);
|
|
590
|
+
|
|
591
|
+
// Online: refresh fetches, caches and records the route
|
|
592
|
+
feed({
|
|
593
|
+
updates: [
|
|
594
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
595
|
+
],
|
|
596
|
+
});
|
|
597
|
+
const originalFetch = globalThis.fetch;
|
|
598
|
+
globalThis.fetch = mockOpenMeteo();
|
|
599
|
+
try {
|
|
600
|
+
res = await call("/api/briefing/refresh", { query: { route: "r1" } });
|
|
601
|
+
assert.equal(res.code, null);
|
|
602
|
+
assert.equal(res.payload.cached, true);
|
|
603
|
+
assert.ok(existsSync(join(app.dataDir, "weather", "latest-r1.json")));
|
|
604
|
+
|
|
605
|
+
// The briefing now serves from cache, also after going offline
|
|
606
|
+
feed({
|
|
607
|
+
updates: [
|
|
608
|
+
{ values: [{ path: "network.internet.state", value: "offline" }] },
|
|
609
|
+
],
|
|
610
|
+
});
|
|
611
|
+
res = await call("/api/briefing", { query: { route: "r1" } });
|
|
612
|
+
assert.equal(res.code, null);
|
|
613
|
+
assert.equal(res.payload.online, false);
|
|
614
|
+
assert.equal(res.payload.payload.metadata.source, "api");
|
|
615
|
+
assert.ok(res.payload.cachedAt);
|
|
616
|
+
|
|
617
|
+
// Routes listing carries distances
|
|
618
|
+
res = await call("/api/routes", { query: {} });
|
|
619
|
+
assert.equal(res.payload.length, 1);
|
|
620
|
+
assert.ok(
|
|
621
|
+
res.payload[0].distanceNm > 89 && res.payload[0].distanceNm < 91,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// Unknown route briefing: empty payload, not an error
|
|
625
|
+
res = await call("/api/briefing", { query: { route: "nope" } });
|
|
626
|
+
assert.equal(res.code, null);
|
|
627
|
+
assert.equal(res.payload.mode, "route");
|
|
628
|
+
assert.equal(res.payload.payload, null);
|
|
629
|
+
assert.equal(res.payload.cached, false);
|
|
630
|
+
} finally {
|
|
631
|
+
globalThis.fetch = originalFetch;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
plugin.stop();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("bulletins: fetched online, filtered per route, served and spliced", async () => {
|
|
638
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
639
|
+
const NAVAREA = [
|
|
640
|
+
"FQPS01 NFFN 011200Z AUG 26",
|
|
641
|
+
"ZCZC GA14",
|
|
642
|
+
"011200Z AUG 26",
|
|
643
|
+
"NAVAREA XIV 114/26",
|
|
644
|
+
"GALE WARNING",
|
|
645
|
+
"PART 1 WARNING",
|
|
646
|
+
"DEVELOPING TROUGH T1 WITH SQUALLS AND GALES WITHIN 120NM",
|
|
647
|
+
"EAST OF AXIS 16S 170E TO 20S 178W TO 25S 175W.",
|
|
648
|
+
"EXPECT WINDS 35 KNOTS. ROUGH SEAS.",
|
|
649
|
+
"PARTS 2 AND 3 SYNOPSIS AND FORECAST",
|
|
650
|
+
"SITUATION IS MODERATE OVER REMAINDER WATERS.",
|
|
651
|
+
"NNNN",
|
|
652
|
+
].join("\n");
|
|
653
|
+
|
|
654
|
+
const app = createMockApp();
|
|
655
|
+
// Tonga → Opua: crosses the antimeridian
|
|
656
|
+
const route = {
|
|
657
|
+
name: "Tonga to Opua",
|
|
658
|
+
feature: {
|
|
659
|
+
geometry: {
|
|
660
|
+
coordinates: [
|
|
661
|
+
[-175.2, -21.1],
|
|
662
|
+
[174.3, -35.3],
|
|
663
|
+
],
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
app.resourcesApi = {
|
|
668
|
+
async getResource(resType, resId) {
|
|
669
|
+
if (resType === "routes" && resId === "r1") {
|
|
670
|
+
return route;
|
|
671
|
+
}
|
|
672
|
+
throw new Error("not found");
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
const plugin = pluginFactory(app);
|
|
676
|
+
plugin.start({ bulletin_urls: ["https://met.test/navarea.txt"] });
|
|
677
|
+
plugin.registerWithRouter(app.router);
|
|
678
|
+
const feed = app.getDeltaHandlers()[0];
|
|
679
|
+
const call = async (path) => {
|
|
680
|
+
const r = app.getRoutes().find((x) => x.path === path);
|
|
681
|
+
const res = {
|
|
682
|
+
code: null,
|
|
683
|
+
payload: null,
|
|
684
|
+
status(c) {
|
|
685
|
+
this.code = c;
|
|
686
|
+
return this;
|
|
687
|
+
},
|
|
688
|
+
json(p) {
|
|
689
|
+
this.payload = p;
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
await r.handler({ query: { route: "r1" } }, res);
|
|
693
|
+
return res;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Offline: bulletin refresh refuses, briefing refresh refuses
|
|
697
|
+
feed({
|
|
698
|
+
updates: [
|
|
699
|
+
{ values: [{ path: "network.internet.state", value: "offline" }] },
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
let res = await call("/api/bulletin/refresh");
|
|
703
|
+
assert.equal(res.code, 503);
|
|
704
|
+
|
|
705
|
+
// Online: the combined fetch serves Open-Meteo + the bulletin
|
|
706
|
+
feed({
|
|
707
|
+
updates: [
|
|
708
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
709
|
+
],
|
|
710
|
+
});
|
|
711
|
+
const openMeteoFetch = mockOpenMeteo();
|
|
712
|
+
const originalFetch = globalThis.fetch;
|
|
713
|
+
globalThis.fetch = async (url, opts) => {
|
|
714
|
+
if (String(url).includes("met.test")) {
|
|
715
|
+
return {
|
|
716
|
+
ok: true,
|
|
717
|
+
text: async () => NAVAREA,
|
|
718
|
+
json: async () => ({}),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return openMeteoFetch(url, opts);
|
|
722
|
+
};
|
|
723
|
+
try {
|
|
724
|
+
res = await call("/api/bulletin/refresh");
|
|
725
|
+
assert.equal(res.code, null);
|
|
726
|
+
assert.deepEqual(res.payload.fetched, ["https://met.test/navarea.txt"]);
|
|
727
|
+
|
|
728
|
+
// Briefing refresh attaches the filtered bulletin to the payload
|
|
729
|
+
res = await call("/api/briefing/refresh");
|
|
730
|
+
assert.equal(res.code, null);
|
|
731
|
+
|
|
732
|
+
res = await call("/api/briefing");
|
|
733
|
+
const bulletin = res.payload.payload.metareaBulletin;
|
|
734
|
+
assert.ok(bulletin, "bulletin attached");
|
|
735
|
+
assert.equal(bulletin.source, "api");
|
|
736
|
+
assert.equal(bulletin.blocks.length > 0, true);
|
|
737
|
+
const trough = bulletin.blocks.find((b) => b.text.includes("TROUGH"));
|
|
738
|
+
assert.ok(trough, "trough block intersects the Tonga track");
|
|
739
|
+
assert.equal(trough.geometryType, "polygon");
|
|
740
|
+
} finally {
|
|
741
|
+
globalThis.fetch = originalFetch;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Standalone bulletin route serves the filtered view too
|
|
745
|
+
res = await call("/api/bulletin");
|
|
746
|
+
assert.ok(res.payload, "bulletin served");
|
|
747
|
+
assert.ok(res.payload.blocks.some((b) => b.text.includes("TROUGH")));
|
|
748
|
+
|
|
749
|
+
plugin.stop();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("zone sources: TGFTP fast path and UKHO structured warnings merge", async () => {
|
|
753
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
754
|
+
const TGFTP_TEXT = [
|
|
755
|
+
"FQPS01 NFFN 011200Z AUG 26",
|
|
756
|
+
"ZCZC GA14",
|
|
757
|
+
"NAVAREA XIV 114/26",
|
|
758
|
+
"GALE WARNING.",
|
|
759
|
+
"DEVELOPING TROUGH T1 WITH GALES WITHIN 120NM",
|
|
760
|
+
"OF AXIS 21S 178W TO 24S 175W.",
|
|
761
|
+
"NNNN",
|
|
762
|
+
].join("\n");
|
|
763
|
+
const UKHO_JSON = JSON.stringify({
|
|
764
|
+
warnings: [
|
|
765
|
+
{
|
|
766
|
+
text: "NAVAREA XIV 115/26",
|
|
767
|
+
issuedAt: "2026-09-27T10:00:00Z",
|
|
768
|
+
coordinates: [
|
|
769
|
+
[-20.8, -175.6],
|
|
770
|
+
[-21.2, -174.8],
|
|
771
|
+
[-21.6, -175.4],
|
|
772
|
+
],
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
text: "NAVAREA I 1/26",
|
|
776
|
+
issuedAt: "2026-09-27T09:00:00Z",
|
|
777
|
+
coordinates: [[50.0, -5.0]],
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const app = createMockApp();
|
|
783
|
+
const route = {
|
|
784
|
+
name: "Tonga to Opua",
|
|
785
|
+
feature: {
|
|
786
|
+
geometry: {
|
|
787
|
+
coordinates: [
|
|
788
|
+
[-175.2, -21.1],
|
|
789
|
+
[174.3, -35.3],
|
|
790
|
+
],
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
app.resourcesApi = {
|
|
795
|
+
async getResource(resType, resId) {
|
|
796
|
+
if (resType === "routes" && resId === "r1") {
|
|
797
|
+
return route;
|
|
798
|
+
}
|
|
799
|
+
throw new Error("not found");
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
const plugin = pluginFactory(app);
|
|
803
|
+
plugin.start({
|
|
804
|
+
bulletin_stations: [{ zone: 14, header: "fqps01", station: "NFFN" }],
|
|
805
|
+
});
|
|
806
|
+
plugin.registerWithRouter(app.router);
|
|
807
|
+
const feed = app.getDeltaHandlers()[0];
|
|
808
|
+
const call = async (path) => {
|
|
809
|
+
const r = app.getRoutes().find((x) => x.path === path);
|
|
810
|
+
const res = {
|
|
811
|
+
code: null,
|
|
812
|
+
payload: null,
|
|
813
|
+
status(c) {
|
|
814
|
+
this.code = c;
|
|
815
|
+
return this;
|
|
816
|
+
},
|
|
817
|
+
json(p) {
|
|
818
|
+
this.payload = p;
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
await r.handler({ query: { route: "r1" } }, res);
|
|
822
|
+
return res;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
feed({
|
|
826
|
+
updates: [
|
|
827
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
828
|
+
],
|
|
829
|
+
});
|
|
830
|
+
const openMeteoFetch = mockOpenMeteo();
|
|
831
|
+
const originalFetch = globalThis.fetch;
|
|
832
|
+
globalThis.fetch = async (url) => {
|
|
833
|
+
const u = String(url);
|
|
834
|
+
if (u.includes("tgftp.nws.noaa.gov")) {
|
|
835
|
+
return { ok: true, text: async () => TGFTP_TEXT };
|
|
836
|
+
}
|
|
837
|
+
if (u.includes("msi.admiralty.co.uk")) {
|
|
838
|
+
return { ok: true, text: async () => UKHO_JSON };
|
|
839
|
+
}
|
|
840
|
+
if (u.includes("weather.gmdss.org")) {
|
|
841
|
+
throw new Error("portal down");
|
|
842
|
+
}
|
|
843
|
+
return openMeteoFetch(url);
|
|
844
|
+
};
|
|
845
|
+
try {
|
|
846
|
+
const res = await call("/api/briefing/refresh");
|
|
847
|
+
assert.equal(res.code, null);
|
|
848
|
+
|
|
849
|
+
// Both ingestion paths merge into one console, track-filtered:
|
|
850
|
+
// the UKHO polygon near Tonga and the TGFTP trough axis band
|
|
851
|
+
// stay, the UKHO warning in the North Sea is dropped
|
|
852
|
+
const briefing = await call("/api/briefing");
|
|
853
|
+
const bulletin = briefing.payload.payload.metareaBulletin;
|
|
854
|
+
assert.ok(bulletin, "bulletin attached");
|
|
855
|
+
const texts = bulletin.blocks.map((b) => b.text);
|
|
856
|
+
assert.ok(texts.includes("NAVAREA XIV 115/26"), "ukho polygon kept");
|
|
857
|
+
assert.ok(!texts.includes("NAVAREA I 1/26"), "ukho far warning dropped");
|
|
858
|
+
assert.ok(
|
|
859
|
+
bulletin.blocks.some((b) => b.text.includes("TROUGH")),
|
|
860
|
+
"tgftp text block kept",
|
|
861
|
+
);
|
|
862
|
+
assert.ok(
|
|
863
|
+
bulletin.blocks.some((b) => b.source === "ukho"),
|
|
864
|
+
"ukho blocks carry their source",
|
|
865
|
+
);
|
|
866
|
+
assert.match(bulletin.header, /NAVAREA XIV/);
|
|
867
|
+
assert.equal(bulletin.issuedAt, "2026-09-27T10:00:00.000Z");
|
|
868
|
+
|
|
869
|
+
// Standalone bulletin route serves the merged view too
|
|
870
|
+
const direct = await call("/api/bulletin");
|
|
871
|
+
assert.ok(
|
|
872
|
+
direct.payload.blocks.some((b) => b.source === "ukho"),
|
|
873
|
+
"ukho blocks on the standalone route",
|
|
874
|
+
);
|
|
875
|
+
assert.ok(
|
|
876
|
+
direct.payload.blocks.some((b) => b.text.includes("TROUGH")),
|
|
877
|
+
"tgftp blocks on the standalone route",
|
|
878
|
+
);
|
|
879
|
+
} finally {
|
|
880
|
+
globalThis.fetch = originalFetch;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
plugin.stop();
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("synoptic chart: fetched on the online gate, served for the position zone", async () => {
|
|
887
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
888
|
+
const UTIF = require("../public/vendor/utif/UTIF.js");
|
|
889
|
+
const rgba = new Uint8Array(8 * 4 * 4);
|
|
890
|
+
for (let i = 0; i < 8 * 4; i++) {
|
|
891
|
+
const v = i % 3 === 0 ? 0 : 255;
|
|
892
|
+
rgba[i * 4] = v;
|
|
893
|
+
rgba[i * 4 + 1] = v;
|
|
894
|
+
rgba[i * 4 + 2] = v;
|
|
895
|
+
rgba[i * 4 + 3] = 255;
|
|
896
|
+
}
|
|
897
|
+
const tif = Buffer.from(UTIF.encodeImage(rgba, 8, 4));
|
|
898
|
+
|
|
899
|
+
const app = createMockApp();
|
|
900
|
+
// Vessel in zone I waters (English Channel)
|
|
901
|
+
app.getSelfPath = (path) =>
|
|
902
|
+
path === "navigation.position"
|
|
903
|
+
? { latitude: 50.0, longitude: -5.0 }
|
|
904
|
+
: null;
|
|
905
|
+
const plugin = pluginFactory(app);
|
|
906
|
+
plugin.start({});
|
|
907
|
+
plugin.registerWithRouter(app.router);
|
|
908
|
+
const feed = app.getDeltaHandlers()[0];
|
|
909
|
+
const call = async (path) => {
|
|
910
|
+
const [base, qs] = path.split("?");
|
|
911
|
+
const r = app.getRoutes().find((x) => x.path === base);
|
|
912
|
+
const res = {
|
|
913
|
+
code: null,
|
|
914
|
+
payload: null,
|
|
915
|
+
body: null,
|
|
916
|
+
type: null,
|
|
917
|
+
status(c) {
|
|
918
|
+
this.code = c;
|
|
919
|
+
return this;
|
|
920
|
+
},
|
|
921
|
+
json(p) {
|
|
922
|
+
this.payload = p;
|
|
923
|
+
},
|
|
924
|
+
type(t) {
|
|
925
|
+
this.type = t;
|
|
926
|
+
return this;
|
|
927
|
+
},
|
|
928
|
+
send(b) {
|
|
929
|
+
this.body = b;
|
|
930
|
+
return this;
|
|
931
|
+
},
|
|
932
|
+
};
|
|
933
|
+
await r.handler(
|
|
934
|
+
{ query: Object.fromEntries(new URLSearchParams(qs ?? "")) },
|
|
935
|
+
res,
|
|
936
|
+
);
|
|
937
|
+
return res;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
feed({
|
|
941
|
+
updates: [
|
|
942
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
943
|
+
],
|
|
944
|
+
});
|
|
945
|
+
const openMeteoFetch = mockOpenMeteo();
|
|
946
|
+
const originalFetch = globalThis.fetch;
|
|
947
|
+
globalThis.fetch = async (url) => {
|
|
948
|
+
if (String(url).includes("/fax/")) {
|
|
949
|
+
return {
|
|
950
|
+
ok: true,
|
|
951
|
+
status: 200,
|
|
952
|
+
arrayBuffer: async () => tif,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return openMeteoFetch(url);
|
|
956
|
+
};
|
|
957
|
+
try {
|
|
958
|
+
// The online transition runs the here refresh, which pulls the
|
|
959
|
+
// zone XIV chart alongside the weather
|
|
960
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
961
|
+
const res = await call("/api/synoptic");
|
|
962
|
+
assert.equal(res.type, "image/png");
|
|
963
|
+
assert.equal(res.body[0], 0x89);
|
|
964
|
+
assert.ok(res.body.length > 8);
|
|
965
|
+
|
|
966
|
+
// Brief meta serves the same view the tile publishes
|
|
967
|
+
const meta = await call("/api/brief-meta");
|
|
968
|
+
assert.equal(meta.payload.comfort, "coffee");
|
|
969
|
+
assert.equal(meta.payload.stale, false);
|
|
970
|
+
assert.equal(meta.payload.ageHours, 0);
|
|
971
|
+
|
|
972
|
+
// Explicit zone override and the no-chart case
|
|
973
|
+
const z1 = await call("/api/synoptic?zone=1");
|
|
974
|
+
assert.equal(z1.body[0], 0x89);
|
|
975
|
+
const missing = await call("/api/synoptic?zone=15");
|
|
976
|
+
assert.equal(missing.code, 404);
|
|
977
|
+
} finally {
|
|
978
|
+
globalThis.fetch = originalFetch;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
plugin.stop();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("notes: here refresh publishes placeable blocks to resources", async () => {
|
|
985
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
986
|
+
const TGFTP_TEXT = [
|
|
987
|
+
"FQPS01 NFFN 271800",
|
|
988
|
+
"ZCZC GA14",
|
|
989
|
+
"NAVAREA XIV 114/26",
|
|
990
|
+
"IN THE AREA SOUTH OF 10S AND WEST OF 165W, EXPECT SOUTHEAST WINDS 20",
|
|
991
|
+
"TO 30 KNOTS. ROUGH TO VERY ROUGH SEAS.",
|
|
992
|
+
"NNNN",
|
|
993
|
+
].join("\n");
|
|
994
|
+
|
|
995
|
+
const app = createMockApp();
|
|
996
|
+
app.getSelfPath = (path) =>
|
|
997
|
+
path === "navigation.position"
|
|
998
|
+
? { latitude: -18.658, longitude: -173.982 }
|
|
999
|
+
: null;
|
|
1000
|
+
const plugin = pluginFactory(app);
|
|
1001
|
+
plugin.start({});
|
|
1002
|
+
plugin.registerWithRouter(app.router);
|
|
1003
|
+
const feed = app.getDeltaHandlers()[0];
|
|
1004
|
+
const call = async (path) => {
|
|
1005
|
+
const r = app.getRoutes().find((x) => x.path === path);
|
|
1006
|
+
const res = {
|
|
1007
|
+
code: null,
|
|
1008
|
+
payload: null,
|
|
1009
|
+
status(c) {
|
|
1010
|
+
this.code = c;
|
|
1011
|
+
return this;
|
|
1012
|
+
},
|
|
1013
|
+
json(p) {
|
|
1014
|
+
this.payload = p;
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
await r.handler({ query: {} }, res);
|
|
1018
|
+
return res;
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
feed({
|
|
1022
|
+
updates: [
|
|
1023
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
1024
|
+
],
|
|
1025
|
+
});
|
|
1026
|
+
const openMeteoFetch = mockOpenMeteo();
|
|
1027
|
+
const originalFetch = globalThis.fetch;
|
|
1028
|
+
globalThis.fetch = async (url) => {
|
|
1029
|
+
if (String(url).includes("tgftp.nws.noaa.gov")) {
|
|
1030
|
+
return { ok: true, text: async () => TGFTP_TEXT };
|
|
1031
|
+
}
|
|
1032
|
+
return openMeteoFetch(url);
|
|
1033
|
+
};
|
|
1034
|
+
try {
|
|
1035
|
+
const res = await call("/api/briefing/refresh");
|
|
1036
|
+
assert.equal(res.code, null);
|
|
1037
|
+
|
|
1038
|
+
// The plugin serves its notes through the registered provider
|
|
1039
|
+
const provider = app
|
|
1040
|
+
.getResourceProviders()
|
|
1041
|
+
.find((p) => p.type === "notes");
|
|
1042
|
+
assert.ok(provider, "notes provider registered");
|
|
1043
|
+
const listed = await provider.methods.listResources({});
|
|
1044
|
+
const metareaIds = Object.keys(listed).filter((id) =>
|
|
1045
|
+
id.startsWith("metarea-"),
|
|
1046
|
+
);
|
|
1047
|
+
assert.equal(metareaIds.length, 1, "one metarea note written");
|
|
1048
|
+
const note = listed[metareaIds[0]];
|
|
1049
|
+
assert.match(note.description, /SOUTHEAST WINDS/);
|
|
1050
|
+
// Position is the center of the quadrant nearest the vessel
|
|
1051
|
+
assert.equal(note.position.latitude, -30);
|
|
1052
|
+
assert.equal(note.position.longitude, -176.25);
|
|
1053
|
+
assert.equal(note.properties.zone, 14);
|
|
1054
|
+
assert.equal(note.properties.sourcePlugin, "signalk-passage-briefing");
|
|
1055
|
+
} finally {
|
|
1056
|
+
globalThis.fetch = originalFetch;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
plugin.stop();
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("space events: attached to here briefings, degraded when endpoints fail", async () => {
|
|
1063
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
1064
|
+
// Forecast rows inside the 24h forward window relative to now
|
|
1065
|
+
const iso = (offsetHours) =>
|
|
1066
|
+
new Date(Date.now() + offsetHours * 3600000)
|
|
1067
|
+
.toISOString()
|
|
1068
|
+
.replace(/\.\d{3}Z$/, "");
|
|
1069
|
+
const KP = JSON.stringify([
|
|
1070
|
+
{ time_tag: iso(-3), kp: 2, observed: "observed" },
|
|
1071
|
+
{ time_tag: iso(3), kp: 9, observed: "predicted" },
|
|
1072
|
+
]);
|
|
1073
|
+
// A naked-eye comet: deterministic (no night gate), so the wiring
|
|
1074
|
+
// assertion below holds at any wall-clock time; the aurora path
|
|
1075
|
+
// (Kp x magnetic latitude x night) is covered by the unit tests
|
|
1076
|
+
const SBDB = JSON.stringify({
|
|
1077
|
+
fields: ["full_name", "M1", "K1", "r", "dist"],
|
|
1078
|
+
rows: [["C/2026 A1 (Plugin Test)", 4.0, 10, 1.2, 0.5]],
|
|
1079
|
+
});
|
|
1080
|
+
const app = createMockApp();
|
|
1081
|
+
app.getSelfPath = (path) => {
|
|
1082
|
+
if (path === "navigation.position") {
|
|
1083
|
+
return { latitude: -50, longitude: 170 };
|
|
1084
|
+
}
|
|
1085
|
+
return null;
|
|
1086
|
+
};
|
|
1087
|
+
const plugin = pluginFactory(app);
|
|
1088
|
+
plugin.start({});
|
|
1089
|
+
plugin.registerWithRouter(app.router);
|
|
1090
|
+
const feed = app.getDeltaHandlers()[0];
|
|
1091
|
+
const call = async (path, req = { query: {} }) => {
|
|
1092
|
+
const route = app.getRoutes().find((r) => r.path === path);
|
|
1093
|
+
const res = {
|
|
1094
|
+
code: null,
|
|
1095
|
+
payload: null,
|
|
1096
|
+
status(code) {
|
|
1097
|
+
this.code = code;
|
|
1098
|
+
return this;
|
|
1099
|
+
},
|
|
1100
|
+
json(payload) {
|
|
1101
|
+
this.payload = payload;
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
await route.handler(req, res);
|
|
1105
|
+
return res;
|
|
1106
|
+
};
|
|
1107
|
+
feed({
|
|
1108
|
+
updates: [
|
|
1109
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
1110
|
+
],
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
const originalFetch = globalThis.fetch;
|
|
1114
|
+
globalThis.fetch = async (url, opts) => {
|
|
1115
|
+
const u = String(url);
|
|
1116
|
+
if (u.includes("swpc.noaa.gov")) {
|
|
1117
|
+
return {
|
|
1118
|
+
ok: true,
|
|
1119
|
+
status: 200,
|
|
1120
|
+
json: async () => JSON.parse(KP),
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (u.includes("ssd-api.jpl.nasa.gov")) {
|
|
1124
|
+
return {
|
|
1125
|
+
ok: true,
|
|
1126
|
+
status: 200,
|
|
1127
|
+
json: async () => JSON.parse(SBDB),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
return mockOpenMeteo()(url, opts);
|
|
1131
|
+
};
|
|
1132
|
+
try {
|
|
1133
|
+
// Space events attach to the here payload. The comet is
|
|
1134
|
+
// wall-clock independent; the aurora depends on local night at
|
|
1135
|
+
// the vessel and may or may not fire (unit-tested separately)
|
|
1136
|
+
await call("/api/briefing/refresh");
|
|
1137
|
+
const res = await call("/api/briefing");
|
|
1138
|
+
assert.equal(res.code, null);
|
|
1139
|
+
const events = res.payload.payload.spaceEvents ?? [];
|
|
1140
|
+
const comet = events.find((e) => e.kind === "comet");
|
|
1141
|
+
assert.ok(comet, "comet event attached");
|
|
1142
|
+
assert.match(comet.description, /C\/2026 A1/);
|
|
1143
|
+
for (const e of events.filter((x) => x.kind === "aurora")) {
|
|
1144
|
+
assert.equal(e.tactical, true);
|
|
1145
|
+
assert.match(e.description, /Look south/);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Endpoints down: refresh still succeeds, events degrade to none
|
|
1149
|
+
globalThis.fetch = async (url, opts) => {
|
|
1150
|
+
if (
|
|
1151
|
+
String(url).includes("swpc.noaa.gov") ||
|
|
1152
|
+
String(url).includes("ssd-api.jpl.nasa.gov")
|
|
1153
|
+
) {
|
|
1154
|
+
throw new Error("network down");
|
|
1155
|
+
}
|
|
1156
|
+
return mockOpenMeteo()(url, opts);
|
|
1157
|
+
};
|
|
1158
|
+
const res2 = await call("/api/briefing/refresh");
|
|
1159
|
+
assert.equal(res2.code, null);
|
|
1160
|
+
const res3 = await call("/api/briefing");
|
|
1161
|
+
assert.deepEqual(res3.payload.payload.spaceEvents, []);
|
|
1162
|
+
} finally {
|
|
1163
|
+
globalThis.fetch = originalFetch;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
plugin.stop();
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("plotter tile: publishes brief meta, acknowledge clears hasNew", async () => {
|
|
1170
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
1171
|
+
const app = createMockApp();
|
|
1172
|
+
app.resourcesApi = {
|
|
1173
|
+
async getResource(resType, resId) {
|
|
1174
|
+
if (resType === "routes" && resId === "r1") {
|
|
1175
|
+
return {
|
|
1176
|
+
name: "Sabado Crossing",
|
|
1177
|
+
feature: {
|
|
1178
|
+
geometry: {
|
|
1179
|
+
coordinates: [
|
|
1180
|
+
[0, 0],
|
|
1181
|
+
[0, 1.5],
|
|
1182
|
+
],
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
throw new Error("not found");
|
|
1188
|
+
},
|
|
1189
|
+
};
|
|
1190
|
+
const plugin = pluginFactory(app);
|
|
1191
|
+
plugin.start({});
|
|
1192
|
+
plugin.registerWithRouter(app.router);
|
|
1193
|
+
const feed = app.getDeltaHandlers()[0];
|
|
1194
|
+
feed({
|
|
1195
|
+
updates: [
|
|
1196
|
+
{ values: [{ path: "network.internet.state", value: "online" }] },
|
|
1197
|
+
],
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
// Providers + asset mount registered: the plotter-extension
|
|
1201
|
+
// manifest, the Status Tiles example set, and the notes store
|
|
1202
|
+
const providers = app.getResourceProviders();
|
|
1203
|
+
assert.equal(providers.length, 3);
|
|
1204
|
+
assert.deepEqual(providers.map((p) => p.type).sort(), [
|
|
1205
|
+
"notes",
|
|
1206
|
+
"plotterExtensions",
|
|
1207
|
+
"statusTileExamples",
|
|
1208
|
+
]);
|
|
1209
|
+
assert.equal(
|
|
1210
|
+
app.getMounts()[0].prefix,
|
|
1211
|
+
"/plotterext/signalk-passage-briefing",
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
const values = (delta) =>
|
|
1215
|
+
Object.fromEntries(delta.updates[0].values.map((v) => [v.path, v.value]));
|
|
1216
|
+
const briefValues = () =>
|
|
1217
|
+
app
|
|
1218
|
+
.getPublishedDeltas()
|
|
1219
|
+
.filter((d) =>
|
|
1220
|
+
d.updates[0].values.some((v) =>
|
|
1221
|
+
v.path.startsWith("navigation.briefing."),
|
|
1222
|
+
),
|
|
1223
|
+
)
|
|
1224
|
+
.map(values);
|
|
1225
|
+
|
|
1226
|
+
const originalFetch = globalThis.fetch;
|
|
1227
|
+
globalThis.fetch = mockOpenMeteo();
|
|
1228
|
+
try {
|
|
1229
|
+
const call = async (path, req = { query: {} }) => {
|
|
1230
|
+
const r = app.getRoutes().find((x) => x.path === path);
|
|
1231
|
+
const res = {
|
|
1232
|
+
code: null,
|
|
1233
|
+
payload: null,
|
|
1234
|
+
status(c) {
|
|
1235
|
+
this.code = c;
|
|
1236
|
+
return this;
|
|
1237
|
+
},
|
|
1238
|
+
json(p) {
|
|
1239
|
+
this.payload = p;
|
|
1240
|
+
},
|
|
1241
|
+
};
|
|
1242
|
+
await r.handler(req, res);
|
|
1243
|
+
return res;
|
|
1244
|
+
};
|
|
1245
|
+
await call("/api/briefing/refresh", { query: { route: "r1" } });
|
|
1246
|
+
|
|
1247
|
+
const meta = briefValues().at(-1);
|
|
1248
|
+
assert.equal(meta["navigation.briefing.route"], "Sabado Crossing");
|
|
1249
|
+
assert.equal(meta["navigation.briefing.hasNew"], true);
|
|
1250
|
+
assert.ok(meta["navigation.briefing.generatedAt"]);
|
|
1251
|
+
|
|
1252
|
+
// Acknowledge put clears the NEW badge
|
|
1253
|
+
const ack = app.getPutHandlers()["navigation.briefing.acknowledgedAt"];
|
|
1254
|
+
assert.ok(ack, "ack put handler registered");
|
|
1255
|
+
await ack.handler(
|
|
1256
|
+
"vessels.self",
|
|
1257
|
+
"navigation.briefing.acknowledgedAt",
|
|
1258
|
+
meta["navigation.briefing.generatedAt"],
|
|
1259
|
+
);
|
|
1260
|
+
const after = briefValues().at(-1);
|
|
1261
|
+
assert.equal(after["navigation.briefing.hasNew"], false);
|
|
1262
|
+
assert.ok(
|
|
1263
|
+
existsSync(join(app.dataDir, "weather", "ack.json")),
|
|
1264
|
+
"ack persisted",
|
|
1265
|
+
);
|
|
1266
|
+
} finally {
|
|
1267
|
+
globalThis.fetch = originalFetch;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Stop empties the provider so hosts tear the context down
|
|
1271
|
+
plugin.stop();
|
|
1272
|
+
assert.deepEqual(await providers[0].methods.listResources(), {});
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("oneshot fetch prefers the active route over the last briefed one", async () => {
|
|
1276
|
+
const { mockOpenMeteo } = require("./openmeteo-mock.js");
|
|
1277
|
+
const app = createMockApp();
|
|
1278
|
+
const geometry = (lat) => ({
|
|
1279
|
+
feature: {
|
|
1280
|
+
geometry: {
|
|
1281
|
+
coordinates: [
|
|
1282
|
+
[0, 0],
|
|
1283
|
+
[0, lat],
|
|
1284
|
+
],
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
app.resourcesApi = {
|
|
1289
|
+
async getResource(resType, resId) {
|
|
1290
|
+
if (resType === "routes" && resId === "r1") {
|
|
1291
|
+
return { name: "Old", ...geometry(1.5) };
|
|
1292
|
+
}
|
|
1293
|
+
if (resType === "routes" && resId === "r2") {
|
|
1294
|
+
return { name: "Active", ...geometry(1.2) };
|
|
1295
|
+
}
|
|
1296
|
+
throw new Error("not found");
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
const plugin = pluginFactory(app);
|
|
1300
|
+
plugin.start({});
|
|
1301
|
+
|
|
1302
|
+
// Previous session briefed r1
|
|
1303
|
+
mkdirSync(join(app.dataDir, "weather"), { recursive: true });
|
|
1304
|
+
writeFileSync(join(app.dataDir, "weather", "last-route"), "r1");
|
|
1305
|
+
|
|
1306
|
+
// Now sailing r2 while the link comes up
|
|
1307
|
+
const originalFetch = globalThis.fetch;
|
|
1308
|
+
globalThis.fetch = mockOpenMeteo();
|
|
1309
|
+
try {
|
|
1310
|
+
app.getDeltaHandlers()[0]({
|
|
1311
|
+
updates: [
|
|
1312
|
+
{
|
|
1313
|
+
values: [
|
|
1314
|
+
{ path: "network.internet.state", value: "online" },
|
|
1315
|
+
{
|
|
1316
|
+
path: "navigation.course.activeRoute",
|
|
1317
|
+
value: { href: "/resources/routes/r2" },
|
|
1318
|
+
},
|
|
1319
|
+
],
|
|
1320
|
+
},
|
|
1321
|
+
],
|
|
1322
|
+
});
|
|
1323
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
1324
|
+
} finally {
|
|
1325
|
+
globalThis.fetch = originalFetch;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
assert.ok(
|
|
1329
|
+
existsSync(join(app.dataDir, "weather", "latest-r2.json")),
|
|
1330
|
+
"active route r2 briefed",
|
|
1331
|
+
);
|
|
1332
|
+
assert.ok(
|
|
1333
|
+
!existsSync(join(app.dataDir, "weather", "latest-r1.json")),
|
|
1334
|
+
"stale r1 not re-briefed",
|
|
1335
|
+
);
|
|
1336
|
+
assert.ok(existsSync(join(app.dataDir, "weather", "last-route")));
|
|
1337
|
+
assert.equal(
|
|
1338
|
+
readFileSync(join(app.dataDir, "weather", "last-route"), "utf8"),
|
|
1339
|
+
"r2",
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1342
|
+
plugin.stop();
|
|
1343
|
+
});
|
|
1344
|
+
});
|