Files
Flight-Ops/e2e/right-pane.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

428 lines
22 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 } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination +
// abandon buttons (penalty preview from the same
// formula as the server); flying planes show their current leg; market buy/sell previews match
// the catalog + economy constants exposed in /api/state, and the Refuel-to-full row (moved to the
// left pane in v0.23.0, ISS-035) deducts exactly its cost. Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
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
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
}
interface PackageState {
id: string;
originName: string;
destName: string;
reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
}
interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
emptyWeightKg: number; // v0.16.0 weighted burn input
speedKmh: number;
maxPayloadKg: number; // v0.27.0 — replaces the v0.12.0 slot capacity
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
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<StateBody> {
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 });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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);
}
// 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)));
}
// 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;
}
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 1000.
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;
}
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
// this test wants a long-but-flyable leg instead.
async function farthestFlyableToHub(
page: Page,
entry: CatalogEntry,
): 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 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);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
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 { ...farthest, hubLat: hub.lat, hubLon: hub.lon };
}
// 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);
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-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 === 'plane-starter')?.status === 'available', {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe(true);
}
if (starter.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-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
}
}
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
await page.goto('/');
const right = page.getByTestId('right-pane');
await expect(right).toBeVisible({ timeout: 15_000 });
// ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button.
const rows = page.locator('li[data-testid^="open-package-"]');
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled();
// Load through the UI — exactly one eligible plane, so the button acts directly.
await loadBtn.click();
const body = await getState(page);
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded');
expect(body.packages.find((p) => p.id === pkg.id)?.planeId).toBe('plane-starter');
// The loaded package leaves the open list and shows in the on-board section.
await expect(row).not.toBeVisible();
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
const pkg = await createPackage(page, 'LEMD', HUB);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
});
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
test.setTimeout(240_000); // one short flight at scale 1000 (seconds of real time) plus UI steps
await flyHomeViaApi(page);
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
const loadRes = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(loadRes.status()).toBe(200);
await page.goto('/');
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
// making the preview deterministic instead of racing the 1 s poll loop.
await postSim(page, { paused: true });
const state = await getState(page);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
// Fly to the destination through the UI — the button is the destination's name.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible();
await expect(leg).toContainText('→');
// …and /api/state agrees.
let body = await getState(page);
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
// Resume and fast-forward to arrival (poll the authoritative state).
await postSim(page, { paused: false });
await setScale(page, 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: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 1000)` })
.toBe(true);
// The in-deadline package was delivered on arrival — it leaves the on-board list…
body = await getState(page);
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run.
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
});
test('market buy/sell render with previews from the catalog in /api/state; left-pane Refuel deducts exactly', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
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.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts)
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');
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));
// ISS-035 (v0.23.0): the Refuel-to-full row now lives in the LEFT pane next to Relocate — same global testids.
await expect(page.getByTestId('left-pane').getByTestId('market-refuel')).toBeVisible({ timeout: 15_000 });
// Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < 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);
const after = await getState(page);
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
}
});
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page);
const body = await getState(page);
const entry = body.catalog.find((e) => e.model === 'atr72')!;
// Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
// exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg1.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Fly there (delivers pkg1 on arrival) — the tank is now partial.
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: farthest.icao } })).status()).toBe(200);
await setScale(page, 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 === farthest.icao;
}, { timeout: 240_000, message: `waiting for arrival at ${farthest.icao} (scale 1000)` })
.toBe(true);
// New work at the far end: a package back to the hub — its leg is exactly what we refuel for.
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
await page.goto('/');
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
await expect(refuelBtn, 'row Refuel button appears for the loaded package').toBeVisible({ timeout: 15_000 });
// Capture the endpoint's OWN response — the full post-action snapshot at the action's clock.
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/planes/plane-starter/refuel`), { timeout: 15_000 }),
refuelBtn.click(),
]);
expect(res.status()).toBe(200);
const post = (await res.json()) as StateBody;
// Cash dropped by exactly L × price and fuel rose by exactly L.
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6);
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(fuelBefore + liters, 6);
// Left pane (moved from the market in v0.23.0, ISS-035): the fill-up button is labeled "Refuel to full" (testid unchanged).
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
// Top the tank back up so later specs start with a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
});