Files
Flight-Ops/lib/sim/movement.test.ts
T
kamilkrzywda a801311fc5
CI / build + unit + e2e (pull_request) Failing after 5m28s
feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.

Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.

OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
2026-09-19 12:24:34 +00:00

167 lines
6.8 KiB
TypeScript
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 { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import {
arrivalTimeFor,
burnRateConstant,
FUEL_DENSITY_KG_PER_L,
fuelAtWeighted,
positionAt,
progressAt,
requiredFuelFor,
travelDurationMs,
} 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);
});
});