609 lines
32 KiB
TypeScript
609 lines
32 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
|
||
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
|
||
import { loadAirportsGeoJson } from '../lib/airports';
|
||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
|
||
import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||
import { weatherFor } from '../lib/weather';
|
||
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||
|
||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||
// while flying, ISS-053), and
|
||
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
|
||
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
|
||
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
|
||
|
||
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 DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
|
||
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
|
||
|
||
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 StateBody {
|
||
account: { cash: number; reputation: number };
|
||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||
planes: PlaneState[];
|
||
packages: PackageState[];
|
||
}
|
||
|
||
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. ISS-041 also clears pause: a
|
||
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
|
||
async function setScale(page: Page, scale: number): Promise<void> {
|
||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||
expect(res.status()).toBe(200);
|
||
const body = (await res.json()) as { timeScale: number };
|
||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||
}
|
||
|
||
// Leave the live instance in a sane state for the other specs (scale 1, running).
|
||
test.afterEach(async ({ page }) => {
|
||
const res = await postSim(page, { timeScale: 1, paused: false });
|
||
expect(res.status()).toBe(200);
|
||
});
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||
const created = await page.request.post('/api/test/create-package', {
|
||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||
});
|
||
expect(created.status()).toBe(201);
|
||
return (await created.json()) as PackageState;
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||
const s = await getState(page);
|
||
const p = s.planes.find((x) => x.id === planeId)!;
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
|
||
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
|
||
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
|
||
async function setCash(page: Page, amount: number): Promise<void> {
|
||
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
|
||
}
|
||
|
||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||
let s = await getState(page);
|
||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||
await setScale(page, SCALE);
|
||
}
|
||
if (plane.status === 'flying') {
|
||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||
await expect
|
||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), {
|
||
timeout: 240_000,
|
||
message: 'waiting for an in-flight plane-starter to land',
|
||
})
|
||
.toBe('available');
|
||
s = await getState(page);
|
||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||
}
|
||
if (plane.locationIcao !== HUB) {
|
||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards.
|
||
await setScale(page, SCALE);
|
||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||
expect(res.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 ?? ''), {
|
||
timeout: 240_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
})
|
||
.toBe(HUB);
|
||
}
|
||
// 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();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function waitForAirportCount(page: Page): Promise<void> {
|
||
const countEl = page.getByTestId('airport-count');
|
||
await expect.poll(async () => {
|
||
const text = (await countEl.textContent()) ?? '';
|
||
const m = text.match(/(\d+)/);
|
||
return m ? parseInt(m[1], 10) : -1;
|
||
}).toBeGreaterThan(0);
|
||
}
|
||
|
||
// Rendered features of the given layers in the current viewport, [] until the map exists.
|
||
function renderedFeatures(
|
||
page: Page,
|
||
layers: string[],
|
||
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
|
||
return page.evaluate((ls: string[]) => {
|
||
const m = (window as any).__flightMap;
|
||
if (!m) return [];
|
||
try {
|
||
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
|
||
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
|
||
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
|
||
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
|
||
type?: unknown;
|
||
id?: unknown;
|
||
properties?: unknown;
|
||
geometry?: unknown;
|
||
}>).map((f) => ({
|
||
type: f.type,
|
||
id: f.id,
|
||
properties: f.properties,
|
||
geometry: f.geometry,
|
||
}));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, layers);
|
||
}
|
||
|
||
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
|
||
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
|
||
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
|
||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||
};
|
||
const points = icaos.map((icao) => {
|
||
const f = fc.features.find((x) => x.properties.icao === icao);
|
||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||
return [f.properties.lon, f.properties.lat] as [number, number];
|
||
});
|
||
await page.evaluate((pts: [number, number][]) => {
|
||
const m = (window as any).__flightMap;
|
||
if (!m) throw new Error('map not ready');
|
||
const lons = pts.map((p) => p[0]);
|
||
const lats = pts.map((p) => p[1]);
|
||
// duration 0 → instant camera jump, no animation to wait out.
|
||
m.fitBounds(
|
||
[
|
||
[Math.min(...lons), Math.min(...lats)],
|
||
[Math.max(...lons), Math.max(...lats)],
|
||
],
|
||
{ padding: 40, duration: 0 },
|
||
);
|
||
}, points);
|
||
// Give the renderer a beat after the camera jump before asserting on rendered features.
|
||
await page.waitForTimeout(300);
|
||
}
|
||
|
||
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
|
||
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
|
||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||
};
|
||
const f = fc.features.find((x) => x.properties.icao === icao);
|
||
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
|
||
return [f.properties.lon, f.properties.lat];
|
||
}
|
||
|
||
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
|
||
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
|
||
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
|
||
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
|
||
// any two EU airports.
|
||
const QUEUE_ROUTE_EPS_DEG = 0.05;
|
||
|
||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||
|
||
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||
|
||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
const pkg = await createPackage(page, HUB, DEST);
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
|
||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
|
||
await loadBtn.click();
|
||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
|
||
|
||
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
|
||
const destSelect = page.getByTestId('relocate-dest');
|
||
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||
|
||
// START leg 1 via the UI (fly-to button in the on-board block).
|
||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||
await expect(flyBtn).toBeEnabled();
|
||
await flyBtn.click();
|
||
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
|
||
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
|
||
await expect.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'flying' && p.destIcao === DEST;
|
||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
||
|
||
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||
expect(dupQueue.status()).toBe(409);
|
||
|
||
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
||
// posts to /queue and the leg starts when it lands.
|
||
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||
await destSelect.selectOption(QUEUE);
|
||
await page.getByTestId('relocate-go').click();
|
||
|
||
// …and the new leg appears as queue-leg-0 with its destination.
|
||
const legRow = page.getByTestId('queue-leg-plane-starter-0');
|
||
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
|
||
|
||
// CANCEL it via the UI and assert it's gone.
|
||
await page.getByTestId('queue-cancel-plane-starter-0').click();
|
||
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||
|
||
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||
|
||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||
await setScale(page, SCALE);
|
||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||
expect(home.status()).toBe(200);
|
||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||
timeout: 120_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
}).toBe(HUB);
|
||
});
|
||
|
||
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||
await flyHomeViaApi(page);
|
||
|
||
// Full tank before departure — keeps the state canonical for whatever the cleanup does (ISS-082 hook).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
|
||
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
|
||
const pkg = await createPackage(page, HUB, DEST);
|
||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||
expect(load.status()).toBe(200);
|
||
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||
expect(queueRes.status()).toBe(200);
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(flyRes.status()).toBe(200);
|
||
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||
const flyBody = (await flyRes.json()) as StateBody;
|
||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||
|
||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
|
||
await postSim(page, { paused: true });
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||
await fitToAirports(page, [DEST, QUEUE]);
|
||
|
||
const destLL = await airportLonLat(page, DEST);
|
||
const queueLL = await airportLonLat(page, QUEUE);
|
||
const hubLL = await airportLonLat(page, HUB);
|
||
const near = (a: [number, number], b: [number, number]) =>
|
||
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
|
||
|
||
await expect.poll(async () => {
|
||
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
|
||
properties?: Record<string, unknown>;
|
||
geometry?: { type: string; coordinates: [number, number][] };
|
||
}>;
|
||
// Filter to real LineString segments — keeps this poll callback total (a throw inside
|
||
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
|
||
const segs = feats.filter(
|
||
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||
);
|
||
if (!segs.length) return false;
|
||
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
|
||
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
|
||
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
|
||
// …and some ENDS within EPS of the queued airport.
|
||
const endsAtQueue = segs.some((f) => {
|
||
const c = f.geometry.coordinates;
|
||
return near(c[c.length - 1], queueLL);
|
||
});
|
||
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
|
||
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||
// Every rendered feature belongs to plane-starter (the only queued plane).
|
||
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
|
||
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
|
||
|
||
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
|
||
// to arrival, then fly home.
|
||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||
expect(cancelRes.status()).toBe(200);
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||
|
||
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
|
||
await setScale(page, SCALE);
|
||
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||
expect(home.status()).toBe(200);
|
||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
|
||
timeout: 120_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
}).toBe(HUB);
|
||
});
|
||
|
||
test('grounded plane with a pre-built queue draws its dotted route from its airport (ISS-077)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + map assertions only — no flight in this test
|
||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// Pre-build a two-leg route while GROUNDED via the raw API (queuing is allowed from any state,
|
||
// ISS-043 part 1) — no package or flight needed. The dotted line must chain HUB → DEST → QUEUE.
|
||
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||
expect(q1.status()).toBe(200);
|
||
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||
expect(q2.status()).toBe(200);
|
||
|
||
// All chain endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||
await fitToAirports(page, [HUB, DEST, QUEUE]);
|
||
|
||
const hubLL = await airportLonLat(page, HUB);
|
||
const destLL = await airportLonLat(page, DEST);
|
||
const queueLL = await airportLonLat(page, QUEUE);
|
||
const near = (a: [number, number], b: [number, number]) =>
|
||
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
|
||
|
||
await expect.poll(async () => {
|
||
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
|
||
properties?: Record<string, unknown>;
|
||
geometry?: { type: string; coordinates: [number, number][] };
|
||
}>;
|
||
// Filter to real LineString segments — keeps this poll callback total. "None yet" → keep polling.
|
||
const segs = feats.filter(
|
||
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
|
||
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
|
||
);
|
||
if (!segs.length) return false;
|
||
// The chain HUB → DEST → QUEUE may be clipped into per-tile segments — check across all of them:
|
||
// some segment STARTS within EPS of the hub (the plane's airport, NOT a flight destination)…
|
||
const startsAtHub = segs.some((f) => near(f.geometry.coordinates[0], hubLL));
|
||
// …some ENDS within EPS of the last queued airport…
|
||
const endsAtQueue = segs.some((f) => {
|
||
const c = f.geometry.coordinates;
|
||
return near(c[c.length - 1], queueLL);
|
||
});
|
||
// …and the chain passes THROUGH the first queued airport — an interior joint point survives in
|
||
// whichever tile contains it (not guaranteed to be a segment endpoint after clipping).
|
||
const passesThroughDest = segs.some((f) => f.geometry.coordinates.some((c) => near(c, destLL)));
|
||
// Every rendered feature belongs to plane-starter.
|
||
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
|
||
return startsAtHub && endsAtQueue && passesThroughDest && ownedByStarter;
|
||
}, { timeout: 15_000, message: `waiting for the dotted queue line ${HUB} → ${DEST} → ${QUEUE}` }).toBe(true);
|
||
|
||
// Cleanup: cancel both queued legs (index 0 twice — each cancel shifts the rest down) so the
|
||
// starter stays grounded at HUB with an empty queue. No flight happened, nothing else to reset.
|
||
const c1 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||
expect(c1.status()).toBe(200);
|
||
const c2 = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||
expect(c2.status()).toBe(200);
|
||
});
|
||
|
||
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||
await flyHomeViaApi(page);
|
||
|
||
// Full tank for a deterministic baseline — the engine auto-chains (and auto-refuels, ISS-082) the return leg regardless.
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
|
||
const pkg = await createPackage(page, HUB, DEST);
|
||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||
expect(load.status()).toBe(200);
|
||
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
|
||
expect(queueRes.status()).toBe(200);
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(flyRes.status()).toBe(200);
|
||
|
||
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
|
||
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
|
||
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'flying' && p.destIcao === HUB;
|
||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
|
||
|
||
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
|
||
const s = await getState(page);
|
||
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
|
||
|
||
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
|
||
await expect.poll(async () => {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||
});
|
||
|
||
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
|
||
|
||
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
|
||
await flyHomeViaApi(page);
|
||
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
|
||
for (const icao of [DEST, QUEUE, HUB]) {
|
||
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
|
||
}
|
||
|
||
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(flyRes.status()).toBe(200);
|
||
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
|
||
expect(p0.status).toBe('flying');
|
||
expect(p0.destIcao).toBe(DEST);
|
||
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
|
||
|
||
// Landing at DEST auto-chains EHAM (leg 2)…
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||
|
||
// …and landing at EHAM chains the return to HUB (leg 3).
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'flying' && x.destIcao === HUB;
|
||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
|
||
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'available' && x.locationIcao === HUB;
|
||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||
|
||
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
|
||
});
|
||
|
||
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + two legs at scale 1000
|
||
await flyHomeViaApi(page);
|
||
|
||
const s0 = await getState(page);
|
||
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
|
||
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
|
||
|
||
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
|
||
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
|
||
await setFuelFull(page, 'plane-starter');
|
||
const STAGED_CASH = 20_000;
|
||
await setCash(page, STAGED_CASH);
|
||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||
expect(q.status()).toBe(200);
|
||
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(flyRes.status()).toBe(200);
|
||
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
|
||
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
|
||
|
||
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
|
||
await setFuel(page, 'plane-starter', 0);
|
||
|
||
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
|
||
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
|
||
const fc = await loadAirportsGeoJson();
|
||
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
|
||
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
|
||
|
||
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
|
||
const wHub = weatherFor(HUB);
|
||
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
|
||
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
|
||
expect(landedFuel).toBeCloseTo(0, 6);
|
||
|
||
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
|
||
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
|
||
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
|
||
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
|
||
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
|
||
|
||
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
|
||
const liters = Math.max(0, target - landedFuel);
|
||
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||
|
||
// Landing at DEST auto-chains QUEUE with the top-up…
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||
|
||
const s = await getState(page);
|
||
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
|
||
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
||
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
|
||
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
|
||
|
||
// Let leg 2 finish so the next test starts from a known airport.
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
|
||
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
|
||
});
|
||
|
||
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal + one leg at scale 1000
|
||
await flyHomeViaApi(page);
|
||
|
||
await setFuelFull(page, 'plane-starter');
|
||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||
expect(q.status()).toBe(200);
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(flyRes.status()).toBe(200);
|
||
|
||
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
|
||
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||
expect(cancel.status()).toBe(200);
|
||
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => {
|
||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||
expect(p.destIcao).toBeNull(); // no chained leg started…
|
||
expect(p.queue).toEqual([]); // …and the queue is empty
|
||
});
|