import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // Real helpers (v0.39.0) — the broken-state burn 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 { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement'; // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers. import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog'; // v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in // the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short // full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts // exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate // passes; shortening the gate via the hook brings it back to service on the next tick. 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'; interface PlaneState { id: string; model: string; status: 'available' | 'flying' | 'repairing'; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null; health: number; // v0.39.0 (ISS-069) — structural health in [0, 100] repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate } interface CatalogEntry { model: string; burnPerKm: number; emptyWeightKg: number; maxFuel: number; } interface StateBody { account: { cash: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; catalog: CatalogEntry[]; } 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; } // Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise // surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×). 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); } // Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate. async function setHealth( page: Page, body: { planeId: string; health?: number; repairUntilMs?: number }, ): Promise { const res = await page.request.post('/api/test/set-health', { data: body }); 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); } // 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 — the short fixed leg for the broken-burn flight. // Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts. async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: 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 { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon }; } // Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an // interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in // right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes. async function healStarterHome(page: Page): Promise { let s = await getState(page); let starter = s.planes.find((p) => p.id === STARTER)!; if (starter.status === 'repairing') { // An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back. await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 }); await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 30_000, message: 'waiting for the shortened repair gate to flip plane-starter back', }) .toBe('available'); s = await getState(page); starter = s.planes.find((p) => p.id === STARTER)!; } if (starter.status === 'flying' || starter.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (starter.status === 'flying') { await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe('available'); s = await getState(page); starter = s.planes.find((p) => p.id === STARTER)!; } if (starter.locationIcao !== HUB) { // ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg. await setScale(page, SCALE); // 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. const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(HUB); } } // Leave a clean starter for later spec files (shared DB, workers:1): full health and no active // repair gate — an interrupted run may have left the plane mid-repair or worn down. async function restoreStarter(page: Page): Promise { const s = await getState(page); const starter = s.planes.find((p) => p.id === STARTER)!; const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 }; if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick await setHealth(page, body); } test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file // On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention) // and leave a clean starter — full health, no active repair gate. test.afterEach(async ({ page }) => { const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); expect(res.status()).toBe(200); await restoreStarter(page); }); test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => { test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait await healStarterHome(page); // deterministic: the starter sits grounded at EDDF // (a) Force health to 42 — snapshot AND UI show it. let s = await setHealth(page, { planeId: STARTER, health: 42 }); expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42); await page.goto('/'); const row = page.getByTestId(`plane-${STARTER}`); await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value') .toContainText('42%'); // (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel. s = await setHealth(page, { planeId: STARTER, health: 0 }); expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0); await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears') .toHaveText('broken', { timeout: 15_000 }); // Full tank for the flight (ISS-082 hook — dispatch would only top up to 150% of the leg, not full)… await setFuelFull(page, STARTER); s = await getState(page); const starter = s.planes.find((p) => p.id === STARTER)!; expect(starter.fuel).toBe(starter.maxFuel); // …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch). const nearest = await nearestToHub(page); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200); await setScale(page, SCALE); await expect .poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 120_000, message: 'waiting for the broken plane to land at the nearest airport', }) .toBe(nearest.icao); // Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts). s = await getState(page); const landed = s.planes.find((p) => p.id === STARTER)!; const entry = s.catalog.find((e) => e.model === starter.model)!; const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon); const wHub = weatherFor(HUB); const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)); expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6); // Wear clamped at 0 — the broken plane stays broken. expect(landed.health).toBe(0); // (c) Repair through the UI: exact cash drop + "in repair" pill. const cashBefore = s.account.cash; const repairBtn = page.getByTestId(`repair-${STARTER}`); await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 }); // The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is // checked against the exact game clock the server used (a later GET would tick further). const [res] = await Promise.all([ page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }), repairBtn.click(), ]); expect(res.status()).toBe(200); const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST const repaired = post.planes.find((p) => p.id === STARTER)!; expect(repaired.status).toBe('repairing'); expect(repaired.health).toBe(100); // restored to full… expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out) await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears') .toHaveText('in repair', { timeout: 15_000 }); // (d) Shorten the gate via the hook — the plane comes back to service on the next tick. await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 }); await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale) await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; return p.status === 'available' && p.health === 100 ? true : false; }, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' }) .toBe(true); });