258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { haversineKm } from '@/lib/geo';
|
||
import {
|
||
arrivalTimeFor,
|
||
burnRateConstant,
|
||
FUEL_DENSITY_KG_PER_L,
|
||
fuelAtWeighted,
|
||
guaranteedRangeKm,
|
||
MAX_WIND_BURN_FACTOR,
|
||
maxRangeKm,
|
||
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 + duration', () => {
|
||
const arrival = arrivalTimeFor(DEP, KM, SPEED);
|
||
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
|
||
expect(arrival).toBeGreaterThan(DEP);
|
||
});
|
||
});
|
||
|
||
describe('progressAt', () => {
|
||
const duration = travelDurationMs(KM, SPEED);
|
||
|
||
it('is 0 before departure and at departure', () => {
|
||
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
|
||
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
|
||
});
|
||
|
||
it('is linear in the middle of the flight', () => {
|
||
// 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, DEP + duration / 2)).toBeCloseTo(0.5, 9);
|
||
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
|
||
});
|
||
|
||
it('is exactly 1 at arrival and stays 1 after', () => {
|
||
const arrival = DEP + duration;
|
||
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
|
||
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
|
||
});
|
||
|
||
it('a zero-distance leg is complete immediately', () => {
|
||
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
|
||
});
|
||
});
|
||
|
||
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', () => {
|
||
const duration = travelDurationMs(KM, SPEED);
|
||
const clock = DEP + duration - 1; // one ms before arrival
|
||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
|
||
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
|
||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).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();
|
||
});
|
||
});
|