Files
Flight-Ops/e2e/actions.spec.ts
T
kamilkrzywda 179fad450d
CI / build + unit + e2e (pull_request) Successful in 8m2s
Package weight, payload capacity and per-kg payout (ISS-045)
Packages carry a real weightKg (unit weight x count with +/-30% jitter),
plane capacity becomes maxPayloadKg instead of 6 slots, load validation
sums cargo weight against the payload, and the payout gains a per-kg part.
Loadout display shows tonnage; OpenAPI swaps CatalogEntry.capacity for
maxPayloadKg and adds PackageState.weightKg.
2026-09-20 12:42:03 +00:00

418 lines
23 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 (left pane since
// v0.20.0, ISS-021) → 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 — a long leg: under the v0.16.0 weighted model a full tank covers the
// round trip, but the return leaves too little for another outbound
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
// Fuel remaining after distanceKm from startFuel — closed form of dF/dx = −k(We + ρF),
// k calibrated so a full tank burns exactly burnPerKm (mirrors fuelAtWeighted).
function fuelAfter(startFuel: number, burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
const rho = FUEL_DENSITY_KG_PER_L;
return Math.max(0, (startFuel + emptyWeightKg / rho) * Math.exp(-k * rho * distanceKm) - emptyWeightKg / rho);
}
// 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;
}>;
catalog: Array<{ model: string; burnPerKm: number; emptyWeightKg: number; maxFuel: number }>; // v0.16.0 weighted burn inputs
}
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.
// weightKg (v0.27.0) forces the package weight instead of the RNG draw, so flows that load
// several packages onto one plane stay under its payload deterministically.
async function createPackage(
page: Page,
from?: string,
to?: string,
deadlineOffsetMs?: number,
weightKg?: number,
): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs, weightKg } });
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.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
// resume it or the clock stays frozen and the next wait hangs.
async function setScale(page: Page, scale: number): Promise<void> {
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);
}
// 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> {
let s = await getState(page);
let plane = s.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. While flying, locationIcao stays at the
// DEPARTURE airport, so the pre-landing snapshot can't tell us where the plane ends up — re-read
// state after landing before deciding whether a repositioning flight is needed (run #41 retry: a
// leftover EDDF→EDFH leg landed away from home but the helper still believed it was home and
// skipped the fly-home).
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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);
// Precondition (server truth) before clicking Load: the starter must be grounded at the package's
// origin — that is exactly what makes it load-eligible, and a stale pane snapshot can't prove it.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'available' && p.locationIcao === pkg.originIcao;
}, { timeout: 30_000, message: 'waiting for plane-starter to be grounded at the package origin (self-heal incomplete?)' }).toBe(true);
// 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();
const planeId = 'plane-starter';
// The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view),
// so a getState issued immediately can race ahead of it and still see 'available' — poll until the
// fly actually took effect on the server (the run #36/#41 flake).
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === planeId)!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
let s = await getState(page);
// 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 per the v0.16.0 weight-proportional model (closed form — no rounding drift).
const entry = before.catalog.find((e) => e.model === 'atr72')!;
expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, 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). ISS-041: the
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
await setScale(page, SCALE);
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. Forced weights keep
// the pair under the starter's payload (v0.27.0) so both loads are deterministic.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000, 1_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, 1_000); // short game-time deadline; forced weights keep the pair under payload (v0.27.0)
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000, 1_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. ISS-041: the LEMD arrival above paused the sim, so
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
await page.request.post('/api/planes/plane-starter/refuel');
await setScale(page, SCALE);
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. ISS-041: the hop arrival above
// paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
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 (v0.16.0 weighted burn): a full tank covers the FAR round trip —
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
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 legs
}
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 fits: the weighted burn over it is less than what's left in the tank.
// ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
// …but the tank is now too light for another FAR leg.
const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(againFar.status()).toBe(409);
expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel (the plane is already home).
await page.request.post('/api/planes/plane-starter/refuel');
});
// 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)));
}