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'; import { authenticate } from './auth'; // v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. test.beforeEach(async ({ page }) => { await authenticate(page); }); // 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, 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; // test rate for fast-forwarding legs — keeps flights to seconds of real time const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts 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'; 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 }; // ISS-117: the snapshot carries only the game clock 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 { // ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would // instantly fail any expect.poll callback, so retry briefly instead. for (let attempt = 1; ; attempt++) { const res = await page.request.get('/api/state'); if (res.status() === 200) return (await res.json()) as StateBody; if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); await page.waitForTimeout(500); } } // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } }); expect(res.status()).toBe(200); const body = (await res.json()) as { timeScale: number }; expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).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; } // Test hook (FLIGHT_TEST_HOOKS=1): top a plane'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); } // 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 // Make sure the world rate is at SCALE so any in-flight leg completes fast. 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); return s.planes.find((x) => x.id === planeId)?.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) { // Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels, // so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed. await setScale(page, SCALE); const 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 { // v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any // active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter. expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200); await sellNonStarters(page); await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available 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) await setScale(page, WORLD); }); 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 and both deliver their packages', 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. await setFuelFull(page, STARTER); await setFuelFull(page, secondId); // 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 (a shared-tick arrival would race the first-landing assert). 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); 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 landed = await getState(page); // 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 it back up (ISS-082 hook). await setFuelFull(page, secondId); // Cleanup: fly both home so the live instance stays in its onboarding state for later specs. expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200); }); // ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI — // its absence is pinned in e2e/panes.spec.ts instead. 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). await setFuelFull(page, STARTER); // 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 — v0.46.0 consumes queue[0] on dispatch, so the // response snapshot must already show only [b] left (previously the leg stayed queued and the // landing chained a zero-distance self-hop a→a before reaching b). const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } }); expect(flyRes.status()).toBe(200); const flyBody = (await flyRes.json()) as StateBody; expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]); // …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the // landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain… const seen: Array<{ locationIcao: string; destIcao: string | null }> = []; await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER); if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao }); return !!p && p.status === 'flying' && p.destIcao === b.icao; }, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) .toBe(true); // …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state // is either the outbound leg (HUB→a) or the chained leg (a→b). expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]); // …fast-forward the chained leg to its landing. 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); });