@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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Status Tiles example-set provider (work doc #13):
|
|
3
|
+
* the bundled JSON parses and has the expected shape, the provider
|
|
4
|
+
* lists while running and empties when stopped, and it is
|
|
5
|
+
* read-only.
|
|
6
|
+
*
|
|
7
|
+
* @file statustilesexamples.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert/strict");
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
registerStatusTileExamples,
|
|
15
|
+
EXAMPLES,
|
|
16
|
+
} = require("../plugin/statustilesexamples.js");
|
|
17
|
+
|
|
18
|
+
test("bundled example set parses and references published paths", () => {
|
|
19
|
+
assert.equal(EXAMPLES.name, "Passage Briefing examples");
|
|
20
|
+
const set = EXAMPLES.sets[0];
|
|
21
|
+
assert.equal(set.id, "passage-comfort");
|
|
22
|
+
const tile = set.tiles[0];
|
|
23
|
+
assert.equal(tile.size, "1x1");
|
|
24
|
+
const types = tile.checks.map((c) => c.type);
|
|
25
|
+
assert.deepEqual(types, ["stateMatch", "boolean"]);
|
|
26
|
+
const paths = tile.checks.map((c) => c.path);
|
|
27
|
+
assert.ok(paths.includes("navigation.briefing.comfort"));
|
|
28
|
+
assert.ok(paths.includes("navigation.briefing.stale"));
|
|
29
|
+
const comfortCheck = tile.checks[0];
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
comfortCheck.map.map((m) => m.value),
|
|
32
|
+
["champagne", "easy", "coffee", "rough", "sick"],
|
|
33
|
+
);
|
|
34
|
+
const footerPaths = tile.footer.map((f) => f.path);
|
|
35
|
+
assert.ok(footerPaths.includes("navigation.briefing.ageHours"));
|
|
36
|
+
assert.ok(footerPaths.includes("navigation.briefing.route"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("provider lists while running, empties when stopped, read-only", async () => {
|
|
40
|
+
const registered = [];
|
|
41
|
+
const errors = [];
|
|
42
|
+
const app = {
|
|
43
|
+
registerResourceProvider: (provider) => registered.push(provider),
|
|
44
|
+
error: (m) => errors.push(m),
|
|
45
|
+
};
|
|
46
|
+
const teardown = registerStatusTileExamples(app, {
|
|
47
|
+
id: "signalk-passage-briefing",
|
|
48
|
+
});
|
|
49
|
+
assert.equal(registered.length, 1);
|
|
50
|
+
const provider = registered[0];
|
|
51
|
+
assert.equal(provider.type, "statusTileExamples");
|
|
52
|
+
|
|
53
|
+
const listed = await provider.methods.listResources();
|
|
54
|
+
assert.ok(listed["signalk-passage-briefing"]);
|
|
55
|
+
const resource = await provider.methods.getResource(
|
|
56
|
+
"signalk-passage-briefing",
|
|
57
|
+
);
|
|
58
|
+
assert.equal(resource.sets[0].id, "passage-comfort");
|
|
59
|
+
await assert.rejects(() => provider.methods.getResource("some-other-plugin"));
|
|
60
|
+
await assert.rejects(() => provider.methods.setResource("x", {}));
|
|
61
|
+
await assert.rejects(() => provider.methods.deleteResource("x"));
|
|
62
|
+
|
|
63
|
+
teardown();
|
|
64
|
+
assert.deepEqual(await provider.methods.listResources(), {});
|
|
65
|
+
await assert.rejects(() =>
|
|
66
|
+
provider.methods.getResource("signalk-passage-briefing"),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("re-start reuses the registration instead of stacking", async () => {
|
|
71
|
+
const registered = [];
|
|
72
|
+
const app = {
|
|
73
|
+
registerResourceProvider: (p) => registered.push(p),
|
|
74
|
+
error: () => {},
|
|
75
|
+
};
|
|
76
|
+
const teardown = registerStatusTileExamples(app, {
|
|
77
|
+
id: "signalk-passage-briefing",
|
|
78
|
+
});
|
|
79
|
+
const teardown2 = registerStatusTileExamples(app, {
|
|
80
|
+
id: "signalk-passage-briefing",
|
|
81
|
+
});
|
|
82
|
+
assert.equal(registered.length, 1); // Idempotent
|
|
83
|
+
teardown();
|
|
84
|
+
teardown2();
|
|
85
|
+
assert.deepEqual(await registered[0].methods.listResources(), {});
|
|
86
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the synoptic surface-analysis chart source (work
|
|
3
|
+
* doc #11): zone+hour URL selection with 12Z boundaries, static
|
|
4
|
+
* filenames, missing zones, TIFF→PNG conversion via the vendored
|
|
5
|
+
* UTIF, cache/skip behaviour, and offline reads.
|
|
6
|
+
*
|
|
7
|
+
* @file synoptic-source.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { test, describe } = require("node:test");
|
|
11
|
+
const assert = require("node:assert/strict");
|
|
12
|
+
const { mkdtempSync, existsSync } = require("node:fs");
|
|
13
|
+
const { tmpdir } = require("node:os");
|
|
14
|
+
const { join } = require("node:path");
|
|
15
|
+
|
|
16
|
+
const UTIF = require("../public/vendor/utif/UTIF.js");
|
|
17
|
+
const {
|
|
18
|
+
chartUrlForZone,
|
|
19
|
+
chartsForZones,
|
|
20
|
+
loadSynoptic,
|
|
21
|
+
loadSynopticMap,
|
|
22
|
+
refreshSynoptics,
|
|
23
|
+
} = require("../plugin/synoptic-source.js");
|
|
24
|
+
const { convertChart, detectFormat } = require("../plugin/raster-convert.js");
|
|
25
|
+
|
|
26
|
+
function dataDir() {
|
|
27
|
+
return mkdtempSync(join(tmpdir(), "synoptic-"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Builds a tiny uncompressed RGBA TIFF as agencies would ship. */
|
|
31
|
+
function fixtureTif(width = 8, height = 4) {
|
|
32
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
33
|
+
for (let i = 0; i < width * height; i++) {
|
|
34
|
+
const value = i % 3 === 0 ? 0 : 255; // black/white line art
|
|
35
|
+
rgba[i * 4] = value;
|
|
36
|
+
rgba[i * 4 + 1] = value;
|
|
37
|
+
rgba[i * 4 + 2] = value;
|
|
38
|
+
rgba[i * 4 + 3] = 255;
|
|
39
|
+
}
|
|
40
|
+
return Buffer.from(UTIF.encodeImage(rgba, width, height));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("chart URL selection", () => {
|
|
44
|
+
const map = loadSynopticMap();
|
|
45
|
+
|
|
46
|
+
test("hour boundaries pick 00Z before noon, 12Z from noon", () => {
|
|
47
|
+
const map2 = {
|
|
48
|
+
14: {
|
|
49
|
+
name: "x",
|
|
50
|
+
source: "t",
|
|
51
|
+
hours: {
|
|
52
|
+
"00": "http://a/00.TIF",
|
|
53
|
+
12: "http://a/12.TIF",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const before = chartUrlForZone(map2, 14, new Date("2026-09-27T11:59:00Z"));
|
|
58
|
+
const at = chartUrlForZone(map2, 14, new Date("2026-09-27T12:00:00Z"));
|
|
59
|
+
assert.equal(before.url ?? before.urls[0], "http://a/00.TIF");
|
|
60
|
+
assert.equal(before.validHour, "00");
|
|
61
|
+
assert.equal(at.urls[0], "http://a/12.TIF");
|
|
62
|
+
assert.equal(at.validHour, "12");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("zone 14 ladder: MetService first, NOAA then BoM fallbacks", () => {
|
|
66
|
+
const at = chartUrlForZone(map, 14, new Date("2026-09-27T12:00:00Z"));
|
|
67
|
+
assert.equal(at.validHour, "12");
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
at.urls.map((u) => new URL(u).host),
|
|
70
|
+
["alerts.metservice.com", "tgftp.nws.noaa.gov", "tgftp.nws.noaa.gov"],
|
|
71
|
+
);
|
|
72
|
+
assert.match(at.urls[1], /PPBA89\.TIF$/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("static filenames skip the hour logic", () => {
|
|
76
|
+
const pick = chartUrlForZone(map, 11, new Date("2026-09-27T18:00:00Z"));
|
|
77
|
+
assert.match(pick.urls[0], /PJAA99\.TIF$/);
|
|
78
|
+
assert.equal(pick.validHour, "static");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("missing hour variant falls back, missing zones resolve-and-skip", () => {
|
|
82
|
+
const map2 = {
|
|
83
|
+
3: { name: "x", source: "t", hours: { "00": "http://a/00.TIF" } },
|
|
84
|
+
};
|
|
85
|
+
// From 12Z with no 12Z variant: falls back to the 00Z chart
|
|
86
|
+
const pick = chartUrlForZone(map2, 3, new Date("2026-09-27T18:00:00Z"));
|
|
87
|
+
assert.deepEqual(pick.urls, ["http://a/00.TIF"]);
|
|
88
|
+
assert.equal(pick.validHour, "00");
|
|
89
|
+
assert.equal(chartUrlForZone(map2, 14), null); // not in this map
|
|
90
|
+
assert.deepEqual(chartsForZones(map2, [3, 14]), [
|
|
91
|
+
{ zone: 3, urls: ["http://a/00.TIF"], validHour: "00" },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("conversion and cache", () => {
|
|
97
|
+
test("tiff converts to grayscale png; gif passes through as-is", () => {
|
|
98
|
+
const png = convertChart(fixtureTif());
|
|
99
|
+
assert.equal(detectFormat(png.bytes), "png");
|
|
100
|
+
assert.equal(png.converted, true);
|
|
101
|
+
assert.equal(png.width, 8);
|
|
102
|
+
assert.equal(png.height, 4);
|
|
103
|
+
|
|
104
|
+
// BoM difacs GIF: cached as-is, size read from the header
|
|
105
|
+
const gif = Buffer.concat([
|
|
106
|
+
Buffer.from("GIF89a"),
|
|
107
|
+
Buffer.from([12, 0, 6, 0]), // 12x6 logical screen
|
|
108
|
+
Buffer.from("trailer bytes would follow"),
|
|
109
|
+
]);
|
|
110
|
+
const passthrough = convertChart(gif);
|
|
111
|
+
assert.equal(passthrough.format, "gif");
|
|
112
|
+
assert.equal(passthrough.converted, false);
|
|
113
|
+
assert.equal(passthrough.width, 12);
|
|
114
|
+
assert.equal(passthrough.height, 6);
|
|
115
|
+
assert.equal(passthrough.bytes, gif);
|
|
116
|
+
|
|
117
|
+
assert.equal(convertChart(Buffer.from("NOT AN IMAGE")), null);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refresh fetches, caches, then skips the same chart", async () => {
|
|
121
|
+
const dir = dataDir();
|
|
122
|
+
let fetches = 0;
|
|
123
|
+
const fetchImpl = async () => {
|
|
124
|
+
fetches++;
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
status: 200,
|
|
128
|
+
arrayBuffer: async () => fixtureTif(16, 8),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
const zones = [11]; // static URL entry
|
|
132
|
+
const first = await refreshSynoptics({
|
|
133
|
+
dataDir: dir,
|
|
134
|
+
zones,
|
|
135
|
+
fetchImpl,
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(first.fetched, [11]);
|
|
138
|
+
assert.equal(fetches, 1);
|
|
139
|
+
assert.ok(existsSync(join(dir, "synoptic-11.png")));
|
|
140
|
+
const cached = await loadSynoptic(dir, 11);
|
|
141
|
+
assert.equal(cached.format, "png");
|
|
142
|
+
assert.equal(detectFormat(cached.bytes), "png");
|
|
143
|
+
assert.ok(cached.fetchedAt);
|
|
144
|
+
|
|
145
|
+
const second = await refreshSynoptics({
|
|
146
|
+
dataDir: dir,
|
|
147
|
+
zones,
|
|
148
|
+
fetchImpl,
|
|
149
|
+
});
|
|
150
|
+
assert.deepEqual(second.skipped, [11]);
|
|
151
|
+
assert.equal(fetches, 1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("failed charts are isolated and the cache survives", async () => {
|
|
155
|
+
const dir = dataDir();
|
|
156
|
+
const fetchImpl = async (url) => {
|
|
157
|
+
// Zone 1: both hour variants serve; zone 11: 404s
|
|
158
|
+
if (/PPV[AE]89/.test(String(url))) {
|
|
159
|
+
return { ok: true, status: 200, arrayBuffer: async () => fixtureTif() };
|
|
160
|
+
}
|
|
161
|
+
return { ok: false, status: 404, statusText: "Not Found" };
|
|
162
|
+
};
|
|
163
|
+
const result = await refreshSynoptics({
|
|
164
|
+
dataDir: dir,
|
|
165
|
+
zones: [1, 11], // 11 fails
|
|
166
|
+
fetchImpl,
|
|
167
|
+
});
|
|
168
|
+
assert.deepEqual(result.fetched, [1]);
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
result.failed.map((f) => f.zone),
|
|
171
|
+
[11],
|
|
172
|
+
);
|
|
173
|
+
assert.match(result.failed[0].error, /404/);
|
|
174
|
+
assert.ok(await loadSynoptic(dir, 1));
|
|
175
|
+
assert.equal(await loadSynoptic(dir, 11), null);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("converted chart output is a well-formed grayscale PNG", () => {
|
|
180
|
+
const { bytes: png } = convertChart(fixtureTif(6, 3));
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
[...png.subarray(0, 8)],
|
|
183
|
+
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
184
|
+
);
|
|
185
|
+
assert.equal(png.readUInt32BE(16), 6); // width
|
|
186
|
+
assert.equal(png.readUInt32BE(20), 3); // height
|
|
187
|
+
assert.equal(png[25], 0); // bit depth 8
|
|
188
|
+
assert.equal(png[26], 0); // color type: grayscale
|
|
189
|
+
// IDAT chunk follows the IHDR: inflate it and check the filtered
|
|
190
|
+
// raw size (height * (filter byte + width))
|
|
191
|
+
const dataLen = png.readUInt32BE(33);
|
|
192
|
+
assert.equal(png.toString("ascii", 37, 41), "IDAT");
|
|
193
|
+
const raw = require("node:zlib").inflateSync(png.subarray(41, 41 + dataLen));
|
|
194
|
+
assert.equal(raw.length, 3 * (6 + 1));
|
|
195
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const { test, describe } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
|
|
4
|
+
const tgPromise = import("../public/tack-gybe.js");
|
|
5
|
+
|
|
6
|
+
const approx = (actual, expected, epsilon = 1e-6) =>
|
|
7
|
+
assert.ok(
|
|
8
|
+
Math.abs(actual - expected) < epsilon,
|
|
9
|
+
`expected ${actual} ≈ ${expected} (±${epsilon})`,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds one hourly simulation row for the detection tests.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} overrides
|
|
16
|
+
* @returns {object}
|
|
17
|
+
*/
|
|
18
|
+
function row(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
hoursFromNow: 1,
|
|
21
|
+
timestamp: "2026-06-21T01:00:00.000Z",
|
|
22
|
+
lat: 0.5,
|
|
23
|
+
lon: 0,
|
|
24
|
+
distanceFromStartNm: 5,
|
|
25
|
+
headingDeg: 0,
|
|
26
|
+
twdDeg: 45,
|
|
27
|
+
twsKnots: 12,
|
|
28
|
+
sogKnots: 5,
|
|
29
|
+
sailState: "MAIN_FULL",
|
|
30
|
+
motoring: false,
|
|
31
|
+
night: false,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("tack & gybe detection", () => {
|
|
37
|
+
test("wrap180 normalizes to (-180, 180]", async () => {
|
|
38
|
+
const { wrap180 } = await tgPromise;
|
|
39
|
+
assert.equal(wrap180(0), 0);
|
|
40
|
+
assert.equal(wrap180(180), 180);
|
|
41
|
+
assert.equal(wrap180(-180), 180);
|
|
42
|
+
assert.equal(wrap180(190), -170);
|
|
43
|
+
assert.equal(wrap180(-190), 170);
|
|
44
|
+
assert.equal(wrap180(540), 180);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("tackSide: positive TWA is starboard", async () => {
|
|
48
|
+
const { tackSide } = await tgPromise;
|
|
49
|
+
assert.equal(tackSide(30), "starboard");
|
|
50
|
+
assert.equal(tackSide(-30), "port");
|
|
51
|
+
assert.equal(tackSide(170), "starboard");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("upwind beat: single tack with interpolated crossing", async () => {
|
|
55
|
+
const { detectManeuvers } = await tgPromise;
|
|
56
|
+
const events = detectManeuvers([
|
|
57
|
+
row({
|
|
58
|
+
hoursFromNow: 1,
|
|
59
|
+
timestamp: "2026-06-21T01:00:00.000Z",
|
|
60
|
+
headingDeg: 30,
|
|
61
|
+
twdDeg: 60,
|
|
62
|
+
lat: 0.5,
|
|
63
|
+
distanceFromStartNm: 5,
|
|
64
|
+
}),
|
|
65
|
+
row({
|
|
66
|
+
hoursFromNow: 2,
|
|
67
|
+
timestamp: "2026-06-21T02:00:00.000Z",
|
|
68
|
+
headingDeg: 90,
|
|
69
|
+
twdDeg: 60,
|
|
70
|
+
lat: 0.4,
|
|
71
|
+
distanceFromStartNm: 10,
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
assert.equal(events.length, 1);
|
|
75
|
+
const [e] = events;
|
|
76
|
+
assert.equal(e.maneuver, "tack");
|
|
77
|
+
assert.equal(e.fromTack, "starboard");
|
|
78
|
+
assert.equal(e.toTack, "port");
|
|
79
|
+
assert.equal(e.sailState, "MAIN_FULL@port");
|
|
80
|
+
// TWA +30 → −30: crossing at the segment midpoint
|
|
81
|
+
assert.equal(e.hoursFromNow, 1.5);
|
|
82
|
+
assert.equal(e.timestamp, "2026-06-21T01:30:00.000Z");
|
|
83
|
+
assert.equal(e.eta, e.timestamp);
|
|
84
|
+
approx(e.lat, 0.45);
|
|
85
|
+
assert.equal(e.distanceFromStartNm, 7.5);
|
|
86
|
+
assert.equal(e.twsAtManeuver, 12);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("downwind run: gybe classified across ±180", async () => {
|
|
90
|
+
const { detectManeuvers } = await tgPromise;
|
|
91
|
+
const events = detectManeuvers([
|
|
92
|
+
row({ headingDeg: 0, twdDeg: 170 }),
|
|
93
|
+
row({ headingDeg: 0, twdDeg: -170 }),
|
|
94
|
+
]);
|
|
95
|
+
assert.equal(events.length, 1);
|
|
96
|
+
assert.equal(events[0].maneuver, "gybe");
|
|
97
|
+
assert.equal(events[0].fromTack, "starboard");
|
|
98
|
+
assert.equal(events[0].toTack, "port");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("TWA wrap-around near ±180 still detects the gybe", async () => {
|
|
102
|
+
const { detectManeuvers } = await tgPromise;
|
|
103
|
+
const events = detectManeuvers([
|
|
104
|
+
row({ hoursFromNow: 1, headingDeg: 0, twdDeg: 175 }),
|
|
105
|
+
row({ hoursFromNow: 2, headingDeg: 0, twdDeg: -176 }),
|
|
106
|
+
]);
|
|
107
|
+
assert.equal(events.length, 1);
|
|
108
|
+
assert.equal(events[0].maneuver, "gybe");
|
|
109
|
+
// The path from +175 to −176 wraps through +180: ΔTWA =
|
|
110
|
+
// wrap(−351) = +9°, crossing at f = (180 − 175)/9 = 5/9 ≈ 0.556,
|
|
111
|
+
// rounded to the sim row's 0.1 h precision
|
|
112
|
+
approx(events[0].hoursFromNow, 1.6, 0.001);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("motoring legs are excluded", async () => {
|
|
116
|
+
const { detectManeuvers } = await tgPromise;
|
|
117
|
+
const events = detectManeuvers([
|
|
118
|
+
row({ headingDeg: 30, twdDeg: 60 }),
|
|
119
|
+
row({ headingDeg: 90, twdDeg: 60, motoring: true }),
|
|
120
|
+
row({ headingDeg: 150, twdDeg: 60 }),
|
|
121
|
+
]);
|
|
122
|
+
assert.equal(events.length, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("drift-mode hours (SOG 0) are excluded", async () => {
|
|
126
|
+
const { detectManeuvers } = await tgPromise;
|
|
127
|
+
const events = detectManeuvers([
|
|
128
|
+
row({ headingDeg: 30, twdDeg: 60 }),
|
|
129
|
+
row({ headingDeg: 90, twdDeg: 60, sogKnots: 0 }),
|
|
130
|
+
row({ headingDeg: 150, twdDeg: 60 }),
|
|
131
|
+
]);
|
|
132
|
+
assert.equal(events.length, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("wobble below the 25° margin is rejected", async () => {
|
|
136
|
+
const { detectManeuvers, MIN_TWA_MARGIN_DEG } = await tgPromise;
|
|
137
|
+
assert.equal(MIN_TWA_MARGIN_DEG, 25);
|
|
138
|
+
const events = detectManeuvers([
|
|
139
|
+
row({ headingDeg: 40, twdDeg: 60 }),
|
|
140
|
+
row({ headingDeg: 80, twdDeg: 60 }),
|
|
141
|
+
]);
|
|
142
|
+
assert.equal(events.length, 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("gradual veer through a low-wind-angle hour still lands the tack", async () => {
|
|
146
|
+
const { detectManeuvers } = await tgPromise;
|
|
147
|
+
// TWA +45 → +5 → −45: the mid hour dips below the margin while
|
|
148
|
+
// the wind swings through the eye, but the established side flips
|
|
149
|
+
const events = detectManeuvers([
|
|
150
|
+
row({ hoursFromNow: 1, headingDeg: 0, twdDeg: 45 }),
|
|
151
|
+
row({ hoursFromNow: 2, headingDeg: 0, twdDeg: 5 }),
|
|
152
|
+
row({ hoursFromNow: 3, headingDeg: 0, twdDeg: -45 }),
|
|
153
|
+
]);
|
|
154
|
+
assert.equal(events.length, 1);
|
|
155
|
+
assert.equal(events[0].maneuver, "tack");
|
|
156
|
+
assert.equal(events[0].fromTack, "starboard");
|
|
157
|
+
assert.equal(events[0].toTack, "port");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("wobble that never flips the established side stays silent", async () => {
|
|
161
|
+
const { detectManeuvers } = await tgPromise;
|
|
162
|
+
const events = detectManeuvers([
|
|
163
|
+
row({ hoursFromNow: 1, headingDeg: 0, twdDeg: 45 }),
|
|
164
|
+
row({ hoursFromNow: 2, headingDeg: 0, twdDeg: 10 }),
|
|
165
|
+
row({ hoursFromNow: 3, headingDeg: 0, twdDeg: 45 }),
|
|
166
|
+
]);
|
|
167
|
+
assert.equal(events.length, 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("mixed beam reach (one side < 90, one > 90) is not classified", async () => {
|
|
171
|
+
const { detectManeuvers } = await tgPromise;
|
|
172
|
+
const events = detectManeuvers([
|
|
173
|
+
row({ headingDeg: 30, twdDeg: 60 }),
|
|
174
|
+
row({ headingDeg: 170, twdDeg: 60 }),
|
|
175
|
+
]);
|
|
176
|
+
assert.equal(events.length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("multi-maneuver route: tack then gybe, time-sorted", async () => {
|
|
180
|
+
const { detectManeuvers } = await tgPromise;
|
|
181
|
+
const events = detectManeuvers([
|
|
182
|
+
row({ hoursFromNow: 1, headingDeg: 30, twdDeg: 60 }),
|
|
183
|
+
row({ hoursFromNow: 2, headingDeg: 90, twdDeg: 60 }),
|
|
184
|
+
row({ hoursFromNow: 3, headingDeg: 90, twdDeg: -95 }),
|
|
185
|
+
row({ hoursFromNow: 4, headingDeg: 90, twdDeg: -60 }),
|
|
186
|
+
]);
|
|
187
|
+
assert.equal(events.length, 2);
|
|
188
|
+
assert.equal(events[0].maneuver, "tack");
|
|
189
|
+
assert.equal(events[1].maneuver, "gybe");
|
|
190
|
+
assert.ok(events[0].hoursFromNow <= events[1].hoursFromNow);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("no wind data yields no events", async () => {
|
|
194
|
+
const { detectManeuvers } = await tgPromise;
|
|
195
|
+
const events = detectManeuvers([
|
|
196
|
+
row({ headingDeg: 30, twdDeg: null }),
|
|
197
|
+
row({ headingDeg: 90, twdDeg: null }),
|
|
198
|
+
]);
|
|
199
|
+
assert.equal(events.length, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("no sail state: event still carries the tack label", async () => {
|
|
203
|
+
const { detectManeuvers } = await tgPromise;
|
|
204
|
+
const events = detectManeuvers([
|
|
205
|
+
row({ headingDeg: 30, twdDeg: 60, sailState: null }),
|
|
206
|
+
row({ headingDeg: 90, twdDeg: 60, sailState: null }),
|
|
207
|
+
]);
|
|
208
|
+
assert.equal(events.length, 1);
|
|
209
|
+
assert.equal(events[0].sailState, "@port");
|
|
210
|
+
});
|
|
211
|
+
});
|