495 lines
26 KiB
TypeScript
495 lines
26 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 { fuelAtWeighted, guaranteedRangeKm, 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, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
|
||
import { STARTING_REPUTATION } from '../lib/game/seed';
|
||
|
||
// 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 dispatch auto-refuels a low tank to
|
||
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
|
||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||
|
||
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
|
||
|
||
interface PlaneState {
|
||
id: string;
|
||
model: string;
|
||
status: 'available' | 'flying';
|
||
fuel: number;
|
||
maxFuel: number;
|
||
health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
|
||
locationIcao: string;
|
||
destIcao: string | null;
|
||
}
|
||
|
||
interface PackageState {
|
||
id: string;
|
||
originName: string;
|
||
destName: string;
|
||
reward: number;
|
||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
|
||
planeId: string | null;
|
||
createdSimTime: number;
|
||
deadlineSimTime: number;
|
||
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
|
||
}
|
||
|
||
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 }; // 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 —
|
||
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
|
||
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);
|
||
}
|
||
|
||
// Leave the live instance in a sane state for the other specs (the fixed world rate).
|
||
test.afterEach(async ({ page }) => {
|
||
await setScale(page, WORLD);
|
||
});
|
||
|
||
// 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.
|
||
// Returns both endpoints' coordinates so exact-km fuel assertions can mirror lib/geo.ts.
|
||
async function nearestToHub(page: Page): 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 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, 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. Omitting `special`
|
||
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
|
||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
|
||
const created = await page.request.post('/api/test/create-package', {
|
||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
|
||
});
|
||
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> {
|
||
// 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
|
||
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||
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) {
|
||
// Make sure the world rate is back at SCALE so the return leg fast-forwards. 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/plane-starter/fly', { data: { destIcao: HUB } });
|
||
expect(flyHome.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);
|
||
}
|
||
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||
const s1 = await getState(page);
|
||
for (const pkg of s1.packages) {
|
||
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||
expect(res.ok()).toBeTruthy();
|
||
}
|
||
}
|
||
}
|
||
|
||
test('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('€');
|
||
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
|
||
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
|
||
|
||
// 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, undefined, 4_000);
|
||
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 });
|
||
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
|
||
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
|
||
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||
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('a priority package shows its special badge on the open-package row', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||
|
||
const nearest = await nearestToHub(page);
|
||
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
|
||
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
|
||
|
||
await page.goto('/');
|
||
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 });
|
||
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
|
||
const badge = page.getByTestId(`package-special-${pkg.id}`);
|
||
await expect(badge).toBeVisible();
|
||
await expect(badge).toHaveText('Priority');
|
||
});
|
||
|
||
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 penalty at its snapshot's clock (same formula as the server).
|
||
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
|
||
// other planes), so the world clock is FROZEN (#135) — verify it actually is, then the preview must
|
||
// match the server exactly: a frozen clock means zero drift between any two snapshots.
|
||
const c0 = (await getState(page)).simState.clockMs;
|
||
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
|
||
const state = await getState(page);
|
||
expect(state.simState.clockMs - c0, 'the grounded clock must stay frozen (#135)').toBe(0);
|
||
|
||
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
|
||
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
|
||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||
expect(Number(penaltyText), 'frozen clock → the preview matches the server exactly').toBe(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 fleet row now shows the plane in flight with its current leg (ISS-054)…
|
||
await expect(page.getByTestId('plane-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);
|
||
|
||
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
|
||
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 at SCALE so the live instance stays in its onboarding state for the next run.
|
||
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', 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('/');
|
||
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
|
||
await page.getByTestId('tab-planes-market').click();
|
||
|
||
// Buy: the model picker lists the catalog entry and the price preview is its EFFECTIVE price —
|
||
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list 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);
|
||
const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
|
||
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
|
||
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
|
||
if (body.account.reputation > STARTING_REPUTATION) {
|
||
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
|
||
} else {
|
||
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
|
||
}
|
||
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-082 (v0.42.0): manual refuel is gone — dispatch auto-refuels instead. This test pins the new
|
||
// behavior end-to-end: drop a grounded starter to low fuel via the hook, fly a leg, and assert the
|
||
// cash delta equals the EXACT rep-discounted top-up cost (computed from the snapshot + real lib
|
||
// helpers) while the post-flight landed fuel matches the weighted burn from the topped start value.
|
||
test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement and deducts exactly', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + one short flight each way at scale 1000
|
||
await flyHomeViaApi(page);
|
||
|
||
const body = await getState(page);
|
||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||
|
||
// Drop the grounded starter to a low, deterministic fuel level via the test hook — well below the
|
||
// 150%-of-requirement target even for the shortest dataset leg (EDFH, ~93 km), so a top-up is guaranteed.
|
||
const LOW_FUEL = 50;
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: LOW_FUEL } })).status()).toBe(200);
|
||
|
||
// Nearest airport to the hub → short leg, fast at scale 1000.
|
||
const nearest = await nearestToHub(page);
|
||
const pre = await getState(page);
|
||
const starter = pre.planes.find((p) => p.id === 'plane-starter')!;
|
||
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
|
||
|
||
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
|
||
// wind-adjusted requirement, 150% margin capped at tank capacity.
|
||
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||
const wHub = weatherFor(HUB);
|
||
const required = requiredFuelFor(
|
||
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||
entry.emptyWeightKg,
|
||
entry.maxFuel,
|
||
km,
|
||
);
|
||
const target = Math.min(entry.maxFuel, Math.max(LOW_FUEL, required * 1.5)); // ISS-082: 150% margin, capped at tank capacity
|
||
const liters = Math.max(0, target - LOW_FUEL);
|
||
expect(liters).toBeGreaterThan(0); // the low tank genuinely needs a top-up
|
||
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||
const cashBefore = pre.account.cash;
|
||
|
||
// Dispatch via the API — the response snapshot is authoritative at the action's clock.
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||
expect(flyRes.status()).toBe(200); // auto-refuel made the low tank flyable…
|
||
const post = (await flyRes.json()) as StateBody;
|
||
expect(post.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(target, 6); // …departing with exactly the topped level
|
||
expect(post.account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
|
||
|
||
// Fast-forward to arrival; the landed fuel is the weighted burn from the TOPPED start value.
|
||
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);
|
||
|
||
const landed = await getState(page);
|
||
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
|
||
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
|
||
6,
|
||
);
|
||
|
||
// Restore: fly home (auto-refuel covers the return leg) and top the tank back up for later specs.
|
||
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: 120_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
})
|
||
.toBe(true);
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
|
||
});
|
||
|
||
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||
|
||
const nearest = await nearestToHub(page);
|
||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||
|
||
await page.goto('/');
|
||
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 });
|
||
|
||
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
|
||
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
|
||
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
|
||
await ignoreBtn.click();
|
||
|
||
// Server truth: the package is 'ignored'…
|
||
await expect
|
||
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
|
||
timeout: 15_000,
|
||
message: 'waiting for the ignored status in /api/state',
|
||
})
|
||
.toBe('ignored');
|
||
|
||
// …and the row is gone from the open list (the section filters status==='open').
|
||
await expect(row).not.toBeVisible({ timeout: 15_000 });
|
||
|
||
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
|
||
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||
expect(again.status()).toBe(409);
|
||
});
|
||
|
||
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
||
await page.goto('/');
|
||
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
|
||
expect(body.catalog.length).toBe(6);
|
||
|
||
await page.getByTestId('tab-planes-market').click();
|
||
|
||
// ATR card: name + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0).
|
||
const atr = body.catalog.find((e) => e.model === 'atr72')!;
|
||
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
|
||
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
|
||
const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
|
||
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
|
||
|
||
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
|
||
const b777 = page.getByTestId('plane-type-b777');
|
||
await expect(b777).toBeVisible();
|
||
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
|
||
});
|