@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,194 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const {
7
+ buildManifest,
8
+ registerPlotterExtension,
9
+ staticAssetHandler,
10
+ } = require("../plugin/brief-ext.js");
11
+ const { tileModel, briefAge } = require("../public/brief-ext-model.js");
12
+
13
+ const PLUGIN_ID = "signalk-passage-briefing";
14
+ const ASSET_BASE = `/plotterext/${PLUGIN_ID}`;
15
+ const PUBLIC_DIR = path.join(__dirname, "..", "public");
16
+
17
+ describe("plotter-extension manifest & provider (doc #8)", () => {
18
+ test("manifest targets plotter extension API v1", () => {
19
+ const m = buildManifest(ASSET_BASE, "1.2.3");
20
+ assert.equal(m.apiVersion, "1");
21
+ assert.equal(m.name, "Passage Briefing");
22
+ assert.equal(m.version, "1.2.3");
23
+ assert.ok(m.description.length > 0);
24
+ });
25
+
26
+ test("manifest requires the widget grid and the SK relay", () => {
27
+ const m = buildManifest(ASSET_BASE, "1.2.3");
28
+ assert.ok(m.requires.includes("widgets"));
29
+ assert.ok(m.requires.includes("signalk.stream"));
30
+ for (const cap of m.optional ?? []) {
31
+ assert.ok(!m.requires.includes(cap));
32
+ }
33
+ });
34
+
35
+ test("manifest contributes a valid 1x1 iframe widget", () => {
36
+ const m = buildManifest(ASSET_BASE, "1.2.3");
37
+ assert.equal(m.widgets.length, 1);
38
+ const [w] = m.widgets;
39
+ assert.equal(w.id, "passage-brief-tile");
40
+ assert.equal(w.type, "iframe");
41
+ assert.equal(w.size, "1x1");
42
+ assert.equal(w.lifecycle, "whileEnabled");
43
+ assert.ok(w.url.startsWith(`${ASSET_BASE}/`));
44
+ });
45
+
46
+ test("provider serves the manifest and empties on teardown", async () => {
47
+ const providers = [];
48
+ const mounts = [];
49
+ const app = {
50
+ registerResourceProvider: (p) => providers.push(p),
51
+ use: (prefix, handler) => mounts.push({ prefix, handler }),
52
+ };
53
+ const teardown = registerPlotterExtension(app, { id: PLUGIN_ID });
54
+ assert.equal(providers.length, 1);
55
+ assert.equal(providers[0].type, "plotterExtensions");
56
+ assert.equal(mounts[0].prefix, ASSET_BASE);
57
+
58
+ const manifest = await providers[0].methods.getResource(PLUGIN_ID);
59
+ assert.equal(manifest.name, "Passage Briefing");
60
+ const listing = await providers[0].methods.listResources();
61
+ assert.deepEqual(Object.keys(listing), [PLUGIN_ID]);
62
+
63
+ // Read-only
64
+ await assert.rejects(
65
+ () => providers[0].methods.setResource(PLUGIN_ID, {}),
66
+ /read-only/,
67
+ );
68
+
69
+ teardown();
70
+ assert.deepEqual(
71
+ await providers[0].methods.listResources(),
72
+ {},
73
+ "provider empty after teardown",
74
+ );
75
+ await assert.rejects(() => providers[0].methods.getResource(PLUGIN_ID));
76
+ });
77
+
78
+ test("static handler serves known assets with traversal guard", () => {
79
+ const handler = staticAssetHandler(PUBLIC_DIR, ASSET_BASE);
80
+ const next = () => {
81
+ throw new Error("should have been handled");
82
+ };
83
+
84
+ const serve = (urlPath) =>
85
+ new Promise((resolve) => {
86
+ const chunks = [];
87
+ const res = {
88
+ statusCode: 0,
89
+ headers: {},
90
+ setHeader(k, v) {
91
+ this.headers[k] = v;
92
+ },
93
+ end(data) {
94
+ chunks.push(data);
95
+ resolve({
96
+ status: this.statusCode,
97
+ type: this.headers["Content-Type"],
98
+ body: Buffer.concat(chunks),
99
+ });
100
+ },
101
+ };
102
+ handler({ path: urlPath }, res, next);
103
+ });
104
+
105
+ const fallthrough = (urlPath) =>
106
+ new Promise((resolve) => {
107
+ const res = {
108
+ statusCode: 0,
109
+ setHeader() {},
110
+ end() {},
111
+ };
112
+ handler({ path: urlPath }, res, () => resolve(true));
113
+ // If neither end nor next fired within a tick, resolve false
114
+ setTimeout(() => resolve(false), 50);
115
+ });
116
+
117
+ return (async () => {
118
+ const widget = await serve(`${ASSET_BASE}/brief-ext-widget.html`);
119
+ assert.equal(widget.status, 200);
120
+ assert.match(widget.type, /text\/html/);
121
+ assert.ok(widget.body.includes("brief-ext-widget"));
122
+
123
+ // Traversal attempt falls through, never escapes the root
124
+ assert.ok(
125
+ await fallthrough(`${ASSET_BASE}/../plugin/index.js`),
126
+ "traversal attempt falls through",
127
+ );
128
+
129
+ // Unknown file falls through
130
+ assert.ok(await fallthrough(`${ASSET_BASE}/nope.js`));
131
+ })();
132
+ });
133
+ });
134
+
135
+ describe("brief tile model (doc #8)", () => {
136
+ const NOW = new Date("2026-09-27T12:00:00Z");
137
+
138
+ test("no compiled brief renders muted with no badge", () => {
139
+ const model = tileModel(
140
+ { generatedAt: null, route: null, hasNew: null },
141
+ NOW,
142
+ );
143
+ assert.equal(model.severity, "muted");
144
+ assert.equal(model.badge, false);
145
+ assert.equal(model.title, "No brief");
146
+ assert.equal(briefAge(null, NOW), "");
147
+ });
148
+
149
+ test("available brief shows route name and age", () => {
150
+ const model = tileModel({
151
+ generatedAt: "2026-09-27T09:00:00Z",
152
+ route: "Tonga to Opua",
153
+ hasNew: false,
154
+ now: NOW,
155
+ });
156
+ assert.equal(model.severity, "ok");
157
+ assert.equal(model.badge, false);
158
+ assert.equal(model.title, "Tonga to Opua");
159
+ assert.equal(model.detail, "3 h old");
160
+ });
161
+
162
+ test("new brief flags the badge until acknowledged", () => {
163
+ const model = tileModel({
164
+ generatedAt: "2026-09-27T11:45:00Z",
165
+ route: "",
166
+ hasNew: true,
167
+ now: NOW,
168
+ });
169
+ assert.equal(model.severity, "new");
170
+ assert.equal(model.badge, true);
171
+ // Here mode: empty route falls back to the generic title
172
+ assert.equal(model.title, "Passage brief");
173
+ assert.equal(model.detail, "15 m old");
174
+ });
175
+
176
+ test("age buckets minutes, hours and days", () => {
177
+ assert.equal(briefAge("2026-09-27T11:48:00Z", NOW), "12 m old");
178
+ assert.equal(briefAge("2026-09-27T06:00:00Z", NOW), "6 h old");
179
+ assert.equal(briefAge("2026-09-25T12:00:00Z", NOW), "2 d old");
180
+ });
181
+ });
182
+
183
+ // The static-handler test asserts against a real file; make sure the
184
+ // asset it needs actually shipped.
185
+ assert.ok(
186
+ fs.existsSync(path.join(PUBLIC_DIR, "brief-ext-widget.html")),
187
+ "widget HTML exists in public/",
188
+ );
189
+ assert.ok(
190
+ fs.existsSync(
191
+ path.join(PUBLIC_DIR, "vendor", "plotterext-bus", "extension.js"),
192
+ ),
193
+ "vendored bus client exists",
194
+ );
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Unit tests for the NAVAREA/NAVTEX filtering engine (work doc #4)
3
+ * and the internet bulletin source: boilerplate stripping, NAVTEX
4
+ * subject filtering, GMDSS segmentation, geographic extraction with
5
+ * antimeridian handling, the track discard rule, and cache/gating.
6
+ *
7
+ * @file bulletin-engine.test.js
8
+ */
9
+
10
+ const test = require("node:test");
11
+ const assert = require("node:assert/strict");
12
+ const { mkdtempSync, writeFileSync } = require("node:fs");
13
+ const { tmpdir } = require("node:os");
14
+ const { join } = require("node:path");
15
+
16
+ const {
17
+ filterBulletin,
18
+ navtexSubject,
19
+ parseCardinalBounds,
20
+ parseCoordinateChain,
21
+ segmentBlocks,
22
+ shouldRetainSubject,
23
+ stripBoilerplate,
24
+ bboxIntersectsTrack,
25
+ ringContains,
26
+ ukhoBlocksFromWarnings,
27
+ } = require("../plugin/bulletin-engine.js");
28
+ const {
29
+ loadBulletinCache,
30
+ refreshBulletins,
31
+ saveBulletinCache,
32
+ } = require("../plugin/bulletin-source.js");
33
+
34
+ const approx = (actual, expected, epsilon = 1e-6) =>
35
+ assert.ok(
36
+ Math.abs(actual - expected) < epsilon,
37
+ `expected ${actual} ≈ ${expected} (±${epsilon})`,
38
+ );
39
+
40
+ /** A realistic NAVAREA XIV gale warning, South Pacific (doc #4). */
41
+ const NAVAREA_FIXTURE = `FQPS01 NFFN 011200Z AUG 26
42
+ ZCZC GA14
43
+ 011200Z AUG 26
44
+ NAVAREA XIV 114/26
45
+ SOUTH PACIFIC FIJI WATERS
46
+ GALE WARNING
47
+ PART 1 WARNING
48
+ DEVELOPING TROUGH T1 WITH SQUALLS AND GALES WITHIN 120NM
49
+ EAST OF AXIS 16S 170E TO 20S 178W TO 25S 175W AT 011200Z.
50
+ EXPECT WINDS 35 KNOTS. ROUGH SEAS WITH HEAVY SWELLS.
51
+ PARTS 2 AND 3 SYNOPSIS AND FORECAST
52
+ SITUATION IS MODERATE OVER REMAINDER WATERS.
53
+ NNNN`;
54
+
55
+ /** Ice report — NAVTEX subject C, must be discarded. */
56
+ const ICE_FIXTURE = `ZCZC CC12
57
+ 030800Z AUG 26
58
+ NAVAREA XIV 201/26
59
+ ICE REPORTS SOUTHERN OCEAN. BERGS DRIFTING NORTH.
60
+ NNNN`;
61
+
62
+ /**
63
+ * Tonga → Opua track samples, GeoJSON [lon, lat] order — crosses
64
+ * the antimeridian.
65
+ */
66
+ const TONGA_TRACK = [
67
+ [-175.2, -21.1],
68
+ [-179.0, -25.0],
69
+ [179.0, -30.0],
70
+ [174.3, -35.3],
71
+ ];
72
+
73
+ // --- Tokenizer ------------------------------------------------------------
74
+
75
+ test("stripBoilerplate removes ZCZC/NNNN and routing headers", () => {
76
+ const cleaned = stripBoilerplate(NAVAREA_FIXTURE);
77
+ assert.equal(cleaned.includes("FQPS01 NFFN"), false);
78
+ assert.equal(cleaned.includes("NNNN"), false);
79
+ // The NAVTEX id line stays (the subject filter reads it)
80
+ assert.match(cleaned, /ZCZC GA14/);
81
+ assert.match(cleaned, /GALE WARNING/);
82
+ });
83
+
84
+ test("NAVTEX B_2 subject filtering: C dropped, A and E retained", () => {
85
+ // ZCZC GA14: B_1 = G (Nadi station), B_2 = A (nav warnings)
86
+ assert.equal(navtexSubject(NAVAREA_FIXTURE), "A");
87
+ assert.equal(navtexSubject(ICE_FIXTURE), "C");
88
+ assert.equal(shouldRetainSubject("C"), false);
89
+ assert.equal(shouldRetainSubject("A"), true);
90
+ assert.equal(shouldRetainSubject("E"), true);
91
+ assert.equal(shouldRetainSubject(null), true); // Not NAVTEX: keep
92
+ assert.equal(filterBulletin({ rawText: ICE_FIXTURE, source: "api" }), null);
93
+ });
94
+
95
+ test("segmentBlocks anchors on PART headings and blank lines", () => {
96
+ const cleaned = stripBoilerplate(NAVAREA_FIXTURE);
97
+ const blocks = segmentBlocks(cleaned);
98
+ assert.ok(blocks.length >= 3, `blocks ${blocks.length}`);
99
+ const warning = blocks.find((b) => b.includes("PART 1 WARNING"));
100
+ assert.ok(warning, "warning section found");
101
+ assert.match(warning, /DEVELOPING TROUGH/);
102
+ });
103
+
104
+ // --- Geographic extraction ---------------------------------------------------
105
+
106
+ test("coordinate chains parse with hemisphere letters and closing", () => {
107
+ const ring = parseCoordinateChain(
108
+ "EAST OF AXIS 16S 170E TO 20S 178W TO 25S 175W",
109
+ );
110
+ assert.equal(ring.length, 4); // Closed automatically
111
+ approx(ring[0][0], 170); // 170E
112
+ approx(ring[0][1], -16); // 16S
113
+ approx(ring[1][0], -178); // 178W — antimeridian west side
114
+ approx(ring[2][0], -175);
115
+ approx(ring[3][0], 170); // Ring closed back to start
116
+ });
117
+
118
+ test("two coordinate pairs form an open axis line", () => {
119
+ // NFFN trough style: lat-first pairs, no TO separators
120
+ const line = parseCoordinateChain("TROUGH T1 10S 160E 12S 166E SLOW MOVING");
121
+ assert.equal(line.length, 2);
122
+ approx(line[0][0], 160);
123
+ approx(line[0][1], -10);
124
+ approx(line[1][0], 166);
125
+ approx(line[1][1], -12);
126
+ assert.equal(parseCoordinateChain("10S 160E"), null); // single pair
127
+ });
128
+
129
+ test("cardinal bounds translate to a bbox", () => {
130
+ // SOUTH OF x means lat ≤ x: the box extends to the south pole
131
+ assert.deepEqual(
132
+ parseCardinalBounds("SOUTH OF 12S AND WEST OF 173W"),
133
+ [-180, -90, -173, -12],
134
+ );
135
+ // NORTH OF x means lat ≥ x: the box extends to the north pole
136
+ assert.deepEqual(
137
+ parseCardinalBounds("NORTH OF 25S AND EAST OF 179E"),
138
+ [179, -25, 180, 90],
139
+ );
140
+ assert.equal(parseCardinalBounds("no geography here"), null);
141
+ });
142
+
143
+ test("live NFFN bulletin: axis lines band-filtered, cardinal areas kept", () => {
144
+ // Real FQPS01 NFFN text as received on board 2026-09-27, vessel at
145
+ // anchor 18.7S 174W: T1/T2 axis bands lie well north (dropped), the
146
+ // "south of 10S and west of 169W" area contains the vessel (kept)
147
+ const text = [
148
+ "FQPS01 NFFN 270700",
149
+ "MARINE WEATHER BULLETIN FOR ISLANDS AREA",
150
+ "EQUATOR TO 25S BETWEEN 160E AND 120W.",
151
+ "ISSUED BY FIJI METEOROLOGICAL SERVICE SEP 270800 UTC.",
152
+ "",
153
+ "PART 1 : WARNINGNIL.",
154
+ "",
155
+ "PARTS 2 AND 3 : SYNOPSIS AND FORECAST VALID UNTIL SEP 280600 UTC.",
156
+ "",
157
+ " TROUGH T1 10S 160E 12S 166E SLOW MOVING. POOR VISIBILITY IN",
158
+ "OCCASIONAL SHOWERS AND FEW THUNDERSTORMS WITHIN 100 NAUTICAL MILES OF",
159
+ "T1.",
160
+ "",
161
+ "TROUGH T2 08S 172E 11S 179W 13S 173W SLOW MOVING. POOR VISIBILITY IN",
162
+ "OCCASIONAL SHOWERS AND FEW THUNDERSTORMS WITHIN 120 NAUTICAL MILES OF",
163
+ "T2.",
164
+ "",
165
+ "IN THE AREA SOUTH OF 10S AND WEST OF 169W, EXPECT SOUTHEAST WINDS 25",
166
+ "TO 30 KNOTS. ROUGH TO VERY ROUGH SEAS. MODERATE TO HEAVY SOUTH TO",
167
+ "SOUTHEAST SWELLS. ",
168
+ ].join("\n");
169
+ const bulletin = filterBulletin({
170
+ rawText: text,
171
+ source: "api",
172
+ track: [[-173.982, -18.658]],
173
+ });
174
+ const texts = bulletin.blocks.map((b) => b.text);
175
+ assert.ok(
176
+ texts.some((t) => t.includes("SOUTHEAST WINDS 25")),
177
+ "area containing the vessel kept",
178
+ );
179
+ assert.ok(!texts.some((t) => t.includes("TROUGH T1")), "T1 band dropped");
180
+ assert.ok(!texts.some((t) => t.includes("TROUGH T2")), "T2 band dropped");
181
+ });
182
+
183
+ // --- Antimeridian -------------------------------------------------------------
184
+
185
+ /** Fiji-waters rectangle crossing the seam: 170E → 178W, 16S..25S. */
186
+ const SEAM_BOX = parseCardinalBoundsWrapper();
187
+ function parseCardinalBoundsWrapper() {
188
+ const { parseCoordinateChain } = require("../plugin/bulletin-engine.js");
189
+ return parseCoordinateChain("16S 170E TO 16S 178W TO 25S 178W TO 25S 170E");
190
+ }
191
+
192
+ test("ringContains handles polygons spanning 180", () => {
193
+ const ring = SEAM_BOX;
194
+ // Inside on the west side (179W is between 180 and the 178W edge)
195
+ assert.equal(ringContains(ring, -179, -20), true);
196
+ // Inside on the eastern (170E..180) side
197
+ assert.equal(ringContains(ring, 171, -20), true);
198
+ // Outside: 177W is east of the box's 178W edge
199
+ assert.equal(ringContains(ring, -177, -20), false);
200
+ // Outside: before 170E
201
+ assert.equal(ringContains(ring, 169, -20), false);
202
+ // Outside: well beyond the seam
203
+ assert.equal(ringContains(ring, -160, -20), false);
204
+ });
205
+
206
+ test("bbox intersection wraps around the antimeridian", () => {
207
+ // EAST OF 179E AND WEST OF 173W: wraps from +179 through 180 to −173
208
+ const bbox = parseCardinalBounds("EAST OF 179E AND WEST OF 173W");
209
+ assert.deepEqual(bbox, [179, -90, 187, 90]); // maxLon unwrapped
210
+ // Track point at 178E: west of the 179E bound → outside
211
+ assert.equal(bboxIntersectsTrack(bbox, [[178, -20]]), false);
212
+ // Track points through the seam: 180E and 179W (181 unwrapped)
213
+ assert.equal(bboxIntersectsTrack(bbox, [[180, -20]]), true);
214
+ assert.equal(bboxIntersectsTrack(bbox, [[-179, -20]]), true);
215
+ assert.equal(bboxIntersectsTrack(bbox, [[-175, -20]]), true);
216
+ // Track point at 150E: outside the box either way
217
+ assert.equal(bboxIntersectsTrack(bbox, [[150, -20]]), false);
218
+ });
219
+
220
+ // --- Track discard rule ---------------------------------------------------------
221
+
222
+ test("filterBulletin retains the gale warning end-to-end", () => {
223
+ // Subject A (navigational warning) is retained by default
224
+ const result = filterBulletin({
225
+ rawText: NAVAREA_FIXTURE,
226
+ source: "spool",
227
+ track: TONGA_TRACK,
228
+ issuedAt: "2026-08-01T12:00:00.000Z",
229
+ });
230
+ assert.ok(result, "subject A retained");
231
+ assert.equal(result.source, "spool");
232
+ assert.equal(result.issuedAt, "2026-08-01T12:00:00.000Z");
233
+ assert.ok(result.blocks.length > 0);
234
+ const trough = result.blocks.find((b) => b.text.includes("TROUGH"));
235
+ assert.ok(trough, "trough block kept");
236
+ assert.equal(trough.geometryType, "polygon");
237
+ assert.equal(trough.subject, "A");
238
+ });
239
+
240
+ test("retained bulletin: blocks filtered against the Tonga track", () => {
241
+ const result = filterBulletin({
242
+ rawText: NAVAREA_FIXTURE,
243
+ source: "spool",
244
+ track: TONGA_TRACK,
245
+ issuedAt: "2026-08-01T12:00:00.000Z",
246
+ retainedSubjects: "ABEG",
247
+ });
248
+ assert.ok(result, "retained with G in the set");
249
+
250
+ // The far-away synopsis has no geography: kept conservatively
251
+ const synopsis = result.blocks.find((b) => b.text.includes("SITUATION"));
252
+ assert.ok(synopsis, "no-geometry block kept");
253
+ assert.equal(synopsis.geometryType, null);
254
+ });
255
+ test("ukhoBlocksFromWarnings: structured geometry vs track, point bbox", () => {
256
+ const warnings = [
257
+ {
258
+ text: "BUOY OFF STATION NEAR OPUA",
259
+ issuedAt: "2026-09-27T10:00:00Z",
260
+ coordinates: [
261
+ [174.0, -35.0],
262
+ [174.6, -35.4],
263
+ [174.2, -35.8],
264
+ ],
265
+ },
266
+ {
267
+ text: "FAR SHORE EVENT",
268
+ issuedAt: null,
269
+ coordinates: [
270
+ [0, 0],
271
+ [1, 0],
272
+ [1, 1],
273
+ ],
274
+ },
275
+ { text: "POINT HAZARD", issuedAt: null, coordinates: [[174.31, -35.31]] },
276
+ ];
277
+ const track = [
278
+ [174.3, -35.3],
279
+ [174.9, -35.6],
280
+ ];
281
+ const blocks = ukhoBlocksFromWarnings(warnings, track);
282
+ assert.deepEqual(
283
+ blocks.map((b) => b.text),
284
+ ["BUOY OFF STATION NEAR OPUA", "POINT HAZARD"],
285
+ );
286
+ assert.equal(blocks[0].source, "ukho");
287
+ assert.equal(blocks[0].geometryType, "polygon");
288
+ assert.equal(blocks[1].geometryType, "bbox");
289
+ assert.equal(blocks[1].subject, null);
290
+ });
291
+
292
+ test("discard rule drops geographically irrelevant warnings", () => {
293
+ // Same warning re-anchored to the Atlantic: nowhere near the track
294
+ const atlantic = NAVAREA_FIXTURE.replace(
295
+ "16S 170E TO 20S 178W TO 25S 175W",
296
+ "40N 020W TO 45N 030W TO 38N 035W",
297
+ );
298
+ const result = filterBulletin({
299
+ rawText: atlantic,
300
+ source: "api",
301
+ track: TONGA_TRACK,
302
+ retainedSubjects: "ABEG",
303
+ });
304
+ const trough = result.blocks.find((b) => b.text.includes("TROUGH"));
305
+ assert.equal(trough, undefined, "Atlantic trough dropped for Pacific track");
306
+ assert.ok(result.blocks.some((b) => b.text.includes("SITUATION")));
307
+ });
308
+
309
+ // --- Bulletin source ----------------------------------------------------------
310
+
311
+ test("bulletin cache round-trips and prunes to the newest", async () => {
312
+ const dir = mkdtempSync(join(tmpdir(), "bulletin-cache-"));
313
+ assert.deepEqual(await loadBulletinCache(dir), []);
314
+
315
+ const entries = [
316
+ { url: "https://x/a.txt", fetchedAt: "2026-08-01T00:00:00Z", text: "A" },
317
+ { url: "https://x/b.txt", fetchedAt: "2026-08-02T00:00:00Z", text: "B" },
318
+ ];
319
+ await saveBulletinCache(dir, entries);
320
+ const loaded = await loadBulletinCache(dir);
321
+ assert.equal(loaded.length, 2);
322
+ assert.equal(loaded[0].text, "B"); // Newest first
323
+
324
+ // Corrupt file → empty, not a crash
325
+ writeFileSync(join(dir, "weather", "bulletins.json"), "{oops");
326
+ assert.deepEqual(await loadBulletinCache(dir), []);
327
+ });
328
+
329
+ test("refreshBulletins fetches online sources, skips failures", async () => {
330
+ const dir = mkdtempSync(join(tmpdir(), "bulletin-fetch-"));
331
+ const fetchImpl = async (url) => {
332
+ if (String(url).includes("broken")) {
333
+ throw new Error("connection refused");
334
+ }
335
+ return { ok: true, text: async () => NAVAREA_FIXTURE };
336
+ };
337
+ const { fetched, failed, entries } = await refreshBulletins({
338
+ dataDir: dir,
339
+ urls: ["https://met/a.txt", "https://met/broken.txt"],
340
+ fetchImpl,
341
+ });
342
+ assert.deepEqual(fetched, ["https://met/a.txt"]);
343
+ assert.deepEqual(failed, ["https://met/broken.txt"]);
344
+ assert.equal(entries.length, 1);
345
+
346
+ // Persisted and re-loadable
347
+ const loaded = await loadBulletinCache(dir);
348
+ assert.equal(loaded.length, 1);
349
+ assert.match(loaded[0].text, /GALE WARNING/);
350
+
351
+ // A second refresh failure keeps the cached copy
352
+ const second = await refreshBulletins({
353
+ dataDir: dir,
354
+ urls: ["https://met/a.txt"],
355
+ fetchImpl: async () => {
356
+ throw new Error("offline now");
357
+ },
358
+ });
359
+ assert.deepEqual(second.fetched, []);
360
+ assert.equal((await loadBulletinCache(dir)).length, 1);
361
+ });