Files
gods-eye-view/src/data/aisStreamSentinels.test.mjs
T

261 lines
8.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
AISSTREAM_STALE_MS,
aisStreamRows,
ingestAisStreamEnvelope,
readAisTrack,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
test('a static data report keeps the motion and fix time of a tracked vessel', () => {
ingestAisStreamEnvelope(
positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }),
);
// AISStream's MetaData carries the last known position on every message,
// static data included.
assert.equal(
ingestAisStreamEnvelope({
MessageType: 'ShipStaticData',
Message: {
ShipStaticData: {
UserID: 244690000,
Name: 'EXAMPLE VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
},
MetaData: {
MMSI: '244690000',
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC',
},
}),
true,
);
const row = rowFor('244690000');
assert.equal(row.speed, 12.4);
assert.equal(row.course, 86.7);
assert.equal(row.heading, 87);
assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z');
assert.equal(row.destination, 'ROTTERDAM');
assert.equal(row.imo, '9876543');
});
// AISStream carries both Class B parts; the unused part has Valid: false.
const classBStatic = (part, value) => ({
MessageID: 24,
RepeatIndicator: 0,
Valid: true,
Reserved: 0,
PartNumber: part === 'B',
ReportA: { Valid: part === 'A', Name: part === 'A' ? value : '' },
ReportB: {
Valid: part === 'B',
ShipType: part === 'B' ? value : 0,
VendorIDName: '',
VenderIDModel: 0,
VenderIDSerial: 0,
CallSign: '',
Dimension: { A: 0, B: 0, C: 0, D: 0 },
FixType: 0,
Spare: 0,
},
});
for (const [index, scenario] of [
{
type: 'ShipStaticData',
initial: [
{
Name: 'OLD VESSEL',
Type: 70,
Destination: 'ROTTERDAM',
ImoNumber: 9876543,
},
],
updates: [
{
Name: 'NEW VESSEL',
Type: 80,
Destination: 'HAMBURG',
ImoNumber: 9876544,
},
],
empty: { Name: ' ', Type: 0, Destination: '', ImoNumber: 0 },
expected: {
name: 'NEW VESSEL',
type: '80',
destination: 'HAMBURG',
imo: '9876544',
},
},
{
type: 'StaticDataReport',
initial: [classBStatic('A', 'OLD VESSEL'), classBStatic('B', 36)],
updates: [classBStatic('A', 'NEW VESSEL'), classBStatic('B', 37)],
empty: classBStatic('A', ''),
expected: { name: 'NEW VESSEL', type: '37', destination: '', imo: '' },
},
].entries()) {
test(`${scenario.type} replaces static fields without inventing a position fix`, (t) => {
const mmsi = String(244690100 + index);
const start = Date.parse('2026-09-17T20:00:00Z');
let now = start;
t.mock.method(Date, 'now', () => now);
const position = (
lat,
body = { Sog: 12.4, Cog: 86.7, TrueHeading: 87 },
) => {
const envelope = positionReport(mmsi, body);
Object.assign(envelope.MetaData, {
latitude: lat,
time_utc: new Date(now).toISOString(),
});
return ingestAisStreamEnvelope(envelope);
};
const sendStatic = (body) =>
ingestAisStreamEnvelope({
MessageType: scenario.type,
Message: { [scenario.type]: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
ShipName: 'STALE HEADER',
latitude: 54,
longitude: 7,
time_utc: new Date(now).toISOString(),
},
});
position(53.40879);
for (const body of scenario.initial) assert.equal(sendStatic(body), true);
now += 60_000;
position(53.41879);
const before = rowFor(mmsi);
const track = readAisTrack(mmsi);
assert.equal(track.length, 2);
now += 5 * 60_000;
for (const body of scenario.updates) assert.equal(sendStatic(body), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
// Empty or omitted static fields must not erase known metadata, including
// the cached values used when the next actual position arrives.
assert.equal(sendStatic(scenario.empty), true);
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
assert.deepEqual(readAisTrack(mmsi), track);
if (scenario.type === 'StaticDataReport') {
sendStatic({
...classBStatic('A', ''),
ReportA: { Valid: false, Name: 'INVALID NAME' },
ReportB: { ...classBStatic('B', 99).ReportB, Valid: false },
});
assert.deepEqual(rowFor(mmsi), { ...before, ...scenario.expected });
}
now = start + 60_000 + AISSTREAM_STALE_MS + 1;
assert.equal(
rowFor(mmsi),
undefined,
'static traffic must not renew position freshness',
);
position(53.42879, { Sog: 0, Cog: 0, TrueHeading: 0 });
const next = rowFor(mmsi);
for (const [key, value] of Object.entries(scenario.expected))
assert.equal(next[key], value, key);
assert.equal(next.speed, 0);
assert.equal(next.course, 0);
assert.equal(next.heading, 0);
assert.equal(next.last_position_epoch, Math.floor(now / 1000));
assert.equal(readAisTrack(mmsi).length, 3);
});
}
test('partial static data keeps newer live metadata ahead of the old static cache', () => {
const mmsi = '244690200';
const staticReport = (body) => ({
MessageType: 'ShipStaticData',
Message: { ShipStaticData: { UserID: Number(mmsi), ...body } },
MetaData: { MMSI: mmsi },
});
ingestAisStreamEnvelope(staticReport({ Name: 'OLD VESSEL', Type: 70 }));
const extended = positionReport(mmsi, {});
extended.MessageType = 'ExtendedClassBPositionReport';
extended.Message = {
ExtendedClassBPositionReport: {
UserID: Number(mmsi),
Type: 80,
Sog: 12,
Cog: 86,
},
};
extended.MetaData.ShipName = 'NEW VESSEL';
ingestAisStreamEnvelope(extended);
const before = rowFor(mmsi);
ingestAisStreamEnvelope(staticReport({ Destination: 'HAMBURG' }));
assert.deepEqual(rowFor(mmsi), { ...before, destination: 'HAMBURG' });
ingestAisStreamEnvelope(positionReport(mmsi, { Sog: 12, Cog: 86 }));
assert.equal(rowFor(mmsi).name, 'NEW VESSEL');
assert.equal(rowFor(mmsi).type, '80');
assert.equal(rowFor(mmsi).destination, 'HAMBURG');
});