451 lines
25 KiB
TypeScript
451 lines
25 KiB
TypeScript
import type { Page, Request } from '@playwright/test';
|
||
import { expect, test } from '@playwright/test';
|
||
// Real helpers (v0.36.0) — the wind-adjusted fuel 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 { windBurnFactor } from '../lib/sim/movement';
|
||
|
||
import { authenticate } from './auth';
|
||
|
||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||
|
||
// 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; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||
|
||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
|
||
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.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 }; // ISS-117: the snapshot carries only the game clock
|
||
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> {
|
||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||
for (let attempt = 1; ; attempt++) {
|
||
const res = await page.request.get('/api/state');
|
||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
|
||
// 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];
|
||
}
|
||
|
||
// 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<void> {
|
||
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);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate 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/test/set-timescale', { 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/test/set-timescale`).toBe(scale);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
|
||
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||
expect(res.status()).toBe(200);
|
||
}
|
||
|
||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
|
||
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is why the expiry
|
||
// test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health
|
||
// and no gate afterwards.
|
||
async function activateWorld(page: Page): Promise<void> {
|
||
await setHealth(page, 'plane-starter', { health: 50 });
|
||
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
|
||
}
|
||
|
||
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||
async function deactivateWorld(page: Page): Promise<void> {
|
||
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
|
||
await expect
|
||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
|
||
timeout: 30_000,
|
||
message: 'waiting for the starter to flip back to available',
|
||
})
|
||
.toBe('available');
|
||
}
|
||
|
||
// 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 world rate restores it to the fixed world rate (ISS-117) on exit so
|
||
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||
test.afterEach(async ({ page }) => {
|
||
await setScale(page, WORLD);
|
||
});
|
||
|
||
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> {
|
||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||
// exact-fuel pins never see a worn or broken plane (broken planes burn ×1.5 more).
|
||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||
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) {
|
||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||
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();
|
||
}
|
||
}
|
||
// Run #168 retry cascade: a dirty self-heal return leg lands with a low tank, and the test's own
|
||
// dispatch then auto-refuels AFTER its cash baseline is captured — top up for free (test hook) so
|
||
// exact-cash assertions never see that cost.
|
||
await setFuelFull(page, 'plane-starter');
|
||
}
|
||
|
||
|
||
|
||
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
|
||
// 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();
|
||
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
|
||
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||
|
||
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||
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),
|
||
// wind-adjusted (v0.36.0) by departure weather vs route heading.
|
||
const entry = before.catalog.find((e) => e.model === 'atr72')!;
|
||
const wHub = weatherFor(HUB);
|
||
expect(landed.fuel).toBeCloseTo(
|
||
fuelAfter(
|
||
before.planes.find((p) => p.id === planeId)!.fuel,
|
||
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||
entry.emptyWeightKg,
|
||
entry.maxFuel,
|
||
exactKm,
|
||
),
|
||
6,
|
||
);
|
||
|
||
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
|
||
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
|
||
|
||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
|
||
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);
|
||
|
||
// 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();
|
||
}
|
||
|
||
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair for the expiry
|
||
// window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k repair cost is why
|
||
// the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
|
||
await activateWorld(page);
|
||
try {
|
||
// 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');
|
||
} finally {
|
||
await deactivateWorld(page);
|
||
}
|
||
|
||
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
|
||
let s = before;
|
||
|
||
// 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: top up + fly home at SCALE.
|
||
await setFuelFull(page, 'plane-starter');
|
||
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(300_000); // self-heal + one short hop each way at scale 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, at SCALE.
|
||
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);
|
||
|
||
// ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
|
||
// auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
|
||
// insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
|
||
// app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
|
||
});
|
||
|
||
// 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)));
|
||
}
|