Files
Flight-Ops/e2e/actions.spec.ts
T
2026-09-19 08:49:30 +00:00

356 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load
// via the right pane → click its destination in the on-board list → fly at 1000× →
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
// penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable
// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72).
const BURN_PER_KM = 1.75;
const FUEL_PRICE_PER_L = 0.8;
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
const span = deadlineSimTime - createdSimTime;
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
packages: Array<{
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number;
}>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number];
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { 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/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.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;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
}
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
test.afterEach(async ({ page }) => {
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// Self-heal helper: let any in-flight leg finish, reposition plane-starter home via the API
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
const plane = s0.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') {
// Let the in-flight leg finish before repositioning.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
}
if (plane.locationIcao !== HUB) {
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // 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 }).toBe(HUB);
}
// Abandon leftover loaded/expired packages (expired ones cost nothing; loaded ones take a
// penalty — irrelevant, the baseline snapshot 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();
}
}
}
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights + refuel
// Self-heal: the starter plane may be anywhere from a previous run/spec.
await flyHomeViaApi(page);
const before = await getState(page);
expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB);
// Nearest airport to the hub → shortest possible flight (fastest e2e loop).
const nearest = await nearestToHub(page);
// A known short package at the hub with a generous deadline.
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
expect(pkg.originIcao).toBe(HUB);
expect(pkg.destIcao).toBe(nearest.icao);
// LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button.
await page.goto('/');
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled();
await loadBtn.click();
// The action endpoint's full snapshot is applied immediately (applySnapshot) — the
// package shows as loaded on the starter in the on-board section.
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
// FLY via the UI: click the destination name button in the on-board list.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
let s = await getState(page);
const planeId = 'plane-starter';
expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying');
expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao);
// Fast-forward to arrival (a few seconds of real time at 1000×).
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.status), { timeout: 120_000 }).toBe('available');
s = await getState(page);
const landed = s.planes.find((p) => p.id === planeId)!;
expect(landed.locationIcao).toBe(nearest.icao); // arrived at the package's destination
expect(landed.destIcao).toBeNull(); // flight context cleared on landing
// EXACT reward: cash increased by exactly the package's payout…
const delivered = s.packages.find((p) => p.id === pkg.id)!;
expect(delivered.status).toBe('delivered');
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward, 6);
// …and rep gained per the engine formula (great-circle km of the leg just flown),
// mirroring lib/sim/engine.ts's double rounding (gain rounded to cents, then total).
const exactKm = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned exactly burnPerKm × km (analytic movement — no rounding drift).
expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy();
s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// Restore: fly home (repositioning — no package needed in the v0.12.0 model).
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
});
test('abandon while grounded deducts exactly the formula penalty', async ({ page }) => {
test.setTimeout(120_000);
await flyHomeViaApi(page);
const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
}
// Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked
// against the exact game clock the server used (a later GET would tick the clock further).
await page.goto('/');
const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`);
await expect(abandonBtn).toBeEnabled();
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }),
abandonBtn.click(),
]);
expect(res.status()).toBe(200);
const s = (await res.json()) as StateBody; // full post-action snapshot at the penalty's clock
const expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs);
expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor
const abandoned = s.packages.find((p) => p.id === pkgA.id)!;
expect(abandoned.status).toBe('abandoned');
expect(abandoned.planeId).toBeNull(); // slot freed
expect(s.account.cash).toBeCloseTo(before.account.cash - expectedPenalty, 6);
// B is still on board — abandon it via the API to leave a clean state.
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
expect(resB.ok()).toBeTruthy();
});
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page);
const before = await getState(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
// still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
}
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
const landedA = s.packages.find((p) => p.id === pkgA.id)!;
expect(landedA.status).toBe('expired'); // still expired — never delivered…
expect(landedA.planeId).toBeNull(); // …but the slot was freed on arrival
expect(s.account.cash).toBe(before.account.cash); // NO reward for expired cargo
expect(s.account.reputation).toBe(before.account.reputation); // no rep either
// B: abandon an expired package while grounded — penalty is exactly €0.
const cashBeforeAbandon = s.account.cash;
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
expect(resB.ok()).toBeTruthy();
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home (scale is still SCALE from the expiry wait above).
await page.request.post('/api/planes/plane-starter/refuel');
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
});
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
test.setTimeout(600_000); // the FAR round trip alone is ~30 s real time at 1000×
await flyHomeViaApi(page);
// Load a package whose origin the plane is not at → 409.
const pkg = await createPackage(page, 'LEMD', HUB, 24 * 3_600_000); // originates in Madrid, plane sits in Frankfurt
let res = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.status()).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
// Load an unknown package → 404.
res = await page.request.post('/api/packages/no-such-package/load', { data: { planeId: 'plane-starter' } });
expect(res.status()).toBe(404);
// Fly to an ICAO not in the dataset → 400.
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'ZZZZ' } });
expect(res.status()).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
// Fly while already flying → 409 (short hop leg on the nearest route, then let it finish).
const hopDest = await nearestToHub(page);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: hopDest.icao } });
expect(res.ok()).toBeTruthy();
const again = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(again.status()).toBe(409);
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(hopDest.icao);
// Abandon an open package → 409 (nothing loaded to abandon).
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409);
// Fly home from the hop so the fuel section starts at the hub.
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
// Insufficient fuel: a full-tank one-way burn of > half the tank makes the return leg unfuelable.
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
if (starter.fuel < starter.maxFuel) {
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long leg
}
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(farRes.ok()).toBeTruthy();
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
const sFar = await getState(page);
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR);
// The return leg needs the same burn as the outbound — more than what's left.
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.status()).toBe(409);
expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel + fly home.
await page.request.post('/api/planes/plane-starter/refuel');
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
});
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371;
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 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}