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