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 // while flying, ISS-053), and // a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining // dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000) const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination interface PlaneState { id: string; model: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[]; } interface PackageState { id: string; originIcao: string; destIcao: string; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; packages: PackageState[]; } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); expect(res.status()).toBe(200); return (await res.json()) as StateBody; } async function postSim(page: Page, body: unknown) { return page.request.post('/api/sim', { data: body }); } // Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a // landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale); } // Leave the live instance in a sane state for the other specs (scale 1, running). test.afterEach(async ({ page }) => { const res = await postSim(page, { timeScale: 1, paused: false }); expect(res.status()).toBe(200); }); // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise { const created = await page.request.post('/api/test/create-package', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, }); expect(created.status()).toBe(201); return (await created.json()) as PackageState; } // Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel // endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed. async function setFuelFull(page: Page, planeId: string): Promise { const s = await getState(page); const p = s.planes.find((x) => x.id === planeId)!; 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 { 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 { 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 { let s = await getState(page); let plane = s.planes.find((p) => p.id === 'plane-starter')!; if (plane.status === 'flying' || plane.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (plane.status === 'flying') { // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe('available'); s = await getState(page); plane = s.planes.find((p) => p.id === 'plane-starter')!; } if (plane.locationIcao !== HUB) { // ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards. await setScale(page, SCALE); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 240_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(HUB); } // Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup). const s1 = await getState(page); for (const pkg of s1.packages) { if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) { const res = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(res.ok()).toBeTruthy(); } } } async function waitForAirportCount(page: Page): Promise { const countEl = page.getByTestId('airport-count'); await expect.poll(async () => { const text = (await countEl.textContent()) ?? ''; const m = text.match(/(\d+)/); return m ? parseInt(m[1], 10) : -1; }).toBeGreaterThan(0); } // Rendered features of the given layers in the current viewport, [] until the map exists. function renderedFeatures( page: Page, layers: string[], ): Promise> { return page.evaluate((ls: string[]) => { const m = (window as any).__flightMap; if (!m) return []; try { // queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy // PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so // geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6). return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown; }>).map((f) => ({ type: f.type, id: f.id, properties: f.properties, geometry: f.geometry, })); } catch { return []; } }, layers); } // Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features // rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts). async function fitToAirports(page: Page, icaos: string[]): Promise { const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const points = icaos.map((icao) => { const f = fc.features.find((x) => x.properties.icao === icao); if (!f) throw new Error(`airport ${icao} not found in /api/airports`); return [f.properties.lon, f.properties.lat] as [number, number]; }); await page.evaluate((pts: [number, number][]) => { const m = (window as any).__flightMap; if (!m) throw new Error('map not ready'); const lons = pts.map((p) => p[0]); const lats = pts.map((p) => p[1]); // duration 0 → instant camera jump, no animation to wait out. m.fitBounds( [ [Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)], ], { padding: 40, duration: 0 }, ); }, points); // Give the renderer a beat after the camera jump before asserting on rendered features. await page.waitForTimeout(300); } // [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from. async function airportLonLat(page: Page, icao: string): Promise<[number, number]> { const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const f = fc.features.find((x) => x.properties.icao === icao); if (!f) throw new Error(`airport ${icao} not found in /api/airports`); return [f.properties.lon, f.properties.lat]; } // MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so // queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to // the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in // e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between // any two EU airports. const QUEUE_ROUTE_EPS_DEG = 0.05; test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF // Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway). await setFuelFull(page, 'plane-starter'); const pkg = await createPackage(page, HUB, DEST); await page.goto('/'); await waitForAirportCount(page); // LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin. const loadBtn = page.getByTestId(`load-${pkg.id}`); await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 }); await loadBtn.click(); const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`); await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); // LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it. const destSelect = page.getByTestId('relocate-dest'); await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 }); // START leg 1 via the UI (fly-to button in the on-board block). const flyBtn = page.getByTestId(`fly-to-${pkg.id}`); await expect(flyBtn).toBeEnabled(); await flyBtn.click(); // The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE // immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us. await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'flying' && p.destIcao === DEST; }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks // ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409. const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } }); expect(dupQueue.status()).toBe(409); // ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go // posts to /queue and the leg starts when it lands. await page.getByTestId('relocate-plane').selectOption('plane-starter'); await destSelect.selectOption(QUEUE); await page.getByTestId('relocate-go').click(); // …and the new leg appears as queue-leg-0 with its destination. const legRow = page.getByTestId('queue-leg-plane-starter-0'); await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 }); // CANCEL it via the UI and assert it's gone. await page.getByTestId('queue-cancel-plane-starter-0').click(); await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 }); // Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home. await setScale(page, SCALE); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === DEST; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); // ISS-041: the arrival above paused the sim — setScale clears pause for the return leg. await setScale(page, SCALE); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }).toBe(HUB); }); test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => { test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup await flyHomeViaApi(page); // Full tank before departure — keeps the state canonical for whatever the cleanup does (ISS-082 hook). await setFuelFull(page, 'plane-starter'); // Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while // grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0]. const pkg = await createPackage(page, HUB, DEST); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(load.status()).toBe(200); const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } }); expect(queueRes.status()).toBe(200); const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); expect(flyRes.status()).toBe(200); // The endpoint's OWN post-action snapshot already shows the plane flying — no poll race. const flyBody = (await flyRes.json()) as StateBody; expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying'); // Freeze mid-flight so the ~10 s leg can't land under us while we assert. await postSim(page, { paused: true }); await page.goto('/'); await waitForAirportCount(page); // All line endpoints must be in-viewport for queryRenderedFeatures to see them. await fitToAirports(page, [DEST, QUEUE]); const destLL = await airportLonLat(page, DEST); const queueLL = await airportLonLat(page, QUEUE); const hubLL = await airportLonLat(page, HUB); const near = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG; await expect.poll(async () => { const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{ properties?: Record; geometry?: { type: string; coordinates: [number, number][] }; }>; // Filter to real LineString segments — keeps this poll callback total (a throw inside // expect.poll fails the test instantly instead of retrying). "None yet" → keep polling. const segs = feats.filter( (f): f is { properties?: Record; geometry: { type: 'LineString'; coordinates: [number, number][] } } => !!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2, ); if (!segs.length) return false; // The line may be clipped into several per-tile segments — check the endpoints across all of them: // some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)… const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL)); // …and some ENDS within EPS of the queued airport. const endsAtQueue = segs.some((f) => { const c = f.geometry.coordinates; return near(c[c.length - 1], queueLL); }); // The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub. const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL)); // Every rendered feature belongs to plane-starter (the only queued plane). const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter'); return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter; }, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true); // Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward // to arrival, then fly home. const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(cancelRes.status()).toBe(200); await setScale(page, SCALE); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === DEST; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); // ISS-041: the arrival above paused the sim — setScale clears pause for the return leg. await setScale(page, SCALE); const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); expect(home.status()).toBe(200); await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }).toBe(HUB); }); test('grounded plane with a pre-built queue draws its dotted route from its airport (ISS-077)', async ({ page }) => { test.setTimeout(300_000); // self-heal + map assertions only — no flight in this test await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF await page.goto('/'); await waitForAirportCount(page); // Pre-build a two-leg route while GROUNDED via the raw API (queuing is allowed from any state, // ISS-043 part 1) — no package or flight needed. The dotted line must chain HUB → DEST → QUEUE. const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } }); expect(q1.status()).toBe(200); const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } }); expect(q2.status()).toBe(200); // All chain endpoints must be in-viewport for queryRenderedFeatures to see them. await fitToAirports(page, [HUB, DEST, QUEUE]); const hubLL = await airportLonLat(page, HUB); const destLL = await airportLonLat(page, DEST); const queueLL = await airportLonLat(page, QUEUE); const near = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG; await expect.poll(async () => { const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{ properties?: Record; geometry?: { type: string; coordinates: [number, number][] }; }>; // Filter to real LineString segments — keeps this poll callback total. "None yet" → keep polling. const segs = feats.filter( (f): f is { properties?: Record; geometry: { type: 'LineString'; coordinates: [number, number][] } } => !!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2, ); if (!segs.length) return false; // The chain HUB → DEST → QUEUE may be clipped into per-tile segments — check across all of them: // some segment STARTS within EPS of the hub (the plane's airport, NOT a flight destination)… const startsAtHub = segs.some((f) => near(f.geometry.coordinates[0], hubLL)); // …some ENDS within EPS of the last queued airport… const endsAtQueue = segs.some((f) => { const c = f.geometry.coordinates; return near(c[c.length - 1], queueLL); }); // …and the chain passes THROUGH the first queued airport — an interior joint point survives in // whichever tile contains it (not guaranteed to be a segment endpoint after clipping). const passesThroughDest = segs.some((f) => f.geometry.coordinates.some((c) => near(c, destLL))); // Every rendered feature belongs to plane-starter. const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter'); return startsAtHub && endsAtQueue && passesThroughDest && ownedByStarter; }, { timeout: 15_000, message: `waiting for the dotted queue line ${HUB} → ${DEST} → ${QUEUE}` }).toBe(true); // Cleanup: cancel both queued legs (index 0 twice — each cancel shifts the rest down) so the // starter stays grounded at HUB with an empty queue. No flight happened, nothing else to reset. const c1 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(c1.status()).toBe(200); const c2 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); expect(c2.status()).toBe(200); }); test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => { test.setTimeout(300_000); // self-heal + two chained legs at scale 1000 await flyHomeViaApi(page); // Full tank for a deterministic baseline — the engine auto-chains (and auto-refuels, ISS-082) the return leg regardless. await setFuelFull(page, 'plane-starter'); // Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1. const pkg = await createPackage(page, HUB, DEST); const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } }); expect(load.status()).toBe(200); const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } }); expect(queueRes.status()).toBe(200); const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); expect(flyRes.status()).toBe(200); // Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued // return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a // poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO. await setScale(page, SCALE); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'flying' && p.destIcao === HUB; }, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true); // Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered. const s = await getState(page); expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered'); // Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state. await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); 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 });