import { test, expect, type Page } from '@playwright/test'; // v0.10.0 — right Jobs & Market pane against the live origin: open contracts render with // names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created // EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight" // in the UI AND /api/state; market refuel through the pane deducts exactly // (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog + // economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract // (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. const SCALE = 60; const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts interface PlaneState { id: string; model: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; } interface ContractState { id: string; originName: string; destName: string; reward: number; status: 'open' | 'accepted' | 'completed' | 'expired'; planeId: string | null; } interface CatalogEntry { model: string; label: string; price: number; maxFuel: number; burnPerKm: number; speedKmh: number; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; contracts: ContractState[]; 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 }); } // 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); }); // 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))); } // Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 60. async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> { 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; let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; for (const f of fc.features) { if (f.properties.icao === HUB) continue; if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) { nearest = f.properties; } } return nearest; } // Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route. async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { const created = await page.request.post('/api/test/create-contract', { data: { originIcao: fromIcao, destIcao: toIcao }, }); expect(created.status()).toBe(201); return (await created.json()) as ContractState; } // Create + accept + dispatch a contract on plane-starter via the API, then wait for arrival. async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise { const contract = await createContract(page, fromIcao, toIcao); const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); expect(acc.status()).toBe(200); const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(disp.status()).toBe(200); await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const body = await getState(page); const p = body.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === toIcao; }, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` }) .toBe(true); } test('open contracts render with names, reward and deadline; Accept moves the contract into the accepted group', async ({ page }) => { test.setTimeout(90_000); await page.goto('/'); const right = page.getByTestId('right-pane'); await expect(right).toBeVisible({ timeout: 15_000 }); // ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button. const rows = page.locator('li[data-testid^="open-contract-"]'); await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toContainText(/→/); await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€'); await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible(); // Deterministic target: a hook-created EDDF contract, accepted through the UI. const nearest = await nearestToHub(page); const contract = await createContract(page, HUB, nearest.icao); const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`); await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible(); await page.getByTestId(`accept-${contract.id}`).click(); // Server truth first… const body = await getState(page); expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted'); // …and the UI: gone from open, present in the accepted group with dispatch controls. await expect(row).not.toBeVisible(); const accRow = page.getByTestId(`accepted-contract-${contract.id}`); await expect(accRow).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible(); }); test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => { test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes // Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at // another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts). let body = await getState(page); const starter0 = body.planes.find((p) => p.id === 'plane-starter'); expect(starter0).toBeDefined(); if (starter0!.status === 'flying') { await postSim(page, { timeScale: SCALE }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 90_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); body = await getState(page); } if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) { const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao; await flyViaApi(page, from, HUB); } // Hook contract on the shortest EDDF route, accepted through the UI. const nearest = await nearestToHub(page); const contract = await createContract(page, HUB, nearest.icao); await page.goto('/'); const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`); await expect(row).toBeVisible({ timeout: 15_000 }); await page.getByTestId(`accept-${contract.id}`).click(); // Dispatch controls appear in the accepted group; pick plane-starter and dispatch. const accRow = page.getByTestId(`accepted-contract-${contract.id}`); await expect(accRow).toBeVisible({ timeout: 10_000 }); const picker = page.getByTestId(`dispatch-plane-${contract.id}`); await expect(picker).toBeVisible(); await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option await page.getByTestId(`dispatch-${contract.id}`).click(); // UI shows the in-flight indicator… await expect(page.getByTestId(`contract-inflight-${contract.id}`)).toBeVisible({ timeout: 10_000 }); // …and /api/state agrees. body = await getState(page); let starter = body.planes.find((p) => p.id === 'plane-starter')!; expect(starter.status).toBe('flying'); // Fly to arrival at scale 60 (poll the authoritative state). await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === nearest.icao; }, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` }) .toBe(true); // Market refuel through the pane: the flight genuinely burned fuel… body = await getState(page); starter = body.planes.find((p) => p.id === 'plane-starter')!; expect(starter.fuel).toBeLessThan(starter.maxFuel); const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100; const cashBefore = body.account.cash; // …the refuel picker lists it, the preview matches the exact cost, and the click deducts it. await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 }); await page.getByTestId('market-refuel-plane').selectOption('plane-starter'); await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost)); await page.getByTestId('market-refuel').click(); // Cash decreased by exactly the expected cost and the tank is full (server truth). await expect .poll(async () => { const b = await getState(page); return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001; }, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' }) .toBe(true); body = await getState(page); starter = body.planes.find((p) => p.id === 'plane-starter')!; expect(starter.fuel).toBe(starter.maxFuel); // Fly home so the live instance stays in its onboarding state for the next run. await flyViaApi(page, nearest.icao, HUB); }); test('market buy/sell render with price and payout previews from the catalog in /api/state', async ({ page }) => { test.setTimeout(60_000); const body = await getState(page); // authoritative catalog + economy constants (v0.10.0) expect(body.catalog.length).toBeGreaterThan(0); const entry = body.catalog.find((e) => e.model === 'atr72')!; expect(entry).toBeDefined(); await page.goto('/'); // Buy: the model picker lists the catalog entry and the price preview is its full price. const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel).toBeVisible({ timeout: 15_000 }); await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label); await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price)); await expect(page.getByTestId('market-buy')).toBeVisible(); // Sell: the grounded-plane picker lists plane-starter and the payout preview is // sellFraction × price, both from the snapshot's catalog/constants. const starter = body.planes.find((p) => p.id === 'plane-starter')!; expect(starter.status).toBe('available'); // test order: it flew home at the end of the previous test await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 }); const payout = Math.round(entry.price * body.sellFraction * 100) / 100; await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout)); await expect(page.getByTestId('market-sell')).toBeVisible(); }); test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => { test.setTimeout(240_000); // two short flights at scale 60 plus UI steps // Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at // another airport (same pattern as the dispatch-loop test above). let body = await getState(page); const starter0 = body.planes.find((p) => p.id === 'plane-starter'); expect(starter0).toBeDefined(); if (starter0!.status === 'flying') { await postSim(page, { timeScale: SCALE }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 90_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); body = await getState(page); } if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) { const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao; await flyViaApi(page, from, HUB); } // Two hook contracts, both originating at EDDF on the shortest route. const nearest = await nearestToHub(page); const a = await createContract(page, HUB, nearest.icao); const b = await createContract(page, HUB, nearest.icao); await page.goto('/'); // Accept BOTH through the UI buttons. for (const c of [a, b]) { const row = page.locator(`li[data-testid="open-contract-${c.id}"]`); await expect(row).toBeVisible({ timeout: 15_000 }); await page.getByTestId(`accept-${c.id}`).click(); } const accA = page.getByTestId(`accepted-contract-${a.id}`); const accB = page.getByTestId(`accepted-contract-${b.id}`); await expect(accA).toBeVisible({ timeout: 10_000 }); await expect(accB).toBeVisible(); // Both dispatch pickers render with the placeholder default — nothing preselected (v0.10.1)… const pickerA = page.getByTestId(`dispatch-plane-${a.id}`); const pickerB = page.getByTestId(`dispatch-plane-${b.id}`); await expect(pickerA).toBeVisible(); await expect(pickerB).toBeVisible(); await expect(pickerA).toHaveValue(''); await expect(pickerB).toHaveValue(''); await expect(pickerA.locator('option[value=""]')).toHaveText(/select a plane/i); // …and the Dispatch buttons stay disabled until a valid plane is chosen. await expect(page.getByTestId(`dispatch-${a.id}`)).toBeDisabled(); await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled(); // Select plane-starter in contract A's picker → contract B's picker must STILL show the // placeholder (the v0.10.1 regression: selections used to appear shared across rows). await pickerA.selectOption('plane-starter'); await expect(pickerA).toHaveValue('plane-starter'); await expect(pickerB).toHaveValue(''); // Dispatch A with the selected plane → it goes 'flying' and its row shows the in-flight… await page.getByTestId(`dispatch-${a.id}`).click(); await expect(page.getByTestId(`contract-inflight-${a.id}`)).toBeVisible({ timeout: 10_000 }); body = await getState(page); expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying'); // …indicator, while B remains accepted with its own independent picker on the placeholder. await expect(accB).toBeVisible(); await expect(pickerB).toHaveValue(''); await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled(); // Restore: let A's flight finish and fly plane-starter home so later specs start clean. await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === nearest.icao; }, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` }) .toBe(true); await flyViaApi(page, nearest.icao, HUB); });