@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,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
|
+
});
|