Max range display is the guaranteed (worst-case headwind) full-tank range (ISS-099, v0.51.0)
CI / build + unit + e2e (pull_request) Successful in 12m19s
CI / build + unit + e2e (pull_request) Successful in 12m19s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { STARTING_REPUTATION } from './seed';
|
||||
import { guaranteedRangeKm, MAX_WIND_BURN_FACTOR, maxRangeKm, requiredFuelFor } from '@/lib/sim/movement';
|
||||
|
||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||
describe('PLANE_TYPES', () => {
|
||||
@@ -43,3 +44,25 @@ describe('repRate', () => {
|
||||
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
// v0.51.0 (ISS-099) — max range consistency: for EVERY catalog type the displayed guaranteed range
|
||||
// must be flyable on a full tank under worst-case headwind burn (the same requiredFuelFor ≤ maxFuel
|
||||
// rule as flyPlane's fuel guard), and tight — not padded. The closed form inverts exactly, so the
|
||||
// "≤" side allows only float noise (~1e-11 L → relative 1e-9 tolerance).
|
||||
describe('max range consistency (v0.51.0, ISS-099)', () => {
|
||||
for (const t of Object.values(PLANE_TYPES)) {
|
||||
it(`${t.id}: guaranteed range is flyable in worst-case headwind and not padded`, () => {
|
||||
const r = guaranteedRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
|
||||
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, r))
|
||||
.toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
|
||||
// Tight: 5% beyond the displayed range no longer fits a full tank.
|
||||
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, 1.05 * r))
|
||||
.toBeGreaterThan(t.maxFuel);
|
||||
});
|
||||
|
||||
it(`${t.id}: the no-wind closed form inverts requiredFuelFor exactly`, () => {
|
||||
const r = maxRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
|
||||
expect(requiredFuelFor(t.burnPerKm, t.emptyWeightKg, t.maxFuel, r)).toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
burnRateConstant,
|
||||
FUEL_DENSITY_KG_PER_L,
|
||||
fuelAtWeighted,
|
||||
guaranteedRangeKm,
|
||||
MAX_WIND_BURN_FACTOR,
|
||||
maxRangeKm,
|
||||
positionAt,
|
||||
progressAt,
|
||||
@@ -234,3 +236,22 @@ describe('maxRangeKm (v0.28.0)', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,3 +137,16 @@ export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: nu
|
||||
const rho = FUEL_DENSITY_KG_PER_L;
|
||||
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
||||
}
|
||||
|
||||
// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of
|
||||
// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150.
|
||||
export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150;
|
||||
|
||||
// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case
|
||||
// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor
|
||||
// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what
|
||||
// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard
|
||||
// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn).
|
||||
export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||||
return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user