Files
gods-eye-view/src/ui/cockpitBridge.test.mjs
T
Sameh Khamis cbbf55cacd feat(vessels): key vessels by their source's stable id, and share a selected vessel
AIS Vessels are keyed by the id their live source gives, with the MMSI as an
attribute: an MMSI can be shared, spoofed or reused, so two vessels with one
MMSI are now two vessels. Looking a vessel up or selecting it by MMSI still
finds it.

A share link made while a vessel is selected opens that vessel. A UUID vessel
id travels as 16 bytes in a new thing field (13). A shared thing that is not
reporting now says so in a sentence per kind, with its id (ICAO, NORAD, MMSI
or vessel UUID) on a second line.
2026-11-10 18:12:53 -08:00

1569 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/ui/cockpitBridge.test.mjs — a selected vessel's bridge view: entry,
// the vessel layer's sprite hold, the bridge readouts, refused contact
// navigation and the ways the bridge ends, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { CockpitViewController } from './cockpitController.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitView from './views/CockpitView.vue';
import ContextView from './views/ContextView.vue';
import * as Cesium from 'cesium';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
import { VesselRecords } from '../layers/vessels/records.js';
const RECORD = Object.freeze({
id: '211000000',
mmsi: '211000000',
name: 'NORDIC STAR',
type: '70',
destination: 'KIEL',
lat: 54.5,
lon: 10.2,
speed: 12,
course: 95,
heading: 90,
lastPositionEpoch: null,
missedRefreshes: 0,
});
/** A ship 2 NM north of RECORD, inside the radar's default range. */
const NEIGHBOUR = Object.freeze({
id: '211000001',
name: 'BALTIC TERN',
latitude: RECORD.lat + 2 / 60,
longitude: RECORD.lon,
course: 270,
heading: 270,
type: '30',
color: '#ffd166',
stale: false,
});
function vesselLayer(record) {
const calls = [];
const radarQueries = [];
let selected = record;
let subject = null;
let enabled = true;
return {
calls,
radarQueries,
deselect() {
selected = null;
},
/** Another ship selected in its place. */
select(next) {
selected = next;
},
/** Live AIS Vessels turned off: like the layer, it then has no subject. */
disable() {
enabled = false;
},
getCockpitSubject: () =>
enabled && selected
? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 }
: null,
setCockpitSubject(id) {
calls.push(['subject', id]);
subject = id;
return true;
},
// Like the layer, leaves out the ship whose bridge the Cockpit rides.
getRadarContacts(lat, lon, rangeM, maxCount) {
radarQueries.push({ lat, lon, rangeM, maxCount });
const own = {
...NEIGHBOUR,
id: record.id,
latitude: record.lat,
longitude: record.lon,
};
return [own, NEIGHBOUR].filter((contact) => contact.id !== subject);
},
focusSelectedVessel() {
calls.push(['focus']);
return true;
},
};
}
function fixture(
t,
{ bridgeAllowed = true, record = RECORD, aircraftInfo = null } = {},
) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => [h(ContextView), h(CockpitView)]),
).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const listeners = () => ({ addEventListener: () => () => {} });
const aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000);
const aircraft = {
position: {
getValue: (time, result) =>
Cesium.Cartesian3.clone(aircraftPosition, result),
},
show: true,
gevTrackedId: aircraftInfo ? `flights:${aircraftInfo.icao24}` : '',
};
const views = [];
// Each placed camera: where it is and where it looks.
const shots = [];
const viewer = {
trackedEntity: undefined,
clock: { currentTime: null },
camera: {
cancelFlight() {},
setView({ destination, orientation }) {
views.push(Cesium.Cartesian3.clone(destination));
shots.push({
destination: Cesium.Cartesian3.clone(destination),
direction: Cesium.Cartesian3.clone(orientation.direction),
up: Cesium.Cartesian3.clone(orientation.up),
});
},
},
entities: { contains: (candidate) => candidate === aircraft },
scene: {
preUpdate: listeners(),
screenSpaceCameraController: { enableInputs: true },
},
trackedEntityChanged: listeners(),
};
const vessels = vesselLayer(record);
const navigations = [];
// Turning Live Flights off clears its tracking, as the layer does.
let flightsEnabled = true;
const disableFlights = () => {
flightsEnabled = false;
};
const cockpit = new CockpitViewController(viewer, {
services: {
flightsLayer: {
getTrackedInfo: () => (flightsEnabled ? aircraftInfo : null),
},
militaryFlightsLayer: { getTrackedInfo: () => null },
aisLiveVesselsLayer: vessels,
militaryAwarenessLayer: {
navigateNext: () => navigations.push('next') > 0,
navigatePrevious: () => navigations.push('previous') > 0,
},
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
// Open water: no rendered floor above the sea surface.
cachedGroundFloor: () => null,
GROUND_FLOOR_LIFT_M: 0,
},
// The aircraft gate stays shut: the bridge must not depend on Contacts.
isEntryAllowed: () => Boolean(aircraftInfo),
isBridgeEntryAllowed: () => bridgeAllowed,
publish: {
presence: (view) => store.publishPresence(view),
instruments: (view) => store.publishInstruments(view),
route: (view) => store.publishRoute(view),
radar: (view) => store.publishRadar(view),
},
});
// The briefing and signals have their own tests; the bridge keeps only
// what it shows and owns.
Object.assign(cockpit, {
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
pushCockpitSignal() {},
scheduleContextLayout() {},
updateLocalPosition() {},
maybeRefreshRegionalBrief() {},
});
// An aircraft session's Contact card and flight plan have their own tests.
if (aircraftInfo)
Object.assign(cockpit, { updateContext() {}, updateRoute() {} });
t.after(() => {
cockpit.stop();
app.unmount();
document.body.classList.remove('cockpit-mode');
});
const $ = (selector) => document.querySelector(selector);
return {
$,
cockpit,
viewer,
vessels,
aircraft,
disableFlights,
navigations,
views,
shots,
store,
};
}
/** Replace both clocks the bridge reads; returns a stepper and a restore. */
function manualClock(t, startMs) {
const originalNow = performance.now;
const originalDateNow = Date.now;
let nowMs = startMs;
performance.now = () => nowMs;
Date.now = () => nowMs;
t.after(() => {
performance.now = originalNow;
Date.now = originalDateNow;
});
return {
advance(ms) {
nowMs += ms;
},
set(ms) {
nowMs = ms;
},
};
}
async function settle() {
await nextTick();
await Promise.resolve();
}
test('a selected vessel offers a BRIDGE entry without Contacts', async (t) => {
const { $, cockpit } = fixture(t);
cockpit.syncEntry();
await nextTick();
const entry = $('#cockpit-entry');
assert.equal(entry.hidden, false);
assert.match(entry.textContent, /directions_boat/);
assert.match(entry.textContent, /BRIDGE/);
assert.equal($('#tr3b-toggle').hidden, true, 'TR-3B stays aircraft-only');
});
test('a shut bridge gate keeps the entry hidden', async (t) => {
const { $, cockpit } = fixture(t, { bridgeAllowed: false });
cockpit.syncEntry();
await nextTick();
assert.equal($('#cockpit-entry').hidden, true);
assert.equal(cockpit.enter(), false);
});
test('entering the bridge hides the vessel and shows bridge readouts', async (t) => {
const { $, cockpit, vessels } = fixture(t);
cockpit.syncEntry();
await nextTick();
$('#cockpit-entry').click();
await settle();
assert.equal(cockpit.active, true);
assert.equal(cockpit.subjectKind, 'vessel');
assert.deepEqual(vessels.calls, [['subject', RECORD.id]]);
assert.equal(document.body.classList.contains('cockpit-mode'), true);
assert.equal(
$('#cockpit-hud').getAttribute('aria-label'),
'Ship bridge view',
);
assert.match($('.cockpit-kicker').textContent, /BRIDGE VIEW/);
assert.equal($('#cockpit-callsign').textContent, 'NORDIC STAR');
assert.match($('#cockpit-aircraft-meta').textContent, /CARGO · LIVE AIS/);
assert.match($('#cockpit-aircraft-meta').textContent, /EYE 30/);
assert.equal($('#cockpit-altitude-rim').hidden, true);
assert.equal($('#cockpit-altitude-value'), null);
assert.ok($('#cockpit-course-value'), 'course over ground replaces altitude');
assert.match($('#map-view-switch').textContent, /EXIT BRIDGE/);
assert.equal(
$('#map-view-switch').getAttribute('aria-label'),
'Exit bridge view',
);
const route = $('#cockpit-route');
assert.equal(route.hidden, false);
assert.match(route.textContent, /REPORTED VOYAGE/);
assert.equal($('#cockpit-route-to').textContent, 'KIEL');
assert.equal($('#cockpit-route-from'), null, 'AIS reports no origin');
assert.equal($('#cockpit-route-direction').hidden, true);
});
test('entering the bridge names Live AIS Vessels as the ridden layer', async (t) => {
const { cockpit } = fixture(t);
const rides = [];
cockpit.onRide = (layerId) => rides.push(layerId);
assert.equal(cockpit.enter(), true);
await settle();
assert.deepEqual(rides, ['ais-live-vessels']);
});
test('exiting the bridge releases the vessel and returns to its map view', async (t) => {
const { $, cockpit, vessels } = fixture(t);
cockpit.enter();
await settle();
$('#map-view-switch').click();
await settle();
assert.equal(cockpit.active, false);
assert.equal(cockpit.subjectKind, null);
assert.deepEqual(vessels.calls, [
['subject', RECORD.id],
['subject', null],
['focus'],
]);
assert.match($('#map-view-switch').textContent, /EXIT COCKPIT/);
});
test('the bridge radar shows nearby ships without its own and steps its range', async (t) => {
const { $, cockpit, vessels, store } = fixture(t);
assert.equal(store.radar, null, 'no radar before the bridge');
cockpit.enter();
await settle();
const radar = store.radar;
assert.ok(radar, 'entering the bridge publishes a radar');
assert.equal(radar.rangeNm, 3);
assert.equal(radar.rangeLabel, '3 NM');
assert.deepEqual(radar.ringsNm, [0.75, 1.5, 2.25, 3]);
assert.deepEqual(
radar.contacts.map((contact) => contact.id),
[NEIGHBOUR.id],
'the cockpit subject is not its own contact',
);
const [neighbour] = radar.contacts;
assert.ok(Math.abs(neighbour.distanceNm - 2) < 0.02);
// Heading 090: a ship to the north is on the port beam, heading west.
assert.ok(neighbour.x < -0.3 && Math.abs(neighbour.y) < 0.02);
assert.equal(neighbour.relativeCourseDeg, -180);
assert.equal(radar.ownRelativeCourseDeg, 5, 'COG 095 from heading 090');
assert.equal(vessels.radarQueries.at(-1).rangeM, 3 * 1852);
assert.equal($('#cockpit-radar').hidden, false);
assert.equal($('#cockpit-radar-range').textContent.trim(), '3 NM');
assert.equal($('.cockpit-radar-count').textContent.trim(), '1 CONTACT');
assert.equal(document.querySelectorAll('.cockpit-radar-contact').length, 1);
$('#cockpit-radar-range').click();
await settle();
assert.equal(store.radar.rangeNm, 6);
assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852);
assert.equal($('#cockpit-radar-range').textContent.trim(), '6 NM');
assert.match(
$('#cockpit-radar-range').getAttribute('aria-label'),
/Radar range 6 NM/,
);
cockpit.exit();
await settle();
assert.equal(store.radar, null, 'the radar ends with the bridge');
assert.ok(!$('#cockpit-radar') || $('#cockpit-radar').hidden);
});
test('contact navigation does not jump ship from the bridge', async (t) => {
const { cockpit, navigations } = fixture(t);
cockpit.enter();
await settle();
assert.equal(cockpit.navigateContext(1, { origin: 'user' }), false);
assert.equal(cockpit.navigateContext(-1, { origin: 'user' }), false);
assert.deepEqual(navigations, []);
assert.equal(cockpit.subjectKind, 'vessel');
});
test('tracking an aircraft from the bridge ends it without refocusing the vessel', async (t) => {
const { cockpit, viewer, vessels, aircraft } = fixture(t);
cockpit.enter();
await settle();
viewer.trackedEntity = aircraft;
cockpit.update();
assert.equal(cockpit.active, false);
assert.equal(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking');
assert.equal(aircraft.show, true, 'the aircraft was never adopted');
assert.deepEqual(vessels.calls, [
['subject', RECORD.id],
['subject', null],
]);
});
test('a vessel that leaves the selection ends the bridge', async (t) => {
const { cockpit, vessels } = fixture(t);
cockpit.enter();
await settle();
vessels.deselect();
cockpit.update();
assert.equal(cockpit.active, false);
assert.deepEqual(vessels.calls.at(-1), ['subject', null]);
});
test('turning off Live AIS Vessels ends the bridge', async (t) => {
const { cockpit, vessels } = fixture(t);
cockpit.enter();
await settle();
assert.equal(cockpit.active, true);
vessels.disable();
cockpit.update();
assert.equal(cockpit.active, false);
assert.deepEqual(vessels.calls.at(-1), ['subject', null]);
});
test("turning off the ridden aircraft's layer ends the cockpit", async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft, disableFlights } = fixture(t, {
aircraftInfo,
});
viewer.trackedEntity = aircraft;
assert.equal(cockpit.enter({ kind: 'aircraft' }), true);
await settle();
cockpit.update();
assert.equal(cockpit.active, true);
disableFlights();
cockpit.update();
assert.equal(cockpit.active, false);
});
test('BRIDGE enters the selected vessel bridge and aircraft-only entry refuses it', async (t) => {
const { $, cockpit } = fixture(t);
assert.equal(cockpit.enter({ kind: 'aircraft' }), false);
cockpit.syncEntry();
await settle();
$('#cockpit-entry').click();
await settle();
assert.equal(cockpit.active, true);
assert.equal(cockpit.subjectKind, 'vessel');
});
test('C neither enters nor leaves the bridge; it stays the CCTV shortcut', async (t) => {
const { cockpit } = fixture(t);
const outside = press(cockpit, 'c');
await settle();
assert.equal(cockpit.active, false, 'C does not enter');
assert.equal(outside.defaultPrevented, false);
assert.equal(outside.propagationStopped, false);
cockpit.enter();
await settle();
assert.equal(cockpit.subjectKind, 'vessel');
for (const key of ['c', 'C']) {
const inside = press(cockpit, key);
assert.equal(cockpit.active, true, `${key} does not exit`);
assert.equal(inside.defaultPrevented, false);
assert.equal(inside.propagationStopped, false);
}
});
test('C stays the CCTV shortcut when the bridge gate is shut', async (t) => {
const { cockpit } = fixture(t, { bridgeAllowed: false });
let stopped = false;
const event = {
key: 'c',
target: document.body,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
stopImmediatePropagation() {
stopped = true;
},
};
cockpit.onKeyDown(event);
await settle();
assert.equal(cockpit.active, false);
assert.equal(event.defaultPrevented, false);
assert.equal(stopped, false, 'the application shortcut still sees C');
});
test('a slow-frame bridge keeps pace with its ship', async (t) => {
// One rendered frame a second, as software rendering manages. The bridge
// must still cover the ship's distance, not a capped fraction of it.
const startMs = 1_800_000_000_000;
const clock = manualClock(t, startMs);
const record = { ...RECORD, lastPositionEpoch: startMs / 1000 };
const { cockpit, views } = fixture(t, { record });
cockpit.enter();
await settle();
for (let frame = 0; frame <= 20; frame += 1) {
cockpit.update();
clock.advance(1000);
}
assert.ok(
views.length >= 20,
`expected a view per frame, got ${views.length}`,
);
const travelledM = Cesium.Cartesian3.distance(views[0], views.at(-1));
const expectedM = (views.length - 1) * RECORD.speed * 0.514444;
assert.ok(
Math.abs(travelledM - expectedM) < expectedM * 0.05,
`travelled ${travelledM.toFixed(1)} m, expected ~${expectedM.toFixed(1)} m`,
);
});
/** Press a key through the Cockpit's keydown handler. */
function press(
cockpit,
key,
{ shiftKey = false, repeat = false, target = document.body } = {},
) {
const event = {
key,
shiftKey,
repeat,
target,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopImmediatePropagation() {
this.propagationStopped = true;
},
};
cockpit.onKeyDown(event);
return event;
}
/** A placed camera's view heading and pitch in its local east-north-up frame. */
function viewAngles({ destination, direction }) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(destination);
const toLocal = Cesium.Matrix4.inverseTransformation(
enu,
new Cesium.Matrix4(),
);
const local = Cesium.Matrix4.multiplyByPointAsVector(
toLocal,
direction,
new Cesium.Cartesian3(),
);
const headingDeg =
((Cesium.Math.toDegrees(Math.atan2(local.x, local.y)) % 360) + 360) % 360;
const pitchDeg = Cesium.Math.toDegrees(
Math.asin(Math.max(-1, Math.min(1, local.z))),
);
return { headingDeg, pitchDeg };
}
/** Run `count` camera updates 50 ms apart; returns the last view. */
function frames(cockpit, clock, shots, count = 20) {
for (let frame = 0; frame < count; frame += 1) {
clock.advance(50);
cockpit.update();
}
return viewAngles(shots.at(-1));
}
const near = (actual, expected, label, tolerance = 0.2) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual.toFixed(3)} not within ${tolerance} of ${expected}`,
);
const START_MS = 1_800_000_000_000;
async function bridgeAt(t, clock) {
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const scene = fixture(t, { record });
scene.cockpit.enter();
await settle();
scene.cockpit.update();
return scene;
}
test('arrows turn the bridge view without moving the eye off its path', async (t) => {
const clock = manualClock(t, START_MS);
// A bridge left alone, for the eye's path.
const still = await bridgeAt(t, clock);
frames(still.cockpit, clock, still.shots, 30);
const stillPath = still.shots.map((shot) => shot.destination);
still.cockpit.stop();
const { cockpit, shots, store, $ } = await bridgeAt(t, clock);
let view = viewAngles(shots.at(-1));
near(view.headingDeg, RECORD.heading, 'starts over the bow');
near(view.pitchDeg, -2, 'at the rest pitch');
assert.equal(store.instruments.look.offset, false);
const event = press(cockpit, 'ArrowRight', { shiftKey: true });
assert.equal(event.defaultPrevented, true);
press(cockpit, 'ArrowRight', { shiftKey: true });
// Partway through the ease the view is between the bow and the beam.
clock.advance(50);
cockpit.update();
const midway = viewAngles(shots.at(-1)).headingDeg;
assert.ok(midway > 100 && midway < 175, `midway heading ${midway}`);
view = frames(cockpit, clock, shots, 9);
near(view.headingDeg, RECORD.heading + 90, 'starboard beam');
press(cockpit, 'ArrowUp');
press(cockpit, 'ArrowUp');
press(cockpit, 'ArrowUp');
view = frames(cockpit, clock, shots, 20);
near(view.pitchDeg, 10, 'pitched up');
assert.deepEqual(cockpit.lookTarget, { yawDeg: 90, pitchDeg: 10 });
// The eye followed exactly the path of the bridge left alone.
assert.equal(shots.length, stillPath.length);
shots.forEach((shot, index) =>
assert.ok(
Cesium.Cartesian3.distance(shot.destination, stillPath[index]) < 1e-6,
`frame ${index} moved the eye`,
),
);
await settle();
assert.equal(store.instruments.look.offset, true);
assert.equal(store.instruments.look.yawDeg, 90);
assert.equal($('.cockpit-look').hidden, false);
assert.equal(
$('#cockpit-look-readout').textContent,
'LOOK STBD 090° · UP 10°',
);
assert.ok(
$('.cockpit-radar-look'),
'the radar draws the look line while the view is turned',
);
assert.match($('.cockpit-help').textContent, /ARROWS LOOK · HOME RESET/);
// The heading readout keeps the ship's heading.
assert.equal(store.instruments.heading.text, '090');
});
test('the bridge view pitches no further than straight up or down', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit, shots, store } = await bridgeAt(t, clock);
for (let index = 0; index < 4; index += 1)
press(cockpit, 'ArrowUp', { shiftKey: true });
assert.equal(cockpit.lookTarget.pitchDeg, 90);
let view = frames(cockpit, clock, shots);
near(view.pitchDeg, 90, 'straight up', 0.5);
const up = viewAngles({
destination: shots.at(-1).destination,
direction: shots.at(-1).up,
});
near(up.headingDeg, (RECORD.heading + 180) % 360, 'up stays valid', 1);
for (let index = 0; index < 5; index += 1)
press(cockpit, 'ArrowDown', { shiftKey: true });
assert.equal(cockpit.lookTarget.pitchDeg, -90);
view = frames(cockpit, clock, shots);
near(view.pitchDeg, -90, 'straight down', 0.5);
await settle();
assert.equal(store.instruments.look.readout, 'LOOK DOWN 90°');
});
test('the heading tape follows the bridge look; bow and stern marks stay on the ship', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit, shots, store, $ } = await bridgeAt(t, clock);
const tape = async () => {
frames(cockpit, clock, shots, 40);
await settle();
const { compass, compassMarkers, heading } = store.instruments;
return {
centre: compass.find((division) => division.active).label,
heading: heading.value,
...compassMarkers,
};
};
// Over the bow: the tape centres on the ship's heading, east.
let reading = await tape();
assert.equal(reading.centre, 'E');
assert.equal(reading.prograde, 0);
assert.equal(reading.retrograde, null, 'the stern is off the tape');
assert.equal($('.cockpit-compass-retrograde'), null);
const bow = $('.cockpit-compass-prograde');
assert.equal(bow.getAttribute('aria-hidden'), 'true');
assert.equal(bow.style.getPropertyValue('--slot'), '0');
// Turned to port onto north: the tape follows the camera, the HDG readout
// keeps the ship's heading and both marks sit at their relative bearings.
press(cockpit, 'ArrowLeft', { shiftKey: true });
press(cockpit, 'ArrowLeft', { shiftKey: true });
reading = await tape();
assert.equal(reading.centre, 'N');
near(reading.heading, RECORD.heading, 'the HDG readout', 1e-6);
near(reading.prograde, 3, 'the bow, 90° to starboard', 1e-6);
near(reading.retrograde, -3, 'the stern, 90° to port', 1e-6);
assert.equal(
$('.cockpit-compass-retrograde').getAttribute('aria-hidden'),
'true',
);
// Further round, onto 315°: the bow leaves the tape, the stern comes
// under W, 45° to port.
press(cockpit, 'ArrowLeft', { shiftKey: true });
reading = await tape();
assert.equal(reading.prograde, null);
near(reading.retrograde, -1.5, 'the stern, under W', 1e-6);
const west = store.instruments.compass.find(({ label }) => label === 'W');
near(west.slot, reading.retrograde, 'W over the stern mark', 1e-6);
assert.equal($('.cockpit-compass-prograde'), null);
assert.equal(
$('.cockpit-compass-retrograde').style.getPropertyValue('--slot'),
String(reading.retrograde),
);
});
test('a held arrow keeps turning the bridge view', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit } = await bridgeAt(t, clock);
press(cockpit, 'ArrowLeft');
for (let index = 0; index < 5; index += 1) {
const event = press(cockpit, 'ArrowLeft', { repeat: true });
assert.equal(event.defaultPrevented, true);
}
assert.equal(cockpit.lookTarget.yawDeg, -30);
// A held Home still acts only once.
press(cockpit, 'Home', { repeat: true });
assert.equal(cockpit.lookTarget.yawDeg, -30);
});
test('Home, 0 and LOOK AHEAD bring the bridge view back over the bow', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit, shots, store, $ } = await bridgeAt(t, clock);
const turnAway = () => {
press(cockpit, 'ArrowLeft', { shiftKey: true });
press(cockpit, 'ArrowDown');
frames(cockpit, clock, shots);
};
const resets = [
() => press(cockpit, 'Home'),
() => press(cockpit, '0'),
() => $('#cockpit-look-ahead').click(),
];
for (const reset of resets) {
turnAway();
await settle();
assert.equal($('.cockpit-look').hidden, false);
assert.equal(
$('#cockpit-look-ahead').getAttribute('aria-label'),
'Look ahead: turn the bridge view back to the bow',
);
reset();
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
// It eases back rather than snapping.
clock.advance(50);
cockpit.update();
const easing = viewAngles(shots.at(-1)).headingDeg;
assert.ok(easing > 46 && easing < 89, `easing heading ${easing}`);
const view = frames(cockpit, clock, shots);
near(view.headingDeg, RECORD.heading, 'back over the bow');
near(view.pitchDeg, -2, 'back at the rest pitch');
await settle();
assert.equal(store.instruments.look.offset, false);
assert.equal($('.cockpit-look').hidden, true);
assert.equal($('.cockpit-radar-look'), null);
}
});
test('arrows keep their meaning in sliders, fields and open utilities', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit } = await bridgeAt(t, clock);
const slider = document.body.appendChild(document.createElement('div'));
slider.setAttribute('role', 'slider');
const thumb = slider.appendChild(document.createElement('span'));
const field = document.body.appendChild(document.createElement('input'));
for (const target of [slider, thumb, field]) {
for (const key of ['ArrowLeft', 'ArrowUp', 'Home', '0']) {
const event = press(cockpit, key, { target });
assert.equal(
event.defaultPrevented,
false,
`${key} in ${target.tagName}`,
);
}
}
cockpit.isUtilityDisclosureOpen = () => true;
assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false);
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
// A plain HUD button, where focus sits after entry, still turns the view.
cockpit.isUtilityDisclosureOpen = () => false;
const exit = document.getElementById('map-view-switch');
const event = press(cockpit, 'ArrowRight', { target: exit });
assert.equal(event.defaultPrevented, true);
assert.equal(cockpit.lookTarget.yawDeg, 5);
});
test('the bridge view opens over the bow on every entry', async (t) => {
const clock = manualClock(t, START_MS);
const { cockpit, shots, store } = await bridgeAt(t, clock);
press(cockpit, 'ArrowRight', { shiftKey: true });
press(cockpit, 'ArrowUp');
frames(cockpit, clock, shots, 5);
cockpit.exit();
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
assert.deepEqual(cockpit.lookView, { yawDeg: 0, pitchDeg: -2 });
assert.equal(store.instruments.look.offset, false);
assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false);
cockpit.enter();
await settle();
clock.advance(50);
cockpit.update();
const view = viewAngles(shots.at(-1));
near(view.headingDeg, RECORD.heading, 're-entry looks over the bow');
near(view.pitchDeg, -2, 're-entry at the rest pitch');
});
test('arrows do nothing new in an aircraft cockpit', async (t) => {
const clock = manualClock(t, START_MS);
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft, shots, store } = fixture(t, {
aircraftInfo,
});
viewer.trackedEntity = aircraft;
assert.equal(cockpit.enter({ kind: 'aircraft' }), true);
await settle();
assert.equal(cockpit.subjectKind, 'aircraft');
const before = frames(cockpit, clock, shots, 3);
near(before.headingDeg, 45, 'the aircraft looks along its track');
const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', '0'];
for (const key of keys) {
for (const shiftKey of [false, true]) {
const event = press(cockpit, key, { shiftKey });
assert.equal(event.defaultPrevented, false, `${key} in an aircraft`);
}
}
const held = press(cockpit, 'ArrowLeft', { repeat: true });
assert.equal(held.defaultPrevented, false);
const after = frames(cockpit, clock, shots, 3);
// The aircraft flies on, so its local frame shifts by a hair.
near(after.headingDeg, before.headingDeg, 'the aircraft heading', 1e-6);
near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6);
assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 });
assert.equal(store.instruments.look.offset, false);
assert.equal(document.querySelector('.cockpit-look'), null);
// The tape stays on the aircraft's heading, with no bow or stern marks.
const centre = store.instruments.compass.find(
(division) => division.slot === 0,
);
assert.equal(centre.label, '060');
assert.deepEqual(store.instruments.compassMarkers, {
prograde: null,
retrograde: null,
});
assert.equal(document.querySelector('.cockpit-compass-marker'), null);
assert.doesNotMatch(
document.querySelector('.cockpit-help').textContent,
/ARROWS LOOK/,
);
});
test('C neither enters nor leaves an aircraft cockpit', async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft } = fixture(t, { aircraftInfo });
viewer.trackedEntity = aircraft;
const outside = press(cockpit, 'c');
await settle();
assert.equal(cockpit.active, false, 'C does not enter');
assert.equal(outside.defaultPrevented, false);
assert.equal(outside.propagationStopped, false);
assert.equal(cockpit.enter({ kind: 'aircraft' }), true);
await settle();
const inside = press(cockpit, 'c');
assert.equal(cockpit.active, true, 'C does not exit');
assert.equal(inside.propagationStopped, false);
assert.doesNotMatch(
document.querySelector('.cockpit-help').textContent,
/C TOGGLE/,
);
assert.doesNotMatch(
document
.querySelector('#map-view-switch')
.getAttribute('aria-keyshortcuts'),
/C/,
);
});
/** The eye's ellipsoidal height in a placed camera, metres. */
const eyeHeightM = (shot) =>
Cesium.Cartographic.fromCartesian(shot.destination).height;
test('a bridge on a river above sea level rides the water, not the geoid', async (t) => {
const clock = manualClock(t, START_MS);
// Sea level, as every ship is floated today.
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const river = fixture(t, { record });
const probed = [];
// The ship's own model reads 40 m; the ring finds the river at 27 m and
// a quay at 33 m.
river.viewer.scene.sampleHeightMostDetailed = async (positions) => {
probed.push(positions.length);
return positions.map((position, index) =>
Object.assign(position, {
height: index === 0 ? 40 : index % 2 ? 27 : 33,
}),
);
};
river.cockpit.enter();
await settle();
river.cockpit.update();
await settle();
frames(river.cockpit, clock, river.shots, 60);
near(
eyeHeightM(river.shots.at(-1)) - seaEyeM,
27,
'raised by the river',
0.5,
);
assert.equal(probed[0], 9, 'the ship and eight probes around it');
const reads = probed.length;
frames(river.cockpit, clock, river.shots, 20);
await settle();
assert.equal(probed.length, reads, 'no new read inside five seconds');
frames(river.cockpit, clock, river.shots, 80);
await settle();
assert.ok(probed.length > reads, 'a fresh read every five seconds');
});
test('a bridge at sea keeps the geoid when the probes find no raised water', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const open = fixture(t, { record });
// Swell and tiles a metre off the geoid, and a probe that found nothing.
open.viewer.scene.sampleHeightMostDetailed = async (positions) =>
positions.map((position, index) =>
Object.assign(position, { height: index === 3 ? undefined : 1 }),
);
open.cockpit.enter();
await settle();
open.cockpit.update();
await settle();
frames(open.cockpit, clock, open.shots, 60);
near(eyeHeightM(open.shots.at(-1)), seaEyeM, 'still on the geoid', 0.5);
});
test('a bridge rides the river at once from the tiles already loaded', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const river = fixture(t, { record });
// The full-detail read is still loading tiles; the loaded ones already
// show the river at 27 m beside a quay at 33 m.
river.viewer.scene.sampleHeightMostDetailed = () => new Promise(() => {});
let index = 0;
river.viewer.scene.sampleHeight = () => (index++ % 2 ? 27 : 33);
river.cockpit.enter();
await settle();
river.cockpit.update();
frames(river.cockpit, clock, river.shots, 60);
near(
eyeHeightM(river.shots.at(-1)) - seaEyeM,
27,
'on the river before the detailed read',
0.5,
);
});
/**
* A scene that draws a world point at (400, 300 − its height in metres): a
* mark's height above the ellipsoid moves it on screen.
*/
function heightSensitiveProjection(viewer, { x = 400 } = {}) {
viewer.scene.cartesianToCanvasCoordinates = (position, result) => {
const { height } = Cesium.Cartographic.fromCartesian(position);
return Object.assign(result, { x, y: 300 - height });
};
}
test('a vessel double-click aims at the sprite as drawn, on the geoid', async (t) => {
const { cockpit, viewer, store } = fixture(t);
// The Baltic geoid sits ~35 m above the ellipsoid; the sprite 3 m above it.
const subject = cockpit.services.aisLiveVesselsLayer.getCockpitSubject;
cockpit.services.aisLiveVesselsLayer.getCockpitSubject = () => ({
...subject(),
seaSurfaceHeightM: 35,
markHeightM: 38,
});
heightSensitiveProjection(viewer);
// Where an ellipsoid-height projection would put it: no hit.
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false);
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 262 }), true);
await settle();
assert.equal(store.presence.active, true);
});
test('a vessel double-click lands even when the first click moved the ship', async (t) => {
const { cockpit, viewer, store } = fixture(t);
heightSensitiveProjection(viewer);
// The first press finds the ship at (400, 300); selecting it again
// re-centres the camera, so by the double-click it is drawn at (600, 300).
cockpit.onScenePointerDown(1000);
heightSensitiveProjection(viewer, { x: 600 });
// Too late to count as one gesture.
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1800), false);
cockpit.onScenePointerDown(2000);
heightSensitiveProjection(viewer, { x: 800 });
assert.equal(cockpit.onSceneDoubleClick({ x: 600, y: 300 }, 2300), true);
await settle();
assert.equal(store.presence.active, true);
});
test('re-entering a ship reads its water afresh, never the last session height', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
const scene = ship.viewer.scene;
// First visit: on a river 27 m up.
scene.sampleHeightMostDetailed = async (positions) =>
positions.map((position) => Object.assign(position, { height: 27 }));
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
frames(ship.cockpit, clock, ship.shots, 60);
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5);
ship.cockpit.exit();
await settle();
// Back on the same ship, now at sea; the detailed read is slow, the
// loaded tiles already show the sea.
let late;
scene.sampleHeightMostDetailed = () =>
new Promise((resolve) => {
late = resolve;
});
scene.sampleHeight = () => 0.5;
ship.cockpit.enter();
await settle();
ship.cockpit.update();
frames(ship.cockpit, clock, ship.shots, 60);
near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'at sea, not on the river', 0.5);
// A read from before the exit, landing now, changes nothing.
ship.cockpit.exit();
ship.cockpit.enter();
await settle();
late?.([{ height: 27 }]);
await settle();
ship.cockpit.update();
frames(ship.cockpit, clock, ship.shots, 60);
near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'the late read is dropped', 0.5);
});
test('a failed water read keeps the sea and tries again', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
let reads = 0;
ship.viewer.scene.sampleHeightMostDetailed = async () => {
reads += 1;
throw new Error('tiles unavailable');
};
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
frames(ship.cockpit, clock, ship.shots, 60);
near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'still on the sea', 0.5);
frames(ship.cockpit, clock, ship.shots, 120);
await settle();
assert.ok(reads >= 2, `retried: ${reads} reads`);
});
test('the water is read where the ship is now, not at its last report', async (t) => {
const clock = manualClock(t, START_MS);
// Reported a minute ago at 12 kn on course 095: since then ~370 m east.
const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 };
const ship = fixture(t, { record });
const centres = [];
ship.viewer.scene.sampleHeightMostDetailed = async (positions) => {
centres.push(Cesium.Math.toDegrees(positions[0].longitude));
return positions;
};
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
assert.ok(centres.length > 0, 'a read was made');
const eastM =
(centres[0] - RECORD.lon) *
111_320 *
Math.cos((RECORD.lat * Math.PI) / 180);
assert.ok(eastM > 300 && eastM < 450, `probed ${eastM.toFixed(0)} m east`);
});
test('the bridge floor is read where the ship is now, not at its last report', async (t) => {
const clock = manualClock(t, START_MS);
// Reported a minute ago at 12 kn on course 095: since then ~370 m east.
const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 };
const ship = fixture(t, { record });
const floors = [];
const cached = ship.cockpit.services.cachedGroundFloor;
ship.cockpit.services.cachedGroundFloor = (lat, lon) => {
floors.push({ lat, lon });
return cached(lat, lon);
};
ship.cockpit.enter();
await settle();
frames(ship.cockpit, clock, ship.shots, 20);
const first = floors[0];
assert.ok(first, 'the floor was read');
const eastM =
(first.lon - RECORD.lon) * 111_320 * Math.cos((RECORD.lat * Math.PI) / 180);
assert.ok(eastM > 300 && eastM < 450, `read ${eastM.toFixed(0)} m east`);
});
test('the radar centres where the camera rides, not at the last report', async (t) => {
const clock = manualClock(t, START_MS);
// Reported a minute ago at 12 kn on course 095: since then ~370 m east.
const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 };
const ship = fixture(t, { record });
ship.cockpit.enter();
await settle();
frames(ship.cockpit, clock, ship.shots, 20);
await settle();
const query = ship.vessels.radarQueries.at(-1);
const eastM =
(query.lon - RECORD.lon) * 111_320 * Math.cos((RECORD.lat * Math.PI) / 180);
assert.ok(eastM > 300 && eastM < 450, `centred ${eastM.toFixed(0)} m east`);
});
test('the radar look line turns the short way across astern', async (t) => {
const clock = manualClock(t, START_MS);
const { $, store } = await bridgeAt(t, clock);
const lookAt = async (yawDeg) => {
store.publishInstruments({
...store.instruments,
look: { ...store.instruments.look, offset: true, yawDeg },
});
await settle();
return $('.cockpit-radar-look')?.getAttribute('style') ?? '';
};
assert.match(await lookAt(170), /rotate\(170deg\)/);
// From 170° to -170° is 20° through astern, not 340° back over the bow.
assert.match(await lookAt(-170), /rotate\(190deg\)/);
});
test('a ship with no true heading is held, and labelled, on its course', async (t) => {
const clock = manualClock(t, START_MS);
clock.set(START_MS);
const record = {
...RECORD,
heading: null,
lastPositionEpoch: START_MS / 1000,
};
const { $, cockpit } = fixture(t, { record });
const signals = [];
cockpit.pushCockpitSignal = (...args) => signals.push(args);
cockpit.enter();
await settle();
cockpit.update();
await settle();
assert.match(
$('.cockpit-visor-status-right').textContent,
/COURSE HOLD · AIS/,
);
assert.ok(
signals.some(([, , , detail]) => /COURSE 095°/.test(detail)),
JSON.stringify(signals),
);
});
test('both entries name the subject the same way', async () => {
const { cockpitEntryFace } = await import('./cockpitPresentation.js');
assert.deepEqual(cockpitEntryFace('vessel'), {
icon: 'directions_boat',
label: 'BRIDGE',
});
assert.deepEqual(cockpitEntryFace('satellite'), {
icon: 'satellite_alt',
label: 'ORBIT',
});
assert.deepEqual(cockpitEntryFace('aircraft'), {
icon: 'flight',
label: 'COCKPIT',
});
assert.deepEqual(cockpitEntryFace(null), cockpitEntryFace('aircraft'));
});
test('a ship with neither heading nor course makes up no direction', async (t) => {
const clock = manualClock(t, START_MS);
const record = {
...RECORD,
heading: null,
course: null,
lastPositionEpoch: START_MS / 1000,
};
const { $, cockpit, viewer, shots, store } = fixture(t, { record });
// The map camera looks south-south-west as the bridge opens.
viewer.camera.heading = Cesium.Math.toRadians(200);
const signals = [];
cockpit.pushCockpitSignal = (...args) => signals.push(args);
cockpit.enter();
await settle();
cockpit.update();
const start = shots.at(-1).destination;
// Ten seconds at 12 kn would carry a made-up course some 60 m.
const view = frames(cockpit, clock, shots, 200);
await settle();
near(view.headingDeg, 200, 'keeps the entry camera heading');
const driftM = Cesium.Cartesian3.distance(start, shots.at(-1).destination);
assert.ok(driftM < 1, `the eye stays on the fix, drifted ${driftM} m`);
assert.equal(store.instruments.heading.text, '---');
assert.deepEqual(store.instruments.compassMarkers, {
prograde: null,
retrograde: null,
});
assert.equal(document.querySelector('.cockpit-compass-marker'), null);
assert.match(
$('.cockpit-visor-status-right').textContent,
/NO HEADING · AIS/,
);
const details = signals.map(([, , , detail]) => detail);
assert.ok(details.includes('NORDIC STAR · NO HEADING'), details.join(' | '));
assert.ok(!details.some((detail) => /000°/.test(detail)), details.join());
});
test('the bridge tape reads the exact heading at the caret', async (t) => {
const clock = manualClock(t, START_MS);
const record = {
...RECORD,
heading: 100,
lastPositionEpoch: START_MS / 1000,
};
const { cockpit, shots, store } = fixture(t, { record });
cockpit.enter();
await settle();
frames(cockpit, clock, shots, 40);
await settle();
const { compass, compassMarkers } = store.instruments;
// Over the bow at 100°: the bow mark sits under the caret, and the tape
// has scrolled a third of a division past 090.
near(compassMarkers.prograde, 0, 'the bow under the caret', 1e-3);
const nearest = compass.find((division) => division.active);
assert.equal(nearest.label, 'E');
near(nearest.slot, -1 / 3, 'E a third of a division to port', 1e-3);
const tape = document.querySelector('#cockpit-compass-tape');
near(
Number(tape.style.getPropertyValue('--tape-shift')),
-1 / 3,
'the minor ticks scroll with it',
1e-3,
);
const east = [...tape.querySelectorAll('span')].find(
(span) => span.textContent === 'E',
);
assert.equal(east.className, 'active');
near(Number(east.style.getPropertyValue('--slot')), -1 / 3, 'E drawn', 1e-3);
});
test('a slow water read is never stacked, and lands however late', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
const reads = [];
// Every full-detail read hangs until the test answers it.
ship.viewer.scene.sampleHeightMostDetailed = (positions) =>
new Promise((resolve) => reads.push({ positions, resolve }));
let loadedReads = 0;
ship.viewer.scene.sampleHeight = () => {
loadedReads += 1;
return 0;
};
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
assert.equal(reads.length, 1);
const loadedAtStart = loadedReads;
// Twelve seconds on, still one read in flight; the refreshes read the
// loaded tiles meanwhile.
frames(ship.cockpit, clock, ship.shots, 240);
await settle();
assert.equal(reads.length, 1, 'no second read while the first runs');
assert.ok(loadedReads > loadedAtStart, 'the loaded tiles are read again');
// The 12 s read lands (a river at 27 m) and applies.
const [slow] = reads;
slow.resolve(
slow.positions.map((position) => Object.assign(position, { height: 27 })),
);
await settle();
frames(ship.cockpit, clock, ship.shots, 120);
await settle();
near(
eyeHeightM(ship.shots.at(-1)) - seaEyeM,
27,
'raised by the late read',
0.5,
);
// Settled, the next refresh starts a new read.
assert.equal(reads.length, 2);
// It hangs over tiles not loaded yet: the refreshes sample nothing, and the
// river height stands rather than falling back to the sea.
ship.viewer.scene.sampleHeight = () => undefined;
frames(ship.cockpit, clock, ship.shots, 240);
await settle();
assert.equal(reads.length, 2);
near(
eyeHeightM(ship.shots.at(-1)) - seaEyeM,
27,
'kept through refreshes that sample nothing',
0.5,
);
});
test('a river height stands through reads that sample nothing or one coarse probe', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
const reads = [];
ship.viewer.scene.sampleHeightMostDetailed = (positions) =>
new Promise((resolve) => reads.push({ positions, resolve }));
ship.viewer.scene.sampleHeight = () => undefined;
const answer = (read, height) =>
read.resolve(
read.positions.map((position) => Object.assign(position, { height })),
);
ship.cockpit.enter();
await settle();
ship.cockpit.update();
await settle();
answer(reads[0], 27);
await settle();
frames(ship.cockpit, clock, ship.shots, 120);
await settle();
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5);
// The next full read lands over tiles not loaded yet: every probe empty.
assert.equal(reads.length, 2);
answer(reads[1], undefined);
await settle();
frames(ship.cockpit, clock, ship.shots, 120);
await settle();
near(
eyeHeightM(ship.shots.at(-1)) - seaEyeM,
27,
'an empty full read is no answer',
0.5,
);
// While the next one runs, coarse loaded tiles read the sea.
assert.equal(reads.length, 3);
ship.viewer.scene.sampleHeight = () => 0.5;
frames(ship.cockpit, clock, ship.shots, 240);
await settle();
assert.equal(reads.length, 3, 'still the one read in flight');
near(
eyeHeightM(ship.shots.at(-1)) - seaEyeM,
27,
'loaded tiles do not undo a full read',
0.5,
);
});
test('before any full read lands, a coarse loaded read keeps the river it found', async (t) => {
const clock = manualClock(t, START_MS);
const sea = await bridgeAt(t, clock);
frames(sea.cockpit, clock, sea.shots, 60);
const seaEyeM = eyeHeightM(sea.shots.at(-1));
sea.cockpit.exit();
clock.set(START_MS);
const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 };
const ship = fixture(t, { record });
// The full-detail read is cold and never lands in this test.
ship.viewer.scene.sampleHeightMostDetailed = () => new Promise(() => {});
ship.viewer.scene.sampleHeight = () => 27;
ship.cockpit.enter();
await settle();
ship.cockpit.update();
frames(ship.cockpit, clock, ship.shots, 60);
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5);
// A refresh meets one low probe on coarse tiles.
ship.viewer.scene.sampleHeight = () => 0.5;
frames(ship.cockpit, clock, ship.shots, 240);
near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'still on the river', 0.5);
});
test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => {
// The feed's newest fix is this ship's; the client clock runs 200 s
// ahead of the feed and received the refresh just now.
const skewMs = 200_000;
const clock = manualClock(t, START_MS + skewMs);
const record = {
...RECORD,
lastPositionEpoch: START_MS / 1000,
fixFeedEpoch: START_MS / 1000,
fixReceivedAtMs: START_MS + skewMs,
};
const { $, cockpit, shots } = fixture(t, { record });
cockpit.enter();
await settle();
cockpit.update();
frames(cockpit, clock, shots, 20);
await settle();
assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/);
assert.doesNotMatch($('#cockpit-aircraft-meta').textContent, /STALE/);
});
test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (t) => {
const clock = manualClock(t, START_MS);
// The layer's own records, polled every 30 s with the same fix, as a feed
// serving its cache does.
const records = new VesselRecords({ now: () => Date.now() });
const effects = {
add() {},
remove() {},
beforeUpdate() {},
updated() {},
staleSelected() {},
removed() {},
};
const poll = () =>
records.reconcile(
[
{
...RECORD,
last_position_epoch: START_MS / 1000,
},
],
{ complete: true },
effects,
);
poll();
const record = records.byId.get(RECORD.id);
const { $, cockpit, shots } = fixture(t, { record });
cockpit.enter();
await settle();
cockpit.update();
await settle();
assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/);
// Seven polls later (210 s), the fix is past the 180 s projection.
for (let polls = 0; polls < 7; polls += 1) {
frames(cockpit, clock, shots, 600);
poll();
}
frames(cockpit, clock, shots, 20);
await settle();
assert.match($('#cockpit-aircraft-meta').textContent, /STALE FEED/);
});
test('a followed aircraft keeps its own double-click hit area', async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft } = fixture(t, { aircraftInfo });
viewer.trackedEntity = aircraft;
heightSensitiveProjection(viewer);
// Drawn at (400, -2700) as the press begins, at (600, -2700) by the
// double-click: a click there is not on the aircraft.
cockpit.onScenePointerDown(1000);
heightSensitiveProjection(viewer, { x: 600 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: -2700 }, 1250), false);
assert.equal(cockpit.active, false);
});
test("a ship's double-click memory does not carry over to an aircraft followed since", async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft } = fixture(t, { aircraftInfo });
heightSensitiveProjection(viewer);
// The press finds the ship at (400, 300).
cockpit.onScenePointerDown(1000);
// Within the memory, an aircraft is followed, drawn elsewhere.
viewer.trackedEntity = aircraft;
heightSensitiveProjection(viewer, { x: 900 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
assert.equal(cockpit.active, false);
});
test("a ship's double-click memory does not carry over to another ship", async (t) => {
const { cockpit, viewer, vessels } = fixture(t);
heightSensitiveProjection(viewer);
cockpit.onScenePointerDown(1000);
// Within the memory another ship is selected, drawn elsewhere.
vessels.select({
...RECORD,
id: '211000009',
mmsi: '211000009',
name: 'OTHER',
});
heightSensitiveProjection(viewer, { x: 900 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
assert.equal(cockpit.active, false);
});
test("a double-click's second press keeps the first press's picture of the ship", async (t) => {
const { cockpit, viewer, store } = fixture(t);
heightSensitiveProjection(viewer);
// The first press finds the ship at (400, 300); the first click
// re-centres the camera, so the second press finds it at (600, 300).
cockpit.onScenePointerDown(1000);
heightSensitiveProjection(viewer, { x: 600 });
cockpit.onScenePointerDown(1200);
// By the double-click it has moved again; the gesture began at (400, 300).
heightSensitiveProjection(viewer, { x: 800 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), true);
await settle();
assert.equal(store.presence.active, true);
});