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