607 lines
31 KiB
TypeScript
607 lines
31 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';
|
||
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
||
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
|
||
|
||
// 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 with the ISS-057
|
||
// slow/resume semantics, refuel, 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; // 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
|
||
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' | 'ignored';
|
||
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; 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. Also records lastSelectedTimeScale
|
||
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
|
||
// proceeds at full speed.
|
||
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);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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
|
||
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
|
||
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
|
||
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);
|
||
const plane = s.planes.find((x) => x.id === planeId);
|
||
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
|
||
return plane?.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) {
|
||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||
await setScale(page, SCALE);
|
||
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||
if (flyHome.status() === 409) {
|
||
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
|
||
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> {
|
||
await sellNonStarters(page);
|
||
await healPlaneHome(page, STARTER);
|
||
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)
|
||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||
expect(res.status()).toBe(200);
|
||
});
|
||
|
||
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; the last landing slows to 1× and the next dispatch resumes', 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.
|
||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||
|
||
// 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 (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
|
||
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);
|
||
|
||
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
|
||
await setScale(page, SCALE);
|
||
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);
|
||
|
||
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
|
||
const landed = await getState(page);
|
||
expect(landed.simState.paused).toBe(false);
|
||
expect(landed.simState.timeScale).toBe(1);
|
||
// 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 up for the return leg.
|
||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||
|
||
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyB2.status()).toBe(200);
|
||
let body = (await flyB2.json()) as StateBody;
|
||
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
|
||
|
||
// …but dispatching the LAST grounded plane restores the last manually selected speed.
|
||
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||
expect(flyA2.status()).toBe(200);
|
||
body = (await flyA2.json()) as StateBody;
|
||
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||
});
|
||
|
||
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
|
||
const secondId = await buySecondPlaneViaUi(page);
|
||
|
||
// Partial tanks: a short hop each, to different destinations.
|
||
const [a, b] = await twoNearestToHub(page);
|
||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||
|
||
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
|
||
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 setScale(page, SCALE);
|
||
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 pre = await getState(page);
|
||
const starter = pre.planes.find((p) => p.id === STARTER)!;
|
||
const second = pre.planes.find((p) => p.id === secondId)!;
|
||
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
|
||
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
|
||
|
||
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
|
||
const refuelSelect = page.getByTestId('market-refuel-plane');
|
||
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
|
||
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||
|
||
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'),
|
||
// rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation.
|
||
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||
await refuelSelect.selectOption(secondId);
|
||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||
|
||
await page.getByTestId('market-refuel').click();
|
||
await expect
|
||
.poll(async () => {
|
||
const s = await getState(page);
|
||
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
|
||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||
.toBe(true);
|
||
|
||
const post = await getState(page);
|
||
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
|
||
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
|
||
});
|
||
|
||
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).
|
||
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||
|
||
// 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…
|
||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||
expect(flyRes.status()).toBe(200);
|
||
|
||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||
await setScale(page, SCALE);
|
||
await expect
|
||
.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||
.toBe(true);
|
||
|
||
// …then restore speed so the chained leg fast-forwards too.
|
||
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);
|
||
});
|