69 lines
3.8 KiB
TypeScript
69 lines
3.8 KiB
TypeScript
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', () => {
|
||
it('every entry has positive specs and a unique id', () => {
|
||
const ids = Object.keys(PLANE_TYPES);
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
for (const t of Object.values(PLANE_TYPES)) {
|
||
expect(t.id, `${t.name} id`).toBeTruthy();
|
||
expect(t.price, `${t.name} price`).toBeGreaterThan(0);
|
||
expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0);
|
||
expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0);
|
||
expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0);
|
||
expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0);
|
||
expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
|
||
it('atr72 is the starter model', () => {
|
||
expect(STARTER_MODEL).toBe('atr72');
|
||
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
|
||
});
|
||
});
|
||
|
||
// v0.38.0 (ISS-072) — the shared rep-discount shape: linear above the starting value, capped.
|
||
describe('repRate', () => {
|
||
it('is exactly 0 at and below the starting reputation', () => {
|
||
expect(repRate(STARTING_REPUTATION, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||
expect(repRate(0, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||
expect(repRate(-5, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(0); // below-start clamps too
|
||
});
|
||
|
||
it('is linear in reputation above the starting value (exact mid-range values)', () => {
|
||
expect(repRate(STARTING_REPUTATION + 10, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.1, 12); // 0.01 × 10
|
||
expect(repRate(STARTING_REPUTATION + 30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.3, 12); // 0.01 × 30
|
||
expect(repRate(STARTING_REPUTATION + 40, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBeCloseTo(0.2, 12); // 0.005 × 40 = cap
|
||
});
|
||
|
||
it('clamps at the cap', () => {
|
||
expect(repRate(STARTING_REPUTATION + 50, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(MAX_PLANE_DISCOUNT); // 0.5 → capped to 0.4
|
||
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));
|
||
});
|
||
}
|
||
});
|