mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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.
1569 lines
54 KiB
JavaScript
1569 lines
54 KiB
JavaScript
// 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);
|
||
});
|