@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,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the webapp view models (`public/components/models.mjs`)
|
|
3
|
+
* that back the custom elements (SPEC §6).
|
|
4
|
+
*
|
|
5
|
+
* @file webapp.test.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const test = require("node:test");
|
|
9
|
+
const assert = require("node:assert/strict");
|
|
10
|
+
|
|
11
|
+
test("webapp view models", async (t) => {
|
|
12
|
+
const {
|
|
13
|
+
COMFORT_TIERS,
|
|
14
|
+
comfortColor,
|
|
15
|
+
etaTable,
|
|
16
|
+
fmtGal,
|
|
17
|
+
fmtHours,
|
|
18
|
+
fmtKn,
|
|
19
|
+
fmtUtc,
|
|
20
|
+
sailActionCards,
|
|
21
|
+
sparklineColumns,
|
|
22
|
+
splitSevere,
|
|
23
|
+
tacticalNow,
|
|
24
|
+
hereHourly,
|
|
25
|
+
hereNow,
|
|
26
|
+
sailWorkTimeline,
|
|
27
|
+
} = await import("../public/components/models.mjs");
|
|
28
|
+
|
|
29
|
+
await t.test("fmtUtc renders MM-DD HH:MMZ in UTC", () => {
|
|
30
|
+
assert.equal(fmtUtc("2026-06-21T06:05:00.000Z"), "06-21 06:05Z");
|
|
31
|
+
assert.equal(fmtUtc(null), "");
|
|
32
|
+
assert.equal(fmtUtc("not a date"), "");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await t.test("fmtHours buckets days, hours and minutes", () => {
|
|
36
|
+
assert.equal(fmtHours(31.5), "1d 07h");
|
|
37
|
+
assert.equal(fmtHours(5.34), "5h 20m");
|
|
38
|
+
assert.equal(fmtHours(0.2), "12m");
|
|
39
|
+
assert.equal(fmtHours(null), "");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await t.test("fmtKn and fmtGal keep one decimal", () => {
|
|
43
|
+
assert.equal(fmtKn(12.34), "12.3 kn");
|
|
44
|
+
assert.equal(fmtGal(3.96), "4.0 gal");
|
|
45
|
+
assert.equal(fmtKn(null), "");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await t.test(
|
|
49
|
+
"sparklineColumns maps comfort tiers and normalizes AWS height",
|
|
50
|
+
() => {
|
|
51
|
+
const blocks = [
|
|
52
|
+
{ hoursFromNow: 0, comfortLevel: "champagne", awsKnots: 10 },
|
|
53
|
+
{ hoursFromNow: 1, comfortLevel: "rough", awsKnots: 20 },
|
|
54
|
+
{ hoursFromNow: 2, comfortLevel: "sick", awsKnots: 0 },
|
|
55
|
+
];
|
|
56
|
+
const cols = sparklineColumns(blocks);
|
|
57
|
+
assert.equal(cols.length, 3);
|
|
58
|
+
assert.equal(cols[0].heightPct, 50); // 10 of max 20
|
|
59
|
+
assert.equal(cols[1].heightPct, 100);
|
|
60
|
+
assert.equal(cols[2].heightPct, 4); // Floor
|
|
61
|
+
assert.match(cols[0].color, /champagne/);
|
|
62
|
+
assert.match(cols[2].color, /sick/);
|
|
63
|
+
assert.ok(cols[1].title.includes("20.0 kn"));
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await t.test("sparklineColumns caps the window at 24 columns", () => {
|
|
68
|
+
const blocks = Array.from({ length: 30 }, (_, i) => ({
|
|
69
|
+
hoursFromNow: i,
|
|
70
|
+
comfortLevel: "easy",
|
|
71
|
+
awsKnots: 10,
|
|
72
|
+
}));
|
|
73
|
+
assert.equal(sparklineColumns(blocks).length, 24);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await t.test("comfort tiers cover the Sereno scale", () => {
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
COMFORT_TIERS.map((tier) => tier.level),
|
|
79
|
+
["champagne", "easy", "coffee", "rough", "sick"],
|
|
80
|
+
);
|
|
81
|
+
assert.match(comfortColor("unknown"), /unknown/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t.test("tacticalNow reads the first block and colors the tier", () => {
|
|
85
|
+
const exceptions = {
|
|
86
|
+
next24h: {
|
|
87
|
+
comfortBlocks: [
|
|
88
|
+
{
|
|
89
|
+
hoursFromNow: 0,
|
|
90
|
+
comfortLevel: "coffee",
|
|
91
|
+
awsKnots: 14.2,
|
|
92
|
+
timestamp: "2026-06-21T06:00:00Z",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const now = tacticalNow(exceptions);
|
|
98
|
+
assert.equal(now.comfortLevel, "coffee");
|
|
99
|
+
assert.equal(now.awsKnots, 14.2);
|
|
100
|
+
assert.equal(now.stamp, "06-21 06:00Z");
|
|
101
|
+
assert.match(now.color, /coffee/);
|
|
102
|
+
|
|
103
|
+
assert.equal(tacticalNow(null).comfortLevel, null);
|
|
104
|
+
assert.match(tacticalNow(null).color, /unknown/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await t.test("sailActionCards format stamps and night flags", () => {
|
|
108
|
+
const cards = sailActionCards({
|
|
109
|
+
next24h: {
|
|
110
|
+
sailChanges: [
|
|
111
|
+
{
|
|
112
|
+
hoursFromNow: 6,
|
|
113
|
+
timestamp: "2026-06-21T12:00:00Z",
|
|
114
|
+
sailState: "GENOA_1_MAIN",
|
|
115
|
+
night: false,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
hoursFromNow: 18,
|
|
119
|
+
timestamp: "2026-06-22T00:00:00Z",
|
|
120
|
+
sailState: "STAYSAIL_MAIN_1_REEF",
|
|
121
|
+
night: true,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
assert.equal(cards[0].stamp, "06-21 12:00Z");
|
|
127
|
+
assert.equal(cards[1].night, true);
|
|
128
|
+
assert.equal(cards[1].sailState, "STAYSAIL_MAIN_1_REEF");
|
|
129
|
+
assert.deepEqual(sailActionCards(null), []);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await t.test("splitSevere tokenizes severe keywords", () => {
|
|
133
|
+
const tokens = splitSevere(
|
|
134
|
+
"EXPECT WINDS 35 KNOTS. Rough seas with heavy GALE warnings.",
|
|
135
|
+
);
|
|
136
|
+
const severeWords = tokens
|
|
137
|
+
.filter((t) => t.severe)
|
|
138
|
+
.map((t) => t.text.toUpperCase());
|
|
139
|
+
assert.deepEqual(severeWords, ["ROUGH SEAS", "GALE"]);
|
|
140
|
+
assert.ok(tokens.some((t) => !t.severe && t.text.includes("EXPECT")));
|
|
141
|
+
// Reconstructing the tokens reproduces the input exactly
|
|
142
|
+
const input = "EXPECT WINDS 35 KNOTS. Rough seas with heavy GALE warnings.";
|
|
143
|
+
assert.equal(
|
|
144
|
+
splitSevere(input)
|
|
145
|
+
.map((t) => t.text)
|
|
146
|
+
.join(""),
|
|
147
|
+
input,
|
|
148
|
+
);
|
|
149
|
+
assert.deepEqual(splitSevere("plain text only"), [
|
|
150
|
+
{ text: "plain text only", severe: false },
|
|
151
|
+
]);
|
|
152
|
+
assert.deepEqual(splitSevere(null), []);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t.test("hereHourly evaluates comfort at SOG 0 over 24h", () => {
|
|
156
|
+
const payload = {
|
|
157
|
+
metadata: { fetchedAt: "2026-09-27T10:00:00.000Z", mode: "here" },
|
|
158
|
+
waypoints: [
|
|
159
|
+
{
|
|
160
|
+
lat: -21.1,
|
|
161
|
+
lon: -175.2,
|
|
162
|
+
forecasts: Array.from({ length: 40 }, (_, i) => ({
|
|
163
|
+
timestamp: new Date(
|
|
164
|
+
new Date("2026-09-27T00:00:00.000Z").getTime() + i * 3600000,
|
|
165
|
+
).toISOString(),
|
|
166
|
+
surface: { tws: 12, twd: 45, mslp: 1013, gust: 18 },
|
|
167
|
+
marine: {
|
|
168
|
+
hsCombined: 1.2,
|
|
169
|
+
tpCombined: 7,
|
|
170
|
+
dirCombined: 160,
|
|
171
|
+
},
|
|
172
|
+
current: { drift: 1, set: 45 },
|
|
173
|
+
})),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
const rows = hereHourly(payload, { waterline_length_m: 9.4 });
|
|
178
|
+
assert.equal(rows.length, 24, "24 hourly rows from fetchedAt");
|
|
179
|
+
assert.equal(rows[0].hoursFromNow, 0);
|
|
180
|
+
assert.equal(rows[0].sogKnots, 0);
|
|
181
|
+
// At SOG 0 AWS equals TWS
|
|
182
|
+
assert.equal(rows[0].awsKnots, 12);
|
|
183
|
+
assert.ok(typeof rows[0].azMs2 === "number");
|
|
184
|
+
assert.ok(typeof rows[0].comfortLevel === "string");
|
|
185
|
+
assert.ok(typeof rows[0].night === "boolean");
|
|
186
|
+
|
|
187
|
+
const now = hereNow(payload, rows);
|
|
188
|
+
assert.equal(now.twsKnots, 12);
|
|
189
|
+
assert.equal(now.gustKnots, 18);
|
|
190
|
+
assert.equal(now.hsMeters, 1.2);
|
|
191
|
+
assert.equal(now.currentDriftKnots, 1);
|
|
192
|
+
assert.equal(now.mslpHpa, 1013);
|
|
193
|
+
assert.equal(now.mslpTrend, 0);
|
|
194
|
+
assert.equal(now.comfortLevel, rows[0].comfortLevel);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await t.test("hereNow and hereHourly handle empty payloads", () => {
|
|
198
|
+
assert.deepEqual(hereHourly(null), []);
|
|
199
|
+
const now = hereNow(null);
|
|
200
|
+
assert.equal(now.comfortLevel, null);
|
|
201
|
+
assert.equal(now.twsKnots, null);
|
|
202
|
+
assert.equal(now.stamp, "");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await t.test("etaTable builds percentile rows and motor totals", () => {
|
|
206
|
+
const table = etaTable({
|
|
207
|
+
passageSummary: {
|
|
208
|
+
etaP10: "2026-06-22T12:00:00Z",
|
|
209
|
+
etaP50: "2026-06-22T13:00:00Z",
|
|
210
|
+
etaP90: "2026-06-22T14:30:00Z",
|
|
211
|
+
totalMotorHours: 40,
|
|
212
|
+
totalFuelGal: 32,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
table.rows.map((row) => row.label),
|
|
217
|
+
["P10", "P50", "P90"],
|
|
218
|
+
);
|
|
219
|
+
assert.equal(table.rows[2].stamp, "06-22 14:30Z");
|
|
220
|
+
assert.equal(table.motorHours, "1d 16h");
|
|
221
|
+
assert.equal(table.fuel, "32.0 gal");
|
|
222
|
+
assert.equal(etaTable(null).motorHours, "");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await t.test(
|
|
226
|
+
"sailWorkTimeline labels maneuvers and keeps changes plain",
|
|
227
|
+
() => {
|
|
228
|
+
const timeline = sailWorkTimeline({
|
|
229
|
+
passageSummary: {
|
|
230
|
+
sailChanges: [
|
|
231
|
+
{
|
|
232
|
+
hoursFromNow: 2,
|
|
233
|
+
timestamp: "2026-06-21T08:00:00.000Z",
|
|
234
|
+
sailState: "MAIN_REEF_1",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
hoursFromNow: 4,
|
|
238
|
+
timestamp: "2026-06-21T10:00:00.000Z",
|
|
239
|
+
sailState: "MAIN_FULL@port",
|
|
240
|
+
maneuver: "tack",
|
|
241
|
+
toTack: "port",
|
|
242
|
+
distanceFromStartNm: 32.4,
|
|
243
|
+
twsAtManeuver: 12.2,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
assert.equal(timeline.length, 2);
|
|
249
|
+
assert.equal(timeline[0].label, "MAIN_REEF_1");
|
|
250
|
+
assert.equal(timeline[0].detail, "");
|
|
251
|
+
assert.equal(timeline[1].label, "Tack to port");
|
|
252
|
+
assert.equal(timeline[1].detail, "32 nm · 12.2 kn");
|
|
253
|
+
assert.equal(timeline[1].stamp, "06-21 10:00Z");
|
|
254
|
+
assert.deepEqual(sailWorkTimeline(null), []);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for GMDSS zone resolution and bulletin sources (work
|
|
3
|
+
* doc #9): position/track fixtures → zone arrays, boundary
|
|
4
|
+
* straddling, antimeridian routes, roman numerals, GMDSS HTML `<pre>`
|
|
5
|
+
* extraction, TGFTP URL construction and the fetch ladder ordering.
|
|
6
|
+
*
|
|
7
|
+
* @file zone-source.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert/strict");
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
extractGmdssPre,
|
|
15
|
+
fetchZoneBulletins,
|
|
16
|
+
gmdssBulletinUrl,
|
|
17
|
+
navtexStation,
|
|
18
|
+
parseUkhoWarnings,
|
|
19
|
+
resolveZones,
|
|
20
|
+
romanNumeral,
|
|
21
|
+
tgftpUrl,
|
|
22
|
+
ukhoWarningsUrl,
|
|
23
|
+
} = require("../plugin/zone-source.js");
|
|
24
|
+
|
|
25
|
+
test("parseUkhoWarnings: canonical keys, tolerant fallbacks, unknown payload", () => {
|
|
26
|
+
const parsed = parseUkhoWarnings({
|
|
27
|
+
warnings: [
|
|
28
|
+
{
|
|
29
|
+
text: "CHART DAMAGE",
|
|
30
|
+
issuedAt: "2026-09-27T10:00:00Z",
|
|
31
|
+
coordinates: [
|
|
32
|
+
[-21.1, 175.2],
|
|
33
|
+
[-21.5, 175.8],
|
|
34
|
+
[-21.2, 176.1],
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
message: "DRIFTING CONTAINER",
|
|
39
|
+
created: "not-a-date",
|
|
40
|
+
positions: [[10, 20]],
|
|
41
|
+
},
|
|
42
|
+
{ text: "" },
|
|
43
|
+
null,
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
assert.equal(parsed.length, 2);
|
|
47
|
+
assert.equal(parsed[0].text, "CHART DAMAGE");
|
|
48
|
+
assert.equal(parsed[0].issuedAt, "2026-09-27T10:00:00.000Z");
|
|
49
|
+
assert.deepEqual(parsed[0].coordinates, [
|
|
50
|
+
[175.2, -21.1],
|
|
51
|
+
[175.8, -21.5],
|
|
52
|
+
[176.1, -21.2],
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(parsed[1].text, "DRIFTING CONTAINER");
|
|
55
|
+
assert.equal(parsed[1].issuedAt, null);
|
|
56
|
+
assert.deepEqual(parsed[1].coordinates, [[20, 10]]);
|
|
57
|
+
assert.deepEqual(parseUkhoWarnings({ nope: true }), []);
|
|
58
|
+
assert.deepEqual(parseUkhoWarnings(null), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("zone resolution: Tonga position resolves to XIV", () => {
|
|
62
|
+
assert.deepEqual(resolveZones([[-175.2, -21.1]]), [14]);
|
|
63
|
+
assert.deepEqual(resolveZones([[174.3, -35.3]]), [14]); // Opua, NZ
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("zone resolution: boundary crossing straddles X and XIV", () => {
|
|
67
|
+
// The X/XIV boundary sits at 160E: a route from the Tasman into
|
|
68
|
+
// Fijian waters legitimately activates both zones
|
|
69
|
+
const zones = resolveZones([
|
|
70
|
+
[155, -40],
|
|
71
|
+
[165, -38],
|
|
72
|
+
]);
|
|
73
|
+
assert.deepEqual(zones, [10, 14]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("zone resolution: antimeridian route stays in XIV", () => {
|
|
77
|
+
const zones = resolveZones([
|
|
78
|
+
[-179, -25],
|
|
79
|
+
[179, -30],
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(zones, [14]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("zone resolution: outside all bundled zones yields nothing", () => {
|
|
85
|
+
// Arctic latitudes: the polar NAVAREAs are not in the bundled file
|
|
86
|
+
assert.deepEqual(resolveZones([[0, 78]]), []);
|
|
87
|
+
// London resolves to zone I (UK / North Atlantic, 48-75N)
|
|
88
|
+
assert.deepEqual(resolveZones([[0, 51.5]]), [1]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("zone resolution: overlapping zones prefer the most specific", () => {
|
|
92
|
+
// 20N 50E sits in both VIII (to 30N) and IX (12-30N); the smaller
|
|
93
|
+
// polygon (IX, the Arabian Sea zone) wins for a single point
|
|
94
|
+
assert.deepEqual(resolveZones([[50, 20]]), [9]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- URLs & naming ----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
test("roman numerals and URL construction", () => {
|
|
100
|
+
assert.equal(romanNumeral(14), "XIV");
|
|
101
|
+
assert.equal(romanNumeral(10), "X");
|
|
102
|
+
assert.equal(romanNumeral(4), "IV");
|
|
103
|
+
assert.equal(romanNumeral(21), "XXI");
|
|
104
|
+
assert.equal(gmdssBulletinUrl(14), "https://weather.gmdss.org/XIV.html");
|
|
105
|
+
assert.equal(
|
|
106
|
+
ukhoWarningsUrl(14),
|
|
107
|
+
"https://msi.admiralty.co.uk/api/Warnings/Area/XIV",
|
|
108
|
+
);
|
|
109
|
+
assert.equal(
|
|
110
|
+
tgftpUrl("fqps01", "NFFN"),
|
|
111
|
+
"https://tgftp.nws.noaa.gov/data/raw/fq/fqps01.nffn..txt",
|
|
112
|
+
);
|
|
113
|
+
assert.equal(navtexStation(14), "Z"); // Wellington
|
|
114
|
+
assert.equal(navtexStation(10), "O"); // Sydney
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- GMDSS HTML extraction ------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
test("extractGmdssPre pulls the bulletin body with entities decoded", () => {
|
|
120
|
+
const html = `<!doctype html><html><head><title>XIV</title></head>
|
|
121
|
+
<body><h1>NAVAREA XIV</h1>
|
|
122
|
+
<pre>ZCZC GA14
|
|
123
|
+
011200Z AUG 26
|
|
124
|
+
GALE WARNING & ROUGH SEAS
|
|
125
|
+
NNNN</pre>
|
|
126
|
+
<footer>chrome</footer></body></html>`;
|
|
127
|
+
const text = extractGmdssPre(html);
|
|
128
|
+
assert.match(text, /^ZCZC GA14/);
|
|
129
|
+
assert.match(text, /GALE WARNING & ROUGH SEAS\nNNNN$/);
|
|
130
|
+
assert.equal(extractGmdssPre("<html><body>no pre</body></html>"), null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- Fetch ladder ------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function mockFetch(handlers) {
|
|
136
|
+
return async (url) => {
|
|
137
|
+
for (const [pattern, response] of Object.entries(handlers)) {
|
|
138
|
+
if (String(url).includes(pattern)) {
|
|
139
|
+
if (response instanceof Error) {
|
|
140
|
+
throw response;
|
|
141
|
+
}
|
|
142
|
+
return response(url);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`unexpected url ${url}`);
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("fetch ladder: TGFTP preferred when configured, portal fallback", async () => {
|
|
150
|
+
const calls = [];
|
|
151
|
+
const tgftpResponse = (url) => {
|
|
152
|
+
calls.push(`tgftp:${url}`);
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
text: async () => "FQPS01 NFFN 011200Z AUG 26\nGALE WARNING\nNNNN",
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Station configured: TGFTP answers, portal never called
|
|
160
|
+
let entries = await fetchZoneBulletins({
|
|
161
|
+
zones: [14],
|
|
162
|
+
tgftpStations: [{ zone: 14, header: "fqps01", station: "NFFN" }],
|
|
163
|
+
fetchImpl: mockFetch({
|
|
164
|
+
"tgftp.nws.noaa.gov": tgftpResponse,
|
|
165
|
+
"weather.gmdss.org": (url) => {
|
|
166
|
+
calls.push(`gmdss:${url}`);
|
|
167
|
+
throw new Error("should not be called");
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
assert.equal(entries.length, 1);
|
|
172
|
+
assert.equal(entries[0].source, "api");
|
|
173
|
+
assert.match(entries[0].text, /GALE WARNING/);
|
|
174
|
+
assert.equal(
|
|
175
|
+
calls.some((c) => c.startsWith("tgftp:")),
|
|
176
|
+
true,
|
|
177
|
+
);
|
|
178
|
+
assert.equal(
|
|
179
|
+
calls.some((c) => c.startsWith("gmdss:")),
|
|
180
|
+
false,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// TGFTP down: the portal takes over
|
|
184
|
+
calls.length = 0;
|
|
185
|
+
entries = await fetchZoneBulletins({
|
|
186
|
+
zones: [14],
|
|
187
|
+
tgftpStations: [{ zone: 14, header: "fqps01", station: "NFFN" }],
|
|
188
|
+
fetchImpl: mockFetch({
|
|
189
|
+
"tgftp.nws.noaa.gov": new Error("connection refused"),
|
|
190
|
+
"weather.gmdss.org": (url) => {
|
|
191
|
+
calls.push(`gmdss:${url}`);
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
text: async () =>
|
|
195
|
+
"<html><pre>ZCZC GA14 011200Z AUG 26 NAVAREA XIV 114/26 GALE WARNING</pre></html>",
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
assert.equal(entries.length, 1);
|
|
201
|
+
assert.match(entries[0].text, /GALE WARNING/);
|
|
202
|
+
assert.equal(
|
|
203
|
+
calls.some((c) => c.startsWith("gmdss:")),
|
|
204
|
+
true,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("fetch ladder: only resolved zones are fetched", async () => {
|
|
209
|
+
const fetched = [];
|
|
210
|
+
await fetchZoneBulletins({
|
|
211
|
+
zones: [10, 14],
|
|
212
|
+
fetchImpl: mockFetch({
|
|
213
|
+
"weather.gmdss.org": (url) => {
|
|
214
|
+
fetched.push(String(url));
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
text: async () => "<html><pre>SYNOPSIS</pre></html>",
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
// XIV and X fetched; the other nineteen zones untouched
|
|
223
|
+
assert.equal(fetched.length, 2);
|
|
224
|
+
assert.ok(fetched.some((u) => u.includes("XIV.html")));
|
|
225
|
+
assert.ok(fetched.some((u) => u.includes("X.html")));
|
|
226
|
+
});
|