Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
CI / build + unit + e2e (pull_request) Failing after 12m59s
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
|
||||
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
|
||||
import { loadAirportsGeoJson } from '../lib/airports';
|
||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
|
||||
import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||
|
||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||
@@ -14,7 +21,9 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
@@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
|
||||
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
|
||||
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
|
||||
async function setCash(page: Page, amount: number): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
@@ -450,3 +470,136 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
||||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||
});
|
||||
|
||||
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
|
||||
|
||||
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
|
||||
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
|
||||
for (const icao of [DEST, QUEUE, HUB]) {
|
||||
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
|
||||
expect(p0.status).toBe('flying');
|
||||
expect(p0.destIcao).toBe(DEST);
|
||||
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
|
||||
|
||||
// Landing at DEST auto-chains EHAM (leg 2)…
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||
|
||||
// …and landing at EHAM chains the return to HUB (leg 3).
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||
|
||||
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
|
||||
});
|
||||
|
||||
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + two legs at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
const s0 = await getState(page);
|
||||
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
|
||||
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
|
||||
|
||||
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
|
||||
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
const STAGED_CASH = 20_000;
|
||||
await setCash(page, STAGED_CASH);
|
||||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||
expect(q.status()).toBe(200);
|
||||
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
|
||||
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
|
||||
|
||||
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
|
||||
await setFuel(page, 'plane-starter', 0);
|
||||
|
||||
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
|
||||
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
|
||||
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
|
||||
|
||||
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
|
||||
const wHub = weatherFor(HUB);
|
||||
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
|
||||
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
|
||||
expect(landedFuel).toBeCloseTo(0, 6);
|
||||
|
||||
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
|
||||
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
|
||||
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
|
||||
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
|
||||
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
|
||||
|
||||
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
|
||||
const liters = Math.max(0, target - landedFuel);
|
||||
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||
|
||||
// Landing at DEST auto-chains QUEUE with the top-up…
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||
|
||||
const s = await getState(page);
|
||||
expect(s.planes.find((x) => x.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // departed with exactly the post-top-up level…
|
||||
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // …at exactly the rep-discounted cost
|
||||
|
||||
// Let leg 2 finish so the next test starts from a known airport.
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
|
||||
});
|
||||
|
||||
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one leg at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||
expect(q.status()).toBe(200);
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
|
||||
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(cancel.status()).toBe(200);
|
||||
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||
expect(p.destIcao).toBeNull(); // no chained leg started…
|
||||
expect(p.queue).toEqual([]); // …and the queue is empty
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user