553 lines
28 KiB
TypeScript
553 lines
28 KiB
TypeScript
import type { Page } 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.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||
// spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
|
||
// per-plane queues (scoped testids) and sell.
|
||
//
|
||
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||
|
||
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'; // must match HUB_ICAO in lib/game/seed.ts
|
||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||
|
||
interface PlaneState {
|
||
id: string;
|
||
model: string;
|
||
status: 'available' | 'flying';
|
||
fuel: number;
|
||
maxFuel: number;
|
||
locationIcao: string;
|
||
destIcao: string | null;
|
||
queue: string[];
|
||
}
|
||
|
||
interface PackageState {
|
||
id: string;
|
||
originIcao: string;
|
||
destIcao: string;
|
||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||
planeId: string | null;
|
||
}
|
||
|
||
interface CatalogEntry {
|
||
model: string;
|
||
label: string;
|
||
price: number;
|
||
maxFuel: number;
|
||
burnPerKm: number;
|
||
emptyWeightKg: number;
|
||
speedKmh: number;
|
||
maxPayloadKg: number;
|
||
}
|
||
|
||
interface StateBody {
|
||
account: { cash: number; reputation: number };
|
||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||
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> {
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||
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);
|
||
}
|
||
|
||
// 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/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);
|
||
}
|
||
|
||
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
|
||
async function twoNearestToHub(page: Page): Promise<Array<{ 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;
|
||
return fc.features
|
||
.filter((f) => f.properties.icao !== HUB)
|
||
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||
.sort((x, y) => x.d - y.d)
|
||
.slice(0, 2)
|
||
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
|
||
}
|
||
|
||
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
|
||
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
|
||
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
|
||
// return leg is flown after a top-up, which also 409s if it doesn't fit.
|
||
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
|
||
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;
|
||
const wHub = weatherFor(HUB);
|
||
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);
|
||
const wDest = weatherFor(f.properties.icao);
|
||
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
|
||
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
||
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
|
||
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
|
||
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 { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
|
||
async function createPackage(
|
||
page: Page,
|
||
fromIcao: string,
|
||
toIcao: string,
|
||
deadlineOffsetMs?: number,
|
||
weightKg?: number,
|
||
): Promise<PackageState> {
|
||
const created = await page.request.post('/api/test/create-package', {
|
||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
|
||
});
|
||
expect(created.status()).toBe(201);
|
||
return (await created.json()) as PackageState;
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
|
||
// post-grant snapshot (the route's response).
|
||
async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
||
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
|
||
expect(res.status()).toBe(200);
|
||
return (await res.json()) as StateBody;
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
|
||
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||
async function setFuelFull(page: Page, planeId: string): Promise<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);
|
||
}
|
||
|
||
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
||
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
|
||
|
||
await page.goto('/');
|
||
const right = page.getByTestId('right-pane');
|
||
await expect(right).toBeVisible({ timeout: 15_000 });
|
||
await page.getByTestId('tab-planes-market').click();
|
||
|
||
const buyModel = page.getByTestId('market-buy-model');
|
||
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
|
||
await buyModel.selectOption(BUY_MODEL);
|
||
await page.getByTestId('market-buy').click();
|
||
|
||
let secondId: string | null = null;
|
||
await expect
|
||
.poll(async () => {
|
||
const s = await getState(page);
|
||
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
|
||
return secondId !== null;
|
||
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
|
||
.toBe(true);
|
||
return secondId!;
|
||
}
|
||
|
||
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||
for (;;) {
|
||
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||
if (q.length === 0) return;
|
||
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||
}
|
||
}
|
||
|
||
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
|
||
// carrying cargo, and clean baselines keep exact-cash assertions honest.
|
||
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
|
||
const s = await getState(page);
|
||
for (const pkg of s.packages) {
|
||
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
|
||
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
|
||
// specs, generalized to an arbitrary plane id.
|
||
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||
await clearQueueViaApi(page, planeId);
|
||
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||
if (!p) return; // already sold — nothing to heal
|
||
// Make sure the world rate is at SCALE so any in-flight leg completes fast.
|
||
await setScale(page, SCALE);
|
||
if (p.status === 'flying') {
|
||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||
await expect
|
||
.poll(async () => {
|
||
const s = await getState(page);
|
||
return s.planes.find((x) => x.id === planeId)?.status ?? '';
|
||
}, {
|
||
timeout: 240_000,
|
||
message: `waiting for an in-flight ${planeId} to land`,
|
||
})
|
||
.toBe('available');
|
||
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||
}
|
||
if (p.locationIcao !== HUB) {
|
||
// Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
|
||
// so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
|
||
await setScale(page, SCALE);
|
||
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyHome.status()).toBe(200);
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||
timeout: 240_000,
|
||
message: `waiting for ${planeId} to land back at the hub`,
|
||
})
|
||
.toBe(HUB);
|
||
}
|
||
}
|
||
|
||
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
|
||
// in flight) — leaves the fleet at exactly one plane.
|
||
async function sellNonStarters(page: Page): Promise<void> {
|
||
const s = await getState(page);
|
||
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
|
||
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
|
||
await abandonLeftoverCargo(page, p.id);
|
||
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
|
||
}
|
||
}
|
||
|
||
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||
// no cargo on board — even after an interrupted previous run.
|
||
async function resetFleet(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 before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||
await sellNonStarters(page);
|
||
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||
await abandonLeftoverCargo(page, STARTER);
|
||
}
|
||
|
||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||
|
||
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
|
||
test.beforeEach(async ({ page }) => {
|
||
await resetFleet(page);
|
||
});
|
||
|
||
test.afterEach(async ({ page }) => {
|
||
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||
await setScale(page, WORLD);
|
||
});
|
||
|
||
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
|
||
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
|
||
|
||
const [nearest] = await twoNearestToHub(page);
|
||
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
|
||
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||
|
||
// The multi-plane picker lists BOTH planes…
|
||
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
|
||
await expect(picker).toBeVisible({ timeout: 15_000 });
|
||
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||
|
||
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
|
||
const s0 = await getState(page);
|
||
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
|
||
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
|
||
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
|
||
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
|
||
|
||
// One-click Load binds the package to the preselected (first eligible) plane.
|
||
await loadBtn1.click();
|
||
await expect
|
||
.poll(async () => {
|
||
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
|
||
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
|
||
.toBe(firstEligible.id);
|
||
|
||
// Second package — pick the OTHER plane explicitly via the dropdown.
|
||
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
|
||
await expect(picker2).toBeVisible({ timeout: 15_000 });
|
||
await picker2.selectOption(secondId);
|
||
await page.getByTestId(`load-${pkg2.id}`).click();
|
||
await expect
|
||
.poll(async () => {
|
||
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
|
||
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
||
.toBe(secondId);
|
||
|
||
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
|
||
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||
});
|
||
|
||
test('two planes fly in parallel and both deliver their packages', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
||
const secondId = await buySecondPlaneViaUi(page);
|
||
|
||
// Full tanks for both — the starter's may be partial from an earlier run.
|
||
await setFuelFull(page, STARTER);
|
||
await setFuelFull(page, secondId);
|
||
|
||
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||
// the same tick (a shared-tick arrival would race the first-landing assert).
|
||
const [nearest] = await twoNearestToHub(page);
|
||
const farthest = await farthestFlyableToHub(page);
|
||
|
||
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
|
||
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
|
||
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
|
||
|
||
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||
expect(flyA.status()).toBe(200);
|
||
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
|
||
expect(flyB.status()).toBe(200);
|
||
|
||
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
|
||
const s1 = (await flyB.json()) as StateBody;
|
||
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
|
||
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
|
||
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||
|
||
// Fast-forward to the FIRST landing (the short hop)…
|
||
await setScale(page, SCALE);
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||
timeout: 240_000,
|
||
message: 'waiting for the first plane to land',
|
||
})
|
||
.toBe(true);
|
||
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||
timeout: 240_000,
|
||
message: 'waiting for both planes to land',
|
||
})
|
||
.toBe(true);
|
||
|
||
const landed = await getState(page);
|
||
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||
|
||
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
|
||
await setFuelFull(page, secondId);
|
||
|
||
// Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
|
||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
|
||
});
|
||
|
||
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
|
||
// its absence is pinned in e2e/panes.spec.ts instead.
|
||
|
||
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||
|
||
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
|
||
|
||
// The Relocate section lists BOTH grounded planes…
|
||
const relocateSelect = page.getByTestId('relocate-plane');
|
||
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
|
||
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||
|
||
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
|
||
const destSelect = page.getByTestId('relocate-dest');
|
||
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||
|
||
// Relocate ONLY the second plane…
|
||
await relocateSelect.selectOption(secondId);
|
||
await destSelect.selectOption(a.icao);
|
||
await page.getByTestId('relocate-go').click();
|
||
|
||
// …it is flying to the new airport while the starter stays put at EDDF.
|
||
await expect
|
||
.poll(async () => {
|
||
const s = await getState(page);
|
||
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
|
||
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
|
||
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
|
||
.toBe(true);
|
||
|
||
// Let it land — locationIcao flips to the destination on arrival.
|
||
await setScale(page, SCALE);
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
|
||
timeout: 240_000,
|
||
message: `waiting for the relocated plane to land at ${a.icao}`,
|
||
})
|
||
.toBe(a.icao);
|
||
|
||
const s = await getState(page);
|
||
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
|
||
});
|
||
|
||
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||
const secondId = await buySecondPlaneViaUi(page);
|
||
const [a, b] = await twoNearestToHub(page);
|
||
|
||
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||
await setFuelFull(page, STARTER);
|
||
|
||
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
||
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||
|
||
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
|
||
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
|
||
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
|
||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
|
||
|
||
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
|
||
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
|
||
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
|
||
|
||
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
|
||
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
|
||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
||
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||
|
||
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
|
||
// response snapshot must already show only [b] left (previously the leg stayed queued and the
|
||
// landing chained a zero-distance self-hop a→a before reaching b).
|
||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||
expect(flyRes.status()).toBe(200);
|
||
const flyBody = (await flyRes.json()) as StateBody;
|
||
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||
|
||
// …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
|
||
// landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
|
||
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||
await expect
|
||
.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
|
||
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||
.toBe(true);
|
||
|
||
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
|
||
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||
|
||
// …fast-forward the chained leg to its landing.
|
||
await setScale(page, SCALE);
|
||
await expect
|
||
.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||
return !!p && p.status === 'available' && p.locationIcao === b.icao;
|
||
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
|
||
.toBe(true);
|
||
|
||
// The second plane never moved — still grounded at EDDF with an empty queue.
|
||
const s = await getState(page);
|
||
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
|
||
});
|
||
|
||
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||
|
||
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
|
||
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
|
||
|
||
// The Sell row lists BOTH grounded planes…
|
||
const sellSelect = page.getByTestId('market-sell-plane');
|
||
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
|
||
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||
|
||
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
|
||
await sellSelect.selectOption(secondId);
|
||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||
await page.getByTestId('market-sell').click();
|
||
|
||
await expect
|
||
.poll(async () => {
|
||
const s = await getState(page);
|
||
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
|
||
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
|
||
.toBe(true);
|
||
|
||
// The last remaining plane can never be sold — even via the API.
|
||
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
|
||
expect(res.status()).toBe(409);
|
||
});
|