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(); }); });