@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,265 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const cs = require("../plugin/celestial-source.js");
5
+
6
+ /**
7
+ * SWPC planetary K-index forecast fixture (verified live shape:
8
+ * array of {time_tag, kp, observed} rows at 3-hour steps).
9
+ */
10
+ function kpFixture() {
11
+ return [
12
+ { time_tag: "2026-09-27T00:00:00", kp: 2.33, observed: "observed" },
13
+ { time_tag: "2026-09-27T09:00:00", kp: 4.0, observed: "observed" },
14
+ { time_tag: "2026-09-27T12:00:00", kp: 6.33, observed: "predicted" },
15
+ { time_tag: "2026-09-27T15:00:00", kp: 5.67, observed: "predicted" },
16
+ { time_tag: "2026-09-27T18:00:00", kp: 3.0, observed: "predicted" },
17
+ { time_tag: "2026-09-30T00:00:00", kp: 8.0, observed: "predicted" },
18
+ ];
19
+ }
20
+
21
+ /** SBDB query fixture (documented {fields, rows} response shape). */
22
+ function sbdbFixture() {
23
+ return {
24
+ fields: ["full_name", "M1", "K1", "r", "dist"],
25
+ rows: [
26
+ // m = 4.0 + 5·log10(0.5) + 10·log10(1.2) ≈ 5.12 → naked eye
27
+ ["C/2026 A1 (Fixture)", 4.0, 10, 1.2, 0.5],
28
+ // m ≈ 9.75 → too faint
29
+ ["C/2025 B2 (Faint)", 7.0, 10, 2.0, 1.5],
30
+ // Missing numbers → skipped
31
+ ["C/2024 C3 (Broken)", null, null, null, null],
32
+ ],
33
+ };
34
+ }
35
+
36
+ test("parseComets falls back to perihelion estimate without r/dist", () => {
37
+ // The live SBDB endpoint rejects r/dist for sb-kind=c: with q only,
38
+ // the parse screens on perihelion brightness and flags the estimate
39
+ const comets = cs.parseComets({
40
+ fields: ["full_name", "M1", "K1", "q", "tp_cal"],
41
+ rows: [
42
+ // mPeri = 4.0 + 15·log10(0.9) ≈ 3.31 → naked eye at perihelion
43
+ ["C/2026 D1 (Peri)", 4.0, 10, 0.9, "2026-Nov-03.1"],
44
+ // mPeri = 9 + 15·log10(3) ≈ 16.2 → never naked eye
45
+ ["C/2020 E4 (Faint)", 9.0, 10, 3.0, "2020-Aug-01.5"],
46
+ ],
47
+ });
48
+ assert.equal(comets.length, 1);
49
+ assert.equal(comets[0].name, "C/2026 D1 (Peri)");
50
+ assert.equal(comets[0].estimate, "perihelion");
51
+ assert.equal(comets[0].perihelion, "2026-Nov-03.1");
52
+ });
53
+
54
+ test("fetchComets negotiates invalid fields away and retries", async () => {
55
+ const attempted = [];
56
+ const responses = [
57
+ // 1st: full list rejected on r
58
+ {
59
+ ok: false,
60
+ status: 400,
61
+ text: async () =>
62
+ JSON.stringify({ message: "invalid field specified: 'r'" }),
63
+ },
64
+ // 2nd: dist rejected too
65
+ {
66
+ ok: false,
67
+ status: 400,
68
+ text: async () =>
69
+ JSON.stringify({ message: "invalid field specified: 'dist'" }),
70
+ },
71
+ // 3rd: accepted
72
+ {
73
+ ok: true,
74
+ status: 200,
75
+ json: async () => ({ fields: ["ok"], rows: [] }),
76
+ },
77
+ ];
78
+ const comets = await cs.fetchComets({
79
+ fetchImpl: async (url) => {
80
+ attempted.push(String(url));
81
+ return responses[attempted.length - 1];
82
+ },
83
+ timeoutMs: 500,
84
+ });
85
+ assert.deepEqual(comets, { fields: ["ok"], rows: [] });
86
+ assert.equal(attempted.length, 3);
87
+ assert.match(attempted[0], /fields=full_name,M1,K1,r,dist,q,tp_cal/);
88
+ assert.match(attempted[1], /fields=full_name,M1,K1,dist,q,tp_cal/);
89
+ assert.match(attempted[2], /fields=full_name,M1,K1,q,tp_cal/);
90
+ });
91
+
92
+ test("fetchComets returns null when the endpoint never answers", async () => {
93
+ const comets = await cs.fetchComets({
94
+ fetchImpl: async () => ({ ok: false, status: 503, text: async () => "" }),
95
+ timeoutMs: 500,
96
+ });
97
+ assert.equal(comets, null);
98
+ });
99
+
100
+ const NIGHT = (_date, _lat, _lon) => true;
101
+ const DAY = (_date, _lat, _lon) => false;
102
+
103
+ describe("celestial & space weather (doc #3 Phase 1)", () => {
104
+ test("aurora magnetic-latitude threshold tightens with Kp", () => {
105
+ assert.equal(cs.auroraMagLatThreshold(5), 65);
106
+ assert.equal(cs.auroraMagLatThreshold(8), 50);
107
+ assert.equal(cs.auroraMagLatThreshold(9), 45);
108
+ assert.equal(cs.auroraMagLatThreshold(12), 40); // Clamped
109
+ });
110
+
111
+ test("magnetic latitude: known hemisphere sanity", () => {
112
+ // Opua NZ: geographic -35.3 → dipole about -37 (coarse)
113
+ const opua = cs.magneticLatitudeDeg(-35.3, 174.3);
114
+ assert.ok(opua < -30 && opua > -45, `Opua mag lat ${opua}`);
115
+ // Tromsø, Norway: high northern magnetic latitude
116
+ const tromso = cs.magneticLatitudeDeg(69.6, 18.9);
117
+ assert.ok(tromso > 55, `Tromsø mag lat ${tromso}`);
118
+ });
119
+
120
+ test("parseKpForecast keeps the forward window only", () => {
121
+ const from = new Date("2026-09-27T06:00:00Z");
122
+ const entries = cs.parseKpForecast(kpFixture(), { from, hours: 24 });
123
+ assert.deepEqual(
124
+ entries.map((e) => e.timestamp),
125
+ [
126
+ "2026-09-27T09:00:00.000Z",
127
+ "2026-09-27T12:00:00.000Z",
128
+ "2026-09-27T15:00:00.000Z",
129
+ "2026-09-27T18:00:00.000Z",
130
+ ],
131
+ );
132
+ // The Kp 8 entry three days out is outside the window
133
+ assert.ok(entries.every((e) => e.kp < 8));
134
+ assert.equal(entries[1].predicted, true);
135
+ assert.deepEqual(cs.parseKpForecast(null), []);
136
+ });
137
+
138
+ test("comet apparent magnitude follows m = M1 + 5log Δ + K1 log r", () => {
139
+ const m = cs.cometApparentMagnitude({ M1: 4, K1: 10, r: 1.2, delta: 0.5 });
140
+ approx(m, 4 + 5 * Math.log10(0.5) + 10 * Math.log10(1.2));
141
+ assert.equal(cs.cometApparentMagnitude({ M1: 4 }), null);
142
+ assert.equal(
143
+ cs.cometApparentMagnitude({ M1: 4, K1: 10, r: 0, delta: 1 }),
144
+ null,
145
+ );
146
+ });
147
+
148
+ test("parseComets keeps only naked-eye comets, brightest first", () => {
149
+ const comets = cs.parseComets(sbdbFixture());
150
+ assert.equal(comets.length, 1);
151
+ assert.equal(comets[0].name, "C/2026 A1 (Fixture)");
152
+ assert.ok(comets[0].magnitude < 6);
153
+ // Broken row numbers are skipped, not fatal
154
+ assert.equal(cs.parseComets({ fields: [], rows: [] }).length, 0);
155
+ assert.equal(cs.parseComets(null).length, 0);
156
+ });
157
+
158
+ test("buildSpaceEvents gates aurora by Kp, magnetic latitude and night", async () => {
159
+ const kpEntries = cs.parseKpForecast(kpFixture(), {
160
+ from: new Date("2026-09-27T06:00:00Z"),
161
+ hours: 24,
162
+ });
163
+ const comets = cs.parseComets(sbdbFixture());
164
+
165
+ // Tonga (-21): far too equatorial for Kp 6
166
+ const tonga = await cs.buildSpaceEvents({
167
+ kpEntries,
168
+ comets,
169
+ lat: -21.1,
170
+ lon: -175.2,
171
+ isNightFn: NIGHT,
172
+ });
173
+ assert.ok(tonga.every((e) => e.kind !== "aurora"));
174
+ assert.equal(tonga.length, 1); // The comet
175
+ assert.equal(tonga[0].kind, "comet");
176
+ assert.equal(tonga[0].tactical, false);
177
+
178
+ // Subantarctic south (−54): inside the Kp 6.33 → 55°? No: 68 mag
179
+ // lat threshold at 6.33... use a Kp 9 window instead
180
+ const storm = [
181
+ { timestamp: "2026-09-27T12:00:00.000Z", kp: 9, predicted: true },
182
+ ];
183
+ const farSouth = await cs.buildSpaceEvents({
184
+ kpEntries: storm,
185
+ comets: [],
186
+ lat: -50,
187
+ lon: 170,
188
+ isNightFn: NIGHT,
189
+ });
190
+ assert.equal(farSouth.length, 1);
191
+ assert.equal(farSouth[0].kind, "aurora");
192
+ assert.equal(farSouth[0].tactical, true);
193
+ assert.match(farSouth[0].description, /Look south/);
194
+
195
+ // Daylight at the peak kills the aurora alert
196
+ const daytime = await cs.buildSpaceEvents({
197
+ kpEntries: storm,
198
+ comets: [],
199
+ lat: -50,
200
+ lon: 170,
201
+ isNightFn: DAY,
202
+ });
203
+ assert.equal(daytime.length, 0);
204
+
205
+ // Northern hemisphere looks north
206
+ const north = await cs.buildSpaceEvents({
207
+ kpEntries: storm,
208
+ comets: [],
209
+ lat: 55,
210
+ lon: 20,
211
+ isNightFn: NIGHT,
212
+ });
213
+ assert.match(north[0].description, /Look north/);
214
+
215
+ // Sub-storm Kp: nothing even at high latitude
216
+ const quiet = await cs.buildSpaceEvents({
217
+ kpEntries: [{ timestamp: "2026-09-27T12:00:00.000Z", kp: 4 }],
218
+ comets: [],
219
+ lat: -50,
220
+ lon: 170,
221
+ isNightFn: NIGHT,
222
+ });
223
+ assert.equal(quiet.length, 0);
224
+ });
225
+
226
+ test("fetchSpaceEvents degrades per source and on total failure", async () => {
227
+ const originalFetch = globalThis.fetch;
228
+ try {
229
+ // Only SWPC answers: aurora gating still runs (comets skipped)
230
+ globalThis.fetch = async (url) => {
231
+ if (String(url).includes("swpc.noaa.gov")) {
232
+ return { ok: true, json: async () => kpFixture() };
233
+ }
234
+ throw new Error("network down");
235
+ };
236
+ const events = await cs.fetchSpaceEvents({
237
+ lat: -50,
238
+ lon: 170,
239
+ now: new Date("2026-09-27T06:00:00Z"),
240
+ isNightFn: NIGHT,
241
+ });
242
+ assert.ok(events.every((e) => ["aurora", "comet"].includes(e.kind)));
243
+
244
+ // Everything fails: empty list, never throws
245
+ globalThis.fetch = async () => {
246
+ throw new Error("network down");
247
+ };
248
+ const none = await cs.fetchSpaceEvents({
249
+ lat: -50,
250
+ lon: 170,
251
+ now: new Date("2026-09-27T06:00:00Z"),
252
+ });
253
+ assert.deepEqual(none, []);
254
+ } finally {
255
+ globalThis.fetch = originalFetch;
256
+ }
257
+ });
258
+ });
259
+
260
+ function approx(actual, expected, epsilon = 1e-9) {
261
+ assert.ok(
262
+ Math.abs(actual - expected) < epsilon,
263
+ `expected ${actual} ≈ ${expected} (±${epsilon})`,
264
+ );
265
+ }
@@ -0,0 +1,297 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { mkdtempSync, existsSync, readdirSync } = require("node:fs");
4
+ const { tmpdir } = require("node:os");
5
+ const { join } = require("node:path");
6
+
7
+ const { forecastFixture, mockOpenMeteo } = require("./openmeteo-mock.js");
8
+
9
+ const {
10
+ KEEP_SNAPSHOTS,
11
+ sampleRoutePoints,
12
+ routeDistanceNm,
13
+ dewpointC,
14
+ kIndex,
15
+ normalizeLocations,
16
+ fetchWeatherAlongTrack,
17
+ savePayload,
18
+ loadPayload,
19
+ listCachedRoutes,
20
+ } = require("../plugin/fetch-engine.js");
21
+
22
+ const approx = (actual, expected, epsilon = 1e-6) =>
23
+ assert.ok(
24
+ Math.abs(actual - expected) < epsilon,
25
+ `expected ${actual} ≈ ${expected} (±${epsilon})`,
26
+ );
27
+
28
+ describe("route sampling", () => {
29
+ test("divides a 90 nm meridian leg into even ≤30 nm steps", () => {
30
+ const points = sampleRoutePoints(
31
+ [
32
+ [0, 0],
33
+ [0, 1.5],
34
+ ],
35
+ 30,
36
+ );
37
+ // 90.06 nm total → 4 steps of 22.5 nm
38
+ assert.equal(points.length, 5);
39
+ approx(points[0].distanceFromStartNm, 0);
40
+ approx(points[1].distanceFromStartNm, 22.5, 0.1);
41
+ approx(points[2].distanceFromStartNm, 45, 0.1);
42
+ approx(
43
+ points[4].distanceFromStartNm,
44
+ routeDistanceNm([
45
+ [0, 0],
46
+ [0, 1.5],
47
+ ]),
48
+ 0.1,
49
+ );
50
+ approx(points[2].lat, 0.75, 0.01);
51
+ assert.equal(points[0].lon, 0);
52
+ });
53
+
54
+ test("keeps spacing uniform across multi-leg routes", () => {
55
+ // 60 nm + 60 nm legs
56
+ const points = sampleRoutePoints(
57
+ [
58
+ [0, 0],
59
+ [0, 1],
60
+ [1, 1],
61
+ ],
62
+ 30,
63
+ );
64
+ const distances = points.map((p) => p.distanceFromStartNm);
65
+ assert.equal(distances[0], 0);
66
+ for (let i = 1; i < distances.length; i++) {
67
+ approx(distances[i] - distances[i - 1], distances[1], 0.3);
68
+ }
69
+ approx(
70
+ distances[distances.length - 1],
71
+ routeDistanceNm([
72
+ [0, 0],
73
+ [0, 1],
74
+ [1, 1],
75
+ ]),
76
+ 0.1,
77
+ );
78
+ });
79
+
80
+ test("single-point route and empty input", () => {
81
+ assert.equal(sampleRoutePoints([[5, 55]]).length, 1);
82
+ assert.deepEqual(sampleRoutePoints([]), []);
83
+ });
84
+
85
+ test("route distance", () => {
86
+ approx(
87
+ routeDistanceNm([
88
+ [0, 0],
89
+ [0, 1.5],
90
+ ]),
91
+ 90,
92
+ 0.1,
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("k-index", () => {
98
+ test("dew point via Magnus", () => {
99
+ approx(dewpointC(15, 80), 11.57, 0.01);
100
+ approx(dewpointC(5, 60), -2.13, 0.01);
101
+ });
102
+
103
+ test("K = (T850−T500) + Td850 − (T700−Td700)", () => {
104
+ const k = kIndex({ t850: 15, t700: 5, t500: -10, rh850: 80, rh700: 60 });
105
+ approx(k, 29.44, 0.01);
106
+ });
107
+
108
+ test("null on missing inputs", () => {
109
+ assert.equal(
110
+ kIndex({ t850: 15, t700: 5, t500: null, rh850: 80, rh700: 60 }),
111
+ null,
112
+ );
113
+ });
114
+ });
115
+
116
+ describe("normalizeLocations", () => {
117
+ test("single object, array and null responses", () => {
118
+ assert.deepEqual(normalizeLocations({ a: 1 }, 2), [{ a: 1 }, null]);
119
+ assert.deepEqual(normalizeLocations([{ a: 1 }, { a: 2 }], 2), [
120
+ { a: 1 },
121
+ { a: 2 },
122
+ ]);
123
+ assert.deepEqual(normalizeLocations(null, 1), [null]);
124
+ });
125
+ });
126
+
127
+ const WAYPOINTS = sampleRoutePoints([
128
+ [0, 0],
129
+ [0, 1.5],
130
+ ]);
131
+
132
+ describe("fetchWeatherAlongTrack", () => {
133
+ test("builds the SPEC §3.1 payload with unit conversions", async () => {
134
+ const payload = await fetchWeatherAlongTrack({
135
+ waypoints: WAYPOINTS,
136
+ fetchImpl: mockOpenMeteo(),
137
+ });
138
+ assert.equal(payload.metadata.source, "api");
139
+ assert.deepEqual(payload.metadata.models, [
140
+ "forecast:best_match",
141
+ "marine:ncep_gfswave025",
142
+ "marine:smoc_currents",
143
+ ]);
144
+ assert.equal(payload.waypoints.length, 5);
145
+ const [step] = payload.waypoints[0].forecasts;
146
+ // First step is the current hour (fixture times are relative)
147
+ const firstTimestamp = new Date(
148
+ Date.now() - (Date.now() % 3600000),
149
+ ).toISOString();
150
+ assert.equal(step.timestamp, firstTimestamp);
151
+ assert.deepEqual(step.surface, {
152
+ tws: 12,
153
+ twd: 45,
154
+ mslp: 1013,
155
+ gust: 18,
156
+ });
157
+ assert.deepEqual(step.marine, {
158
+ hsCombined: 1.2,
159
+ tpCombined: 7,
160
+ dirCombined: 160,
161
+ hsSwell: 0.9,
162
+ tpSwell: 9,
163
+ dirSwell: 150,
164
+ hsWindSea: 0.5,
165
+ tpWindSea: 4,
166
+ dirWindSea: 170,
167
+ });
168
+ assert.equal(step.upperAir.cape, 200);
169
+ assert.equal(step.upperAir.kIndex, 29.4);
170
+ assert.equal(step.upperAir.rh700, 60);
171
+ assert.equal(step.upperAir.wind850kts, 20);
172
+ // 1.852 km/h → 1 kn
173
+ assert.equal(step.current.drift, 1);
174
+ assert.equal(step.current.set, 45);
175
+ assert.equal(
176
+ payload.waypoints[4].distanceFromStartNm,
177
+ routeDistanceNm([
178
+ [0, 0],
179
+ [0, 1.5],
180
+ ]),
181
+ );
182
+ });
183
+
184
+ test("aligns multi-location responses in request order", async () => {
185
+ const waypoints = sampleRoutePoints([
186
+ [0, 0],
187
+ [0, 0.5],
188
+ ]); // 30 nm → 3 points
189
+ const multiForecast = [
190
+ forecastFixture(),
191
+ forecastFixture(),
192
+ forecastFixture(),
193
+ ];
194
+ const payload = await fetchWeatherAlongTrack({
195
+ waypoints,
196
+ fetchImpl: mockOpenMeteo({ forecast: multiForecast }),
197
+ });
198
+ assert.equal(payload.waypoints.length, 3);
199
+ assert.equal(payload.waypoints[0].forecasts[0].surface.tws, 12);
200
+ assert.equal(payload.waypoints[2].forecasts[0].surface.tws, 12);
201
+ });
202
+
203
+ test("marine failure degrades gracefully", async () => {
204
+ const payload = await fetchWeatherAlongTrack({
205
+ waypoints: WAYPOINTS,
206
+ fetchImpl: mockOpenMeteo({ throw: "ncep_gfswave025" }),
207
+ });
208
+ assert.deepEqual(payload.metadata.models, [
209
+ "forecast:best_match",
210
+ "marine:smoc_currents",
211
+ ]);
212
+ assert.equal(payload.waypoints[0].forecasts[0].marine.hsCombined, null);
213
+ assert.equal(payload.waypoints[0].forecasts[0].surface.tws, 12);
214
+ });
215
+
216
+ test("forecast failure fails the fetch", async () => {
217
+ await assert.rejects(
218
+ fetchWeatherAlongTrack({
219
+ waypoints: WAYPOINTS,
220
+ fetchImpl: mockOpenMeteo({ throw: "/v1/forecast" }),
221
+ }),
222
+ /returned 500|network down/,
223
+ );
224
+ });
225
+
226
+ test("retries a 500 before succeeding", async () => {
227
+ let attempts = 0;
228
+ const flaky = async (url) => {
229
+ const u = String(url);
230
+ if (u.includes("/v1/forecast") && attempts++ === 0) {
231
+ return { ok: false, status: 500, statusText: "boom" };
232
+ }
233
+ return mockOpenMeteo()(url);
234
+ };
235
+ const payload = await fetchWeatherAlongTrack({
236
+ waypoints: WAYPOINTS,
237
+ fetchImpl: flaky,
238
+ });
239
+ assert.equal(payload.metadata.source, "api");
240
+ });
241
+
242
+ test("empty waypoints rejected", async () => {
243
+ await assert.rejects(
244
+ fetchWeatherAlongTrack({ waypoints: [], fetchImpl: mockOpenMeteo() }),
245
+ /No waypoints/,
246
+ );
247
+ });
248
+ });
249
+
250
+ describe("payload cache", () => {
251
+ test("save, load round-trip and snapshot pruning", async () => {
252
+ const dataDir = mkdtempSync(join(tmpdir(), "passage-cache-"));
253
+ const makePayload = (fetchedAt) => ({
254
+ metadata: { fetchedAt, source: "api", models: ["forecast:best_match"] },
255
+ waypoints: [],
256
+ });
257
+
258
+ for (let i = 0; i < KEEP_SNAPSHOTS + 3; i++) {
259
+ await savePayload(
260
+ dataDir,
261
+ "route 1",
262
+ makePayload(`2026-09-27T${String(i).padStart(2, "0")}:00:00Z`),
263
+ );
264
+ }
265
+
266
+ const latest = await loadPayload(dataDir, "route 1");
267
+ assert.equal(
268
+ latest.payload.metadata.fetchedAt,
269
+ `2026-09-27T${String(KEEP_SNAPSHOTS + 2).padStart(2, "0")}:00:00Z`,
270
+ );
271
+ assert.equal(
272
+ latest.cachedAt,
273
+ `2026-09-27T${String(KEEP_SNAPSHOTS + 2).padStart(2, "0")}:00:00Z`,
274
+ );
275
+
276
+ const snapshots = readdirSync(join(dataDir, "weather")).filter(
277
+ (f) => f.startsWith("route%201-") && f.endsWith(".json"),
278
+ );
279
+ assert.equal(snapshots.length, KEEP_SNAPSHOTS);
280
+ // Newest snapshot carries the same content as latest
281
+ assert.ok(existsSync(join(dataDir, "weather", "latest-route%201.json")));
282
+
283
+ const cached = await listCachedRoutes(dataDir);
284
+ assert.deepEqual(cached, [
285
+ {
286
+ routeId: "route 1",
287
+ cachedAt: `2026-09-27T${String(KEEP_SNAPSHOTS + 2).padStart(2, "0")}:00:00Z`,
288
+ },
289
+ ]);
290
+ });
291
+
292
+ test("missing cache reads null", async () => {
293
+ const dataDir = mkdtempSync(join(tmpdir(), "passage-cache-"));
294
+ assert.equal(await loadPayload(dataDir, "nope"), null);
295
+ assert.deepEqual(await listCachedRoutes(dataDir), []);
296
+ });
297
+ });