@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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. 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
+ });