Files
gods-eye-view/src/cockpitMath.test.mjs
T
Denis Groshev c24352ff5e fix(cockpit): an application subject keeps its rates on a slow update interval, its ground lift eases out, and its HUD shows a speed and a progress name only when given
An application subject's camera step is now capped at its own update
interval plus 0.1 s rather than at 0.1 s, so at 500 ms its heading slew,
ground lift and anchor correction run at their configured rates; a long
stall still moves it no further than one late update. The built-in
views keep their 0.1 s cap.

When groundClamp turns false the lift the floor gave goes at the rate
it came, not at once. A HUD view with no speed has no speed readout,
and a progress line with no summary is hidden from assistive
technology instead of being an unnamed image.
2026-11-12 17:00:12 +01:00

490 lines
18 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
altitudeRulerStep,
altitudeRulerTicks,
altitudeRulerCurveInset,
bearingBetweenCoordinates,
cockpitAnchorCorrectionStep,
cockpitAltitudeDisplayFt,
cockpitGroundSafeHeight,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
easeCockpitLift,
compassDivisions,
compassMarkerSlot,
scrollingCompassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatHeadingReadout,
formatSpeedRulerTick,
normalizeHeading,
relativeBearing,
resolveCockpitContextReadout,
resolveHudRailLayout,
resolveTrackedAircraftInfo,
slewHeading,
speedRulerStep,
speedRulerTicks,
} from './cockpitMath.js';
test('cockpit presentation updates are throttled independently of camera frames', () => {
assert.equal(cockpitUiUpdateDue(1000, 0, 100), true);
assert.equal(cockpitUiUpdateDue(1099, 1000, 100), false);
assert.equal(cockpitUiUpdateDue(1100, 1000, 100), true);
assert.equal(cockpitUiUpdateDue(10, 1000, 100), true);
});
test('grounded cockpit surface acquisition has a bounded wait', () => {
assert.equal(cockpitSurfaceWaitExpired(5999, 1000, 5000), false);
assert.equal(cockpitSurfaceWaitExpired(6000, 1000, 5000), true);
});
test('cockpit anchor correction cannot turn a forward step into a reversal', () => {
const speedMps = 200;
const dtSec = 0.1;
const forwardStepM = speedMps * dtSec;
const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec);
assert.ok(backwardCorrectionM > 0);
assert.ok(backwardCorrectionM < forwardStepM);
});
test('cockpit anchor correction bounds late-fix catch-up and render stalls', () => {
assert.ok(cockpitAnchorCorrectionStep(5000, 250, 2) <= 5.5);
assert.equal(cockpitAnchorCorrectionStep(0, 250, 0.1), 0);
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
});
test('an application subject on a slow cadence is corrected for its whole step', () => {
// The built-in views' step stays capped at 0.1 s...
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
// ...while a subject updated every 500 ms is corrected for all of it.
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
});
test('an application subject steps by its interval, never by a long stall', () => {
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
// A hidden tab's minutes count as one late update.
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
});
test('cockpit camera clearance never falls below the rendered ground floor', () => {
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
assert.equal(cockpitGroundSafeHeight(47, null, 12), 47);
assert.equal(cockpitGroundSafeHeight(47, 57, -4), 57);
});
test('cockpit altitude reads zero on the ground without changing airborne MSL', () => {
assert.equal(cockpitAltitudeDisplayFt(200.56, true), 0);
assert.ok(Math.abs(cockpitAltitudeDisplayFt(3048, false) - 10000) < 0.01);
assert.equal(cockpitAltitudeDisplayFt(null, false), null);
});
test('cockpit Context scope distinguishes radius-complete feeds from viewport installations', () => {
assert.equal(
formatCockpitContextScope('TEST123', 250000, 'CURRENT VIEWPORT ONLY'),
'TEST123 · 250 KM AIR/SEA WINDOW · INSTALLATIONS CURRENT VIEWPORT ONLY',
);
assert.equal(
formatCockpitContextScope('TEST123', 250000),
'TEST123 · 250 KM AIR/SEA WINDOW',
);
});
test('cockpit Context scope preserves zero and replaces missing values intentionally', () => {
assert.equal(
formatCockpitContextScope('', 0),
'— · 0 KM AIR/SEA WINDOW',
);
assert.equal(
formatCockpitContextScope(undefined, Number.NaN, {}),
'— · — KM AIR/SEA WINDOW',
);
});
test('altitude ruler chooses tighter intervals close to the surface', () => {
assert.equal(altitudeRulerStep(1200), 100);
assert.equal(altitudeRulerStep(9000), 250);
assert.equal(altitudeRulerStep(34000), 500);
assert.equal(altitudeRulerStep(Number.NaN), 500);
});
test('altitude ruler ticks move fractionally behind the fixed pointer', () => {
const ticks = altitudeRulerTicks(34875);
assert.equal(ticks.length, 9);
assert.equal(ticks[4].valueFt, 34500);
assert.equal(ticks[4].slot, -0.75);
assert.equal(ticks[5].valueFt, 35000);
assert.equal(ticks[5].slot, 0.25);
assert.equal(ticks[5].major, true);
});
test('altitude ruler clamps below ground and formats fixed-width labels', () => {
assert.equal(altitudeRulerTicks(-20)[4].valueFt, 0);
assert.equal(formatAltitudeRulerTick(900), '00900');
assert.equal(formatAltitudeRulerTick(34874.6), '34875');
assert.equal(formatAltitudeRulerTick(null), '-----');
});
test('altitude ruler curve follows the circular keyhole at wide aspect ratios', () => {
assert.equal(altitudeRulerCurveInset(0), 0);
assert.equal(altitudeRulerCurveInset(-4), altitudeRulerCurveInset(4));
assert.ok(Math.abs(altitudeRulerCurveInset(4) - 0.231625) < 0.00001);
});
test('speed ruler chooses useful taxi, regional, and cruise intervals', () => {
assert.equal(speedRulerStep(45), 10);
assert.equal(speedRulerStep(180), 20);
assert.equal(speedRulerStep(460), 25);
assert.equal(speedRulerStep(Number.NaN), 25);
});
test('speed ruler ticks move smoothly behind a fixed pointer', () => {
const ticks = speedRulerTicks(467);
assert.equal(ticks.length, 9);
assert.equal(ticks[4].valueKt, 450);
assert.ok(Math.abs(ticks[4].slot + 0.68) < 0.00001);
assert.equal(ticks[5].valueKt, 475);
assert.ok(Math.abs(ticks[5].slot - 0.32) < 0.00001);
assert.equal(formatSpeedRulerTick(8), '008');
assert.equal(formatSpeedRulerTick(null), '---');
});
test('normalizeHeading wraps negative and oversized courses', () => {
assert.equal(normalizeHeading(-1), 359);
assert.equal(normalizeHeading(721), 1);
});
test('slewHeading follows the shortest arc across north', () => {
assert.equal(slewHeading(350, 10, 5), 355);
assert.equal(slewHeading(10, 350, 5), 5);
});
test('compass divisions remain ordered across north', () => {
assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]);
});
test('compass marks sit in division slots off the caret, or off the tape', () => {
// Measured from the exact heading under the caret, not the nearest division.
assert.equal(compassMarkerSlot(350, 350), 0);
assert.equal(compassMarkerSlot(35, 350), 1.5);
assert.equal(compassMarkerSlot(260, 350), -3);
assert.equal(compassMarkerSlot(80, 350), 3);
assert.equal(compassMarkerSlot(90, 350), null);
assert.equal(compassMarkerSlot(180, 350), null);
assert.equal(compassMarkerSlot(Number.NaN, 0), null);
});
test('a scrolling tape puts the exact heading under the caret', () => {
// At 100° the divisions sit a third of a slot off their snapped places,
// so the bow mark (also 100°) lines up with the caret, not 10° beside it.
const tape = scrollingCompassDivisions(100);
assert.deepEqual(
tape.map(({ heading }) => heading),
[330, 0, 30, 60, 90, 120, 150, 180, 210],
);
const nearest = tape.find((division) => division.nearest);
assert.equal(nearest.heading, 90);
assert.equal(nearest.slot, -0.3333);
assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667);
assert.equal(compassMarkerSlot(100, 100), 0);
assert.equal(compassMarkerSlot(90, 100), nearest.slot);
// Across north the slots stay continuous.
assert.deepEqual(
scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [
heading,
slot,
]),
[
[330, -0.8333],
[0, 0.1667],
[30, 1.1667],
],
);
assert.deepEqual(scrollingCompassDivisions(Number.NaN), []);
});
test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(270), 'W');
assert.equal(formatCompassDivision(30), '030');
});
test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.6), '000');
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
test('great-circle bearing resolves cardinal directions', () => {
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001);
assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001);
assert.equal(bearingBetweenCoordinates(0, 0, 0, 0), null);
});
test('relative bearing stays on the shortest signed arc', () => {
assert.equal(relativeBearing(10, 350), 20);
assert.equal(relativeBearing(350, 10), -20);
assert.equal(relativeBearing(180, 0), -180);
assert.equal(relativeBearing(null, 0), null);
});
test('HUD rail layout centers between intersecting upper and lower obstacles', () => {
assert.deepEqual(resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 200,
laneLeft: 50,
laneRight: 410,
baseTop: 280,
baseBottom: 940,
gap: 10,
obstacles: [
{ left: 40, right: 240, top: 320, bottom: 460 },
{ left: 300, right: 1000, top: 780, bottom: 930 },
{ left: 900, right: 1100, top: 300, bottom: 700 },
],
}), {
top: 520,
maxHeight: 300,
safeTop: 470,
safeBottom: 770,
constrained: false,
});
});
test('HUD rail layout constrains an oversized panel to the safe corridor', () => {
const layout = resolveHudRailLayout({
viewportHeight: 800,
panelHeight: 300,
laneLeft: 20,
laneRight: 340,
baseTop: 250,
baseBottom: 700,
obstacles: [{ left: 0, right: 350, top: 500, bottom: 760 }],
});
assert.equal(layout.top, 250);
assert.equal(layout.maxHeight, 238);
assert.equal(layout.constrained, true);
});
test('HUD rail layout can anchor controls to the start of the safe corridor', () => {
const layout = resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 100,
laneLeft: 900,
laneRight: 1250,
baseTop: 260,
baseBottom: 940,
align: 'start',
});
assert.equal(layout.top, 260);
assert.equal(layout.safeTop, 260);
assert.equal(layout.safeBottom, 940);
});
// --- Contact panel readout (Monday recording bug: NEXT onto a non-aircraft) ---
const TRACKED_INFO = Object.freeze({
layerId: 'flights',
icao24: 'aaa077',
latitude: 30.2,
longitude: -97.7,
});
function contextSnapshot(subject, extra = {}) {
return {
subject,
evaluatedAt: 1_700_000_000_000,
radiusM: 250_000,
cohorts: [],
navigation: { canPrevious: true, canNext: true },
...extra,
};
}
test('Contact panel hides only when there is no snapshot at all', () => {
const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO });
assert.equal(readout.visible, false);
assert.equal(readout.mode, 'standby');
assert.equal(
resolveCockpitContextReadout({ info: TRACKED_INFO }).visible,
false,
'a missing snapshot argument is the same standby case',
);
});
test('Contact panel survives NEXT onto a vessel or installation subject', () => {
// The panel owns the NEXT button. Hiding it because the subject is not the
// tracked aircraft strands the operator with no way back (owner hit this on
// camera: "click next... whole left panel disappears").
for (const subject of [
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
{ layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' },
{ layerId: 'military', id: 'ae01ce', label: 'RCH451' },
]) {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot(subject),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`);
assert.equal(readout.mode, 'foreign');
assert.equal(readout.subjectMatchesTracked, false);
}
});
test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
assert.equal(readout.mode, 'foreign');
// Nose-relative direction/bearing are measured in the tracked aircraft's own
// frame while every other row value is measured from the subject; rendering
// both live would present one mixed-frame reading as a single measurement.
assert.equal(readout.aircraftRelative, false);
assert.equal(readout.contactLost, false, 'subject-frame values still refresh');
});
test('the tracked aircraft as subject keeps every field in its own frame', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
assert.equal(readout.mode, 'tracked');
assert.equal(readout.subjectMatchesTracked, true);
assert.equal(readout.aircraftRelative, true);
assert.equal(readout.contactLost, false);
});
test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collapsing it', () => {
for (const subject of [
{ layerId: 'flights', id: 'aaa077', label: 'SWA1234' },
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
]) {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot(subject, { subjectPresent: false }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true, 'a lost contact must not take the panel down');
assert.equal(readout.mode, 'lost');
assert.equal(readout.contactLost, true, 'last-known values stay on screen');
}
});
test('an explicitly present subject is never reported lost', () => {
const present = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }),
info: TRACKED_INFO,
});
assert.equal(present.contactLost, false);
assert.equal(present.mode, 'tracked');
// Snapshots from before the presence field existed must not read as lost.
const legacy = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }),
info: TRACKED_INFO,
});
assert.equal(legacy.contactLost, false);
});
test('the tracked flight layer is resolved by normalized tracked identity', () => {
const civilian = { icao24: 'aaa077', callsign: 'SWA1234' };
const military = { icao24: 'ae01ce', callsign: 'RCH451' };
// Both layers describe a tracked aircraft during a cross-layer handoff;
// civilian-first precedence would hand the cockpit the wrong aircraft.
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId,
'military',
);
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24,
'ae01ce',
);
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId,
'flights',
);
// Case-insensitive: layers stamp lowercase hex, callers may not.
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId,
'military',
);
});
test('tracked-identity resolution falls back to layer precedence', () => {
const civilian = { icao24: 'aaa077' };
const military = { icao24: 'ae01ce' };
// No stamped identity (or one that matches neither layer) keeps the historic
// civilian-first order so tracking paths without gevTrackedId still work.
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
'flights',
);
assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military');
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
test('an application subject’s ground lift eases its clearance, never the floor', () => {
// On entry the lift asked for applies at once.
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
// A floor cell that rises by a metre is met at the lift rate...
let lift = 0;
const frames = [];
for (let i = 0; i < 60; i += 1) {
lift = easeCockpitLift(lift, 1, 0, 0.016);
frames.push(lift);
}
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
for (let i = 1; i < frames.length; i += 1)
assert.ok(
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
);
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
// ...and given back the same way.
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
// What keeps the eye out of the floor itself is never eased.
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
assert.equal(
easeCockpitLift(0.2, -3, -3, 0.016),
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
);
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
});