feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
CI / build + unit + e2e (pull_request) Failing after 5m28s

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.
This commit is contained in:
2026-09-19 12:24:34 +00:00
parent 1dc5beb0dd
commit a801311fc5
19 changed files with 606 additions and 74 deletions
+24 -6
View File
@@ -90,8 +90,17 @@ describe('OpenAPI spec', () => {
expect(snap.sellFraction.type).toBe('number');
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'capacity', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm',
'capacity',
'emptyWeightKg', // v0.16.0 weighted burn model input
'label',
'maxFuel',
'model',
'price',
'speedKmh',
]);
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
@@ -165,7 +174,7 @@ describe('OpenAPI spec', () => {
const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
responses: Record<string, unknown>;
requestBody?: { content: Record<string, { schema: { $ref: string } }> };
requestBody?: { required?: boolean; content: Record<string, { schema: { $ref: string } }> };
parameters?: Array<{ name: string }>;
};
@@ -189,9 +198,10 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon/refuel take none.
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/LoadPackageRequest',
);
@@ -205,7 +215,9 @@ describe('OpenAPI spec', () => {
'#/components/schemas/SellPlaneRequest',
);
expect(post('/api/packages/{id}/abandon').requestBody).toBeUndefined();
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
@@ -227,5 +239,11 @@ describe('OpenAPI spec', () => {
const sell = openApiSpec.components.schemas.SellPlaneRequest;
expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string');
// RefuelRequest (v0.16.0): both fields optional — no body / {} means "full".
const refuel = openApiSpec.components.schemas.RefuelRequest;
expect((refuel as { required?: string[] }).required ?? []).toEqual([]);
expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']);
expect(refuel.properties.packageId.type).toBe('string');
});
});