Files
Flight-Ops/lib/sim/movement.test.ts
kamilkrzywda 82e4c64206
CI / build + unit + e2e (pull_request) Failing after 7m29s
v1.12.0 — takeoff & landing phases on the marker
Every leg gains a 10-min takeoff phase at the origin and a 10-min
landing phase at the destination, each weather-scaled up to 2x via a
monotone condition rule (Clear < Cloudy < Rain < Fog). Single
composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor,
MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route
through it (progress is now piecewise — hold at origin / linear cruise
/ hold at destination), so engine arrival gate, client position/fuel
recompute and the job expiry floor all agree. Phases burn no fuel.

Marker shows the current phase next to the status in the popup
(mapData derives it from departureSimTime + clockMs; new
data-testid=plane-phase sibling). plane-status keeps the raw status.

Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest
composition, piecewise progressAt), engine.test.ts (deleted local travelMs
mirror — all ~18 fixture clocks use the real composition), seed.test.ts
(phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts
'taking off' at the origin gate and 'landing' at the destination gate on
EDDF→LEMD using the same legPhases boundaries as the engine.
2026-09-28 07:09:19 +00:00

328 lines
15 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { weatherFor, type AirportWeather, type WeatherCondition } from '@/lib/weather';
import {
arrivalTimeFor,
BASE_PHASE_MS,
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
guaranteedRangeKm,
legPhases,
legPhaseAt,
MAX_PHASE_FACTOR,
MAX_WIND_BURN_FACTOR,
maxRangeKm,
phaseFactor,
positionAt,
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts.
const DEP = 1_750_000_000_000; // departure sim time (ms)
const SPEED = 510; // km/h
const BURN = 1.75; // L/km at a full tank
const WE = 9_300; // kg, operating empty weight
const TANK = 6_300; // liters, full tank
// Frankfurt → Madrid, real coordinates from the CSV dataset.
const FROM = { lat: 50.0333, lon: 8.5706 };
const TO = { lat: 40.47, lon: -3.56 };
const KM = haversineKm(FROM.lat, FROM.lon, TO.lat, TO.lon); // ≈ 2190 km
describe('travelDurationMs / arrivalTimeFor', () => {
it('duration is distance/speed converted to ms', () => {
expect(travelDurationMs(510, SPEED)).toBe(3_600_000); // one hour at cruise
expect(travelDurationMs(KM, SPEED)).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
});
it('rejects a non-positive speed', () => {
expect(() => travelDurationMs(100, 0)).toThrow();
});
it('arrival is departure plus all three segments (v1.12.0)', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBe(DEP + ph.totalMs);
// The cruise part is the old pure duration — phases only add on top.
expect(ph.cruiseMs).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
expect(arrivalTimeFor(DEP, KM, SPEED, 'EDDF', 'LEMD')).toBeGreaterThan(DEP + ph.cruiseMs);
});
});
describe('phaseFactor / legPhases (v1.12.0)', () => {
const mk = (condition: WeatherCondition): AirportWeather => ({ condition, windDirDeg: 90, windKt: 15, visibilityM: 8_000 });
it('MAX_PHASE_FACTOR is phaseFactor\'s actual maximum over the weather domain', () => {
// Fog is the worst condition — the constant must track the factor function, not a copy.
expect(MAX_PHASE_FACTOR).toBe(phaseFactor(mk('Fog')));
});
it('stays within [1, MAX_PHASE_FACTOR] and is monotone in condition severity', () => {
for (const c of ['Clear', 'Cloudy', 'Rain', 'Fog'] as const) {
const f = phaseFactor(mk(c));
expect(f).toBeGreaterThanOrEqual(1);
expect(f).toBeLessThanOrEqual(MAX_PHASE_FACTOR);
}
expect(phaseFactor(mk('Clear'))).toBe(1); // clear skies stay at 1×
expect(phaseFactor(mk('Cloudy'))).toBeGreaterThan(phaseFactor(mk('Clear')));
expect(phaseFactor(mk('Rain'))).toBeGreaterThan(phaseFactor(mk('Cloudy')));
expect(phaseFactor(mk('Fog'))).toBe(MAX_PHASE_FACTOR); // worst condition is exactly 2×
});
it('is a deterministic function of the ICAO via weatherFor', () => {
for (const icao of ['EDDF', 'LEMD', 'EHAM', 'KSMF']) {
expect(phaseFactor(weatherFor(icao))).toBe(phaseFactor(weatherFor(icao)));
}
// KSMF is Clear in the deterministic generator — a real ICAO pinned at exactly 1×.
expect(phaseFactor(weatherFor('KSMF'))).toBe(1);
});
it('takeoff scales on ORIGIN weather, landing on DESTINATION weather', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.takeoffMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('EDDF')));
expect(ph.landingMs).toBe(BASE_PHASE_MS * phaseFactor(weatherFor('LEMD')));
// Non-vacuous: the two airports really do differ (Rain vs Cloudy), so a swapped
// origin/dest would fail these pins.
expect(phaseFactor(weatherFor('EDDF'))).not.toBe(phaseFactor(weatherFor('LEMD')));
});
it('total is the sum of the three segments and exceeds the old pure duration', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(ph.totalMs).toBe(ph.takeoffMs + ph.cruiseMs + ph.landingMs);
expect(ph.totalMs).toBeGreaterThan(travelDurationMs(KM, SPEED));
});
it('legPhaseAt walks takeoff → cruise → landing over the leg', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs - 1)).toBe('takeoff');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.takeoffMs + ph.cruiseMs - 1)).toBe('cruise');
expect(legPhaseAt(DEP, KM, SPEED, 'EDDF', 'LEMD', DEP + ph.totalMs - 1)).toBe('landing');
});
});
describe('progressAt (piecewise, v1.12.0)', () => {
const ORIGIN = 'EDDF';
const DEST = 'LEMD';
const ph = legPhases(KM, SPEED, ORIGIN, DEST);
it('is 0 before departure and throughout the takeoff hold at the origin', () => {
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP - 1)).toBe(0);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs / 2)).toBe(0);
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs - 1));
expect(p).toEqual({ lat: FROM.lat, lon: FROM.lon }); // still at the gate — no crawl along the route
});
it('is linear over the cruise window', () => {
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs / 2)).toBeCloseTo(0.5, 9);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs * 0.25)).toBeCloseTo(0.25, 9);
});
it('is exactly 1 at the start of the landing hold and stays 1 through arrival', () => {
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, ORIGIN, DEST, DEP + ph.takeoffMs + ph.cruiseMs));
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // parked at the destination gate during landing
const arrival = DEP + ph.totalMs;
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival)).toBe(1);
expect(progressAt(DEP, KM, SPEED, ORIGIN, DEST, arrival + 10_000)).toBe(1);
});
it('a zero-distance leg has no cruise to progress through', () => {
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP - 5)).toBe(0); // before departure
expect(progressAt(DEP, 0, SPEED, 'EDDF', 'EDDF', DEP + 1)).toBe(1); // complete once departed
});
});
describe('positionAt (great-circle slerp)', () => {
it('returns the exact endpoints at progress 0 and 1', () => {
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0)).toEqual({ lat: FROM.lat, lon: FROM.lon });
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1)).toEqual({ lat: TO.lat, lon: TO.lon });
});
it('clamps out-of-range progress to the endpoints', () => {
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, -0.5)).toEqual({ lat: FROM.lat, lon: FROM.lon });
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1.5)).toEqual({ lat: TO.lat, lon: TO.lon });
});
it('stays on the great circle: arc lengths add up to the total distance', () => {
for (const t of [0.1, 0.37, 0.5, 0.9]) {
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, t);
const d1 = haversineKm(FROM.lat, FROM.lon, p.lat, p.lon);
const d2 = haversineKm(p.lat, p.lon, TO.lat, TO.lon);
expect(d1 + d2).toBeCloseTo(KM, 6); // slerp keeps the point on the arc
}
});
it('the midpoint is equidistant from both ends', () => {
const m = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0.5);
expect(haversineKm(FROM.lat, FROM.lon, m.lat, m.lon)).toBeCloseTo(KM / 2, 6);
expect(haversineKm(m.lat, m.lon, TO.lat, TO.lon)).toBeCloseTo(KM / 2, 6);
});
it('is exact along the equator (great circle = constant latitude)', () => {
for (const t of [0.25, 0.5, 0.75]) {
const p = positionAt(0, -10, 0, 10, t);
expect(p.lat).toBeCloseTo(0, 9);
expect(p.lon).toBeCloseTo(-10 + 20 * t, 9);
}
});
it('handles the same-point route without dividing by zero', () => {
const p = positionAt(48.85, 2.35, 48.85, 2.35, 0.5);
expect(p).toEqual({ lat: 48.85, lon: 2.35 });
});
});
describe('weighted burn model (v0.16.0)', () => {
it('calibrates so a full tank burns exactly the old constant rate', () => {
const k = burnRateConstant(BURN, WE, TANK);
expect(k * (WE + FUEL_DENSITY_KG_PER_L * TANK)).toBeCloseTo(BURN, 12);
// Over a short hop from full, weighted ≈ constant — divergence only grows as fuel drops off.
const d = 100;
const consumed = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, d, 1);
expect(Math.abs(consumed - BURN * d)).toBeLessThan(1); // < 1 L over 100 km from full
});
it('consumption matches numeric integration of r(F) = k·(We + ρF)', () => {
const k = burnRateConstant(BURN, WE, TANK);
const rho = FUEL_DENSITY_KG_PER_L;
// Euler-integrate dF/dx = −k(We + ρF) from a full tank over the whole leg.
const steps = 200_000;
const h = KM / steps;
let f = TANK;
for (let i = 0; i < steps; i++) f -= k * (WE + rho * f) * h;
expect(Math.abs(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1) - f)).toBeLessThan(0.001); // Euler drift is sub-milliliter here
});
it('requiredFuelFor is zero for a zero leg and strictly increasing in distance', () => {
expect(requiredFuelFor(BURN, WE, TANK, 0)).toBe(0);
let prev = requiredFuelFor(BURN, WE, TANK, 100);
for (const d of [500, 1_000, 2_000, 3_000, 4_000]) {
const cur = requiredFuelFor(BURN, WE, TANK, d);
expect(cur).toBeGreaterThan(prev);
prev = cur;
}
});
it('a lighter start burns less over the same distance', () => {
const burnFromFull = TANK - fuelAtWeighted(TANK, BURN, WE, TANK, KM, 1);
const burnFromHalf = TANK / 2 - fuelAtWeighted(TANK / 2, BURN, WE, TANK, KM, 1);
expect(burnFromFull).toBeGreaterThan(burnFromHalf);
});
it('starting with exactly the required fuel arrives on empty', () => {
const req = requiredFuelFor(BURN, WE, TANK, KM);
expect(fuelAtWeighted(req, BURN, WE, TANK, KM, 1)).toBeCloseTo(0, 6);
});
it('clamps at zero instead of going negative', () => {
expect(fuelAtWeighted(100, BURN, WE, TANK, KM, 1)).toBe(0); // needs ~2800 L from full, has 100
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, 0)).toBe(TANK);
expect(fuelAtWeighted(TANK, BURN, WE, TANK, 0, 1)).toBe(TANK);
});
it('composes with progressAt for a just-before-arrival snapshot (v1.12.0)', () => {
const ph = legPhases(KM, SPEED, 'EDDF', 'LEMD');
const clock = DEP + ph.totalMs - 1; // one ms before arrival — inside the landing hold
const progress = progressAt(DEP, KM, SPEED, 'EDDF', 'LEMD', clock);
expect(progress).toBe(1); // cruise is done: the plane holds at the destination gate…
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progress);
expect(p).toEqual({ lat: TO.lat, lon: TO.lon }); // …so it IS at the gate (v1.12.0 piecewise)
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progress)).toBeGreaterThan(0);
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
expect(r).toBeGreaterThan(4439 * 0.95);
expect(r).toBeLessThan(4439 * 1.05);
});
it('increases with tank size and decreases with burn rate', () => {
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
});
it('zero fuel → zero range', () => {
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
});
it('rejects a non-positive empty weight', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
});
});
describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => {
it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => {
// Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy.
expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90));
});
it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => {
const r = guaranteedRangeKm(BURN, WE, TANK);
expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12);
expect(r).toBeGreaterThan(3696 * 0.95);
expect(r).toBeLessThan(3696 * 1.05);
});
it('zero fuel → zero range; rejects a non-positive empty weight', () => {
expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0);
expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow();
});
});