import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves // the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. import { initialBearingDeg } from '../lib/geo'; import { weatherFor } from '../lib/weather'; import { windBurnFactor } from '../lib/sim/movement'; // v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers. import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog'; // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // greyed-out Load button until a plane was picked from the dropdown, while the single-plane path // is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this // spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057 // slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell. // // Each test buys its own second plane via the UI (after a cash grant from the new // POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach // guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after. 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 STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts) const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys 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' | 'ignored'; planeId: string | null; } interface CatalogEntry { model: string; label: string; price: number; maxFuel: number; burnPerKm: number; emptyWeightKg: number; speedKmh: number; maxPayloadKg: number; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; packages: PackageState[]; catalog: CatalogEntry[]; fuelPricePerL: number; sellFraction: number; } // Mirrors formatCash in lib/format.ts (same Intl settings → identical strings). const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v); 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. Also records lastSelectedTimeScale // (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing // proceeds at full speed. 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); } // Mirrors haversineKm in lib/geo.ts (same formula → identical floats). function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a))); } // Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty — // closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm. const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number { const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel); return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); } // Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations. async function twoNearestToHub(page: Page): Promise> { const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; return fc.features .filter((f) => f.properties.icao !== HUB) .map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) })) .sort((x, y) => x.d - y.d) .slice(0, 2) .map(({ icao, lat, lon }) => ({ icao, lat, lon })); } // Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The // dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind- // adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the // return leg is flown after a top-up, which also 409s if it doesn't fit. async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> { const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!; const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; const wHub = weatherFor(HUB); let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; for (const f of fc.features) { if (f.properties.icao === HUB) continue; const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); const wDest = weatherFor(f.properties.icao); const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon)); if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon)); if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either if (!farthest || d > farthest.d) { farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; } } if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset'); return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon }; } // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight. async function createPackage( page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, ): Promise { const created = await page.request.post('/api/test/create-package', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg }, }); expect(created.status()).toBe(201); return (await created.json()) as PackageState; } // Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full // post-grant snapshot (the route's response). async function addCash(page: Page, amount?: number): Promise { const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } }); expect(res.status()).toBe(200); return (await res.json()) as StateBody; } // Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state // as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name). async function buySecondPlaneViaUi(page: Page): Promise { await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash await page.goto('/'); const right = page.getByTestId('right-pane'); await expect(right).toBeVisible({ timeout: 15_000 }); await page.getByTestId('tab-planes-market').click(); const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 }); await buyModel.selectOption(BUY_MODEL); await page.getByTestId('market-buy').click(); let secondId: string | null = null; await expect .poll(async () => { const s = await getState(page); secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null; return secondId !== null; }, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' }) .toBe(true); return secondId!; } // Cancel every queued leg of a plane via the API (works while flying — the server checks no status). async function clearQueueViaApi(page: Page, planeId: string): Promise { for (;;) { const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? []; if (q.length === 0) return; expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200); } } // Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes // carrying cargo, and clean baselines keep exact-cash assertions honest. async function abandonLeftoverCargo(page: Page, planeId: string): Promise { const s = await getState(page); for (const pkg of s.packages) { if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) { expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy(); } } } // Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing), // fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other // specs, generalized to an arbitrary plane id. async function healPlaneHome(page: Page, planeId: string): Promise { await clearQueueViaApi(page, planeId); let p = (await getState(page)).planes.find((x) => x.id === planeId); if (!p) return; // already sold — nothing to heal // ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg // completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again. await setScale(page, SCALE); if (p.status === 'flying') { // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. await expect .poll(async () => { const s = await getState(page); const plane = s.planes.find((x) => x.id === planeId); if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1× return plane?.status ?? ''; }, { timeout: 240_000, message: `waiting for an in-flight ${planeId} to land`, }) .toBe('available'); p = (await getState(page)).planes.find((x) => x.id === planeId)!; } if (p.locationIcao !== HUB) { // ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg. await setScale(page, SCALE); let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); if (flyHome.status() === 409) { // A long outbound leg can leave the tank too empty for the return — top up and retry. expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200); flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); } expect(flyHome.status()).toBe(200); await expect .poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), { timeout: 240_000, message: `waiting for ${planeId} to land back at the hub`, }) .toBe(HUB); } } // Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or // in flight) — leaves the fleet at exactly one plane. async function sellNonStarters(page: Page): Promise { const s = await getState(page); for (const p of s.planes.filter((x) => x.id !== STARTER)) { await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold await abandonLeftoverCargo(page, p.id); expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200); } } // Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF, // no cargo on board — even after an interrupted previous run. async function resetFleet(page: Page): Promise { await sellNonStarters(page); await healPlaneHome(page, STARTER); await abandonLeftoverCargo(page, STARTER); } test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file // Leave the fleet at exactly one plane and restore sane sim controls for the other specs. test.beforeEach(async ({ page }) => { await resetFleet(page); }); test.afterEach(async ({ page }) => { await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight) const res = await postSim(page, { timeScale: 1, paused: false }); expect(res.status()).toBe(200); }); test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content) const [nearest] = await twoNearestToHub(page); // Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically. const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500); // The multi-plane picker lists BOTH planes… const picker = page.getByTestId(`load-plane-${pkg1.id}`); await expect(picker).toBeVisible({ timeout: 15_000 }); await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 }); await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1); // …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055). const s0 = await getState(page); const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!; await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id); const loadBtn1 = page.getByTestId(`load-${pkg1.id}`); await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled(); // One-click Load binds the package to the preselected (first eligible) plane. await loadBtn1.click(); await expect .poll(async () => { const p = (await getState(page)).packages.find((x) => x.id === pkg1.id); return !!p && p.status === 'loaded' ? p.planeId : null; }, { timeout: 15_000, message: 'waiting for the package to load onto a plane' }) .toBe(firstEligible.id); // Second package — pick the OTHER plane explicitly via the dropdown. const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500); const picker2 = page.getByTestId(`load-plane-${pkg2.id}`); await expect(picker2).toBeVisible({ timeout: 15_000 }); await picker2.selectOption(secondId); await page.getByTestId(`load-${pkg2.id}`).click(); await expect .poll(async () => { const p = (await getState(page)).packages.find((x) => x.id === pkg2.id); return !!p && p.status === 'loaded' ? p.planeId : null; }, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' }) .toBe(secondId); // Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054). await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 }); }); test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => { test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000 const secondId = await buySecondPlaneViaUi(page); // Full tanks for both — the starter's may be partial from an earlier run. expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200); expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200); // Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest // full-tank-flyable one — their landings are separated by hours of game time and can never fall on // the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts). const [nearest] = await twoNearestToHub(page); const farthest = await farthestFlyableToHub(page); const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500); const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500); expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200); expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200); const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } }); expect(flyA.status()).toBe(200); const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } }); expect(flyB.status()).toBe(200); // Both are flying to their destinations (the second /fly response snapshot is authoritative). const s1 = (await flyB.json()) as StateBody; expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao); expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying'); expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao); // Fast-forward to the FIRST landing (the short hop)… await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), { timeout: 240_000, message: 'waiting for the first plane to land', }) .toBe(true); // …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too. await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), { timeout: 240_000, message: 'waiting for both planes to land', }) .toBe(true); // ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing. const landed = await getState(page); expect(landed.simState.paused).toBe(false); expect(landed.simState.timeScale).toBe(1); // Both packages delivered on arrival (in deadline — 24h window vs hours of flight time). expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered'); expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered'); // The outbound to the range edge left the tank nearly empty — top up for the return leg. expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200); // Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume… const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } }); expect(flyB2.status()).toBe(200); let body = (await flyB2.json()) as StateBody; expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1); // …but dispatching the LAST grounded plane restores the last manually selected speed. const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); expect(flyA2.status()).toBe(200); body = (await flyA2.json()) as StateBody; expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000 }); test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => { test.setTimeout(300_000); // self-heal + two short hops at scale 1000 const secondId = await buySecondPlaneViaUi(page); // Partial tanks: a short hop each, to different destinations. const [a, b] = await twoNearestToHub(page); expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200); expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200); // Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both. await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), { timeout: 240_000, message: 'waiting for the first plane to land', }) .toBe(true); await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), { timeout: 240_000, message: 'waiting for both planes to land', }) .toBe(true); const pre = await getState(page); const starter = pre.planes.find((p) => p.id === STARTER)!; const second = pre.planes.find((p) => p.id === secondId)!; expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel); expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel); // The left-pane Refuel section lists BOTH grounded planes with partial tanks… const refuelSelect = page.getByTestId('market-refuel-plane'); await expect(refuelSelect).toBeVisible({ timeout: 15_000 }); await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1); await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1); // …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'), // rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation. const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT); const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100; await refuelSelect.selectOption(secondId); await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost)); await page.getByTestId('market-refuel').click(); await expect .poll(async () => { const s = await getState(page); return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001; }, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' }) .toBe(true); const post = await getState(page); expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel); expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel); }); test('relocate section moves one plane without touching the other', async ({ page }) => { test.setTimeout(300_000); // self-heal + one short hop at scale 1000 const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF const [a] = await twoNearestToHub(page); // relocation destination for the second plane // The Relocate section lists BOTH grounded planes… const relocateSelect = page.getByTestId('relocate-plane'); await expect(relocateSelect).toBeVisible({ timeout: 15_000 }); await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1); await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1); // LeftPane's own /api/airports fetch populates the destination select — wait for it. const destSelect = page.getByTestId('relocate-dest'); await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 }); // Relocate ONLY the second plane… await relocateSelect.selectOption(secondId); await destSelect.selectOption(a.icao); await page.getByTestId('relocate-go').click(); // …it is flying to the new airport while the starter stays put at EDDF. await expect .poll(async () => { const s = await getState(page); return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' && (s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB; }, { timeout: 15_000, message: 'waiting for the relocation to take effect' }) .toBe(true); // Let it land — locationIcao flips to the destination on arrival. await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), { timeout: 240_000, message: `waiting for the relocated plane to land at ${a.icao}`, }) .toBe(a.icao); const s = await getState(page); expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available'); expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB); }); test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => { test.setTimeout(300_000); // self-heal + two chained legs at scale 1000 const secondId = await buySecondPlaneViaUi(page); const [a, b] = await twoNearestToHub(page); // Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b). expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200); // Seed one queued leg per plane via the API so BOTH on-board blocks render… expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200); // …each block shows its own seeded leg row under plane-scoped testids (ISS-055)… const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`); await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 }); await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao); // …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in // ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue. expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200); await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 }); // CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact. await page.getByTestId(`queue-cancel-${secondId}-0`).click(); await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 }); await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao); // Fly the STARTER to its first queued destination… const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } }); expect(flyRes.status()).toBe(200); // …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with // the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain… await setScale(page, SCALE); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER); return !!p && p.status === 'flying' && p.destIcao === b.icao; }, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) .toBe(true); // …then restore speed so the chained leg fast-forwards too. await setScale(page, SCALE); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER); return !!p && p.status === 'available' && p.locationIcao === b.icao; }, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` }) .toBe(true); // The second plane never moved — still grounded at EDDF with an empty queue. const s = await getState(page); expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available'); expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB); }); test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0) const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!; const payout = Math.round(entry.price * pre.sellFraction * 100) / 100; // The Sell row lists BOTH grounded planes… const sellSelect = page.getByTestId('market-sell-plane'); await expect(sellSelect).toBeVisible({ timeout: 15_000 }); await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1); await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1); // …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane. await sellSelect.selectOption(secondId); await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout)); await page.getByTestId('market-sell').click(); await expect .poll(async () => { const s = await getState(page); return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1; }, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' }) .toBe(true); // The last remaining plane can never be sold — even via the API. const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } }); expect(res.status()).toBe(409); });