303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
// v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
|
||
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
|
||
// open-package rows highlight both endpoint airports. The map exposes three top-most
|
||
// highlight layers (planes-highlight / routes-highlight / airports-highlight), each filtered
|
||
// to match nothing until a hover sets the filter — asserted here via window.__flightMap's
|
||
// queryRenderedFeatures, same pattern as e2e/map.spec.ts and e2e/map-interactions.spec.ts.
|
||
|
||
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
|
||
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
|
||
const DEST = 'LEMD';
|
||
|
||
interface PlaneState {
|
||
id: string;
|
||
model: string;
|
||
status: 'available' | 'flying';
|
||
fuel: number;
|
||
maxFuel: number;
|
||
locationIcao: string;
|
||
destIcao: string | null;
|
||
}
|
||
|
||
interface PackageState {
|
||
id: string;
|
||
originName: string;
|
||
destName: string;
|
||
reward: number;
|
||
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 — 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/sim', { 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/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;
|
||
}
|
||
|
||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||
const s0 = await getState(page);
|
||
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) {
|
||
const res = await page.request.post('/api/planes/plane-starter/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 === HUB, {
|
||
timeout: 240_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
})
|
||
.toBe(true);
|
||
}
|
||
}
|
||
|
||
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[]) {
|
||
return page.evaluate((ls: string[]) => {
|
||
const m = (window as any).__flightMap;
|
||
if (!m) return [];
|
||
try {
|
||
return m.queryRenderedFeatures(undefined, { layers: ls });
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, layers);
|
||
}
|
||
|
||
// Move the pointer to a neutral element with no hover handler — leaves any hovered row/block.
|
||
async function unhover(page: Page): Promise<void> {
|
||
await page.getByTestId('wallet-cash').hover();
|
||
}
|
||
|
||
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
|
||
// features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves
|
||
// far endpoints (LEMD) off-screen even when they match a layer filter.
|
||
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);
|
||
}
|
||
|
||
test('hovering a fleet row highlights the plane on the map', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF, inside the initial view
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// Fleet rows are <li data-testid={`plane-${p.id}`}> and the server id already carries the "plane-"
|
||
// prefix (run #34: the real testid is plane-plane-starter) — derive it from /api/state, never hardcode.
|
||
const planes = (await getState(page)).planes;
|
||
const starterId = planes.find((p) => p.id.endsWith('starter'))?.id ?? (planes.length === 1 ? planes[0].id : undefined);
|
||
expect(starterId).toBeDefined();
|
||
const row = page.locator(`li[data-testid="plane-${starterId}"]`);
|
||
// The browser's /api/state polls can lag node-side requests right after a fresh page load
|
||
// (useGameState keeps loading=true until the first success → no fleet rows yet), so poll with
|
||
// diagnostics: on timeout the message compares what the NODE side sees vs what the BROWSER
|
||
// last polled, plus the fleet section's HTML — distinguishing server divergence from a render
|
||
// bug. (The value is returned, not thrown — expect.poll fails immediately on a callback throw
|
||
// instead of retrying.)
|
||
await expect
|
||
.poll(async () => {
|
||
if ((await row.count()) > 0 && (await row.isVisible())) return 'visible';
|
||
const body = await getState(page);
|
||
const browserSnap = await page.evaluate(() => (window as any).__flightLastSnapshot ?? null);
|
||
const fleetHtml = await page.evaluate(() => {
|
||
const el = document.querySelector('[data-testid="fleet-list"]');
|
||
if (el) return `fleet-list HTML: ${(el.outerHTML ?? '').slice(0, 300)}`;
|
||
const pane = document.querySelector<HTMLElement>('[data-testid="left-pane"]');
|
||
return `no fleet-list — left-pane text: ${(pane?.innerText ?? '').slice(0, 200)}`;
|
||
});
|
||
return (
|
||
`not visible — node /api/state planes=[${body.planes.map((p) => p.id).join(', ')}] cash=${body.account.cash}; ` +
|
||
`browser __flightLastSnapshot=${JSON.stringify(browserSnap)}; ${fleetHtml}`
|
||
);
|
||
}, { timeout: 30_000 })
|
||
.toBe('visible');
|
||
|
||
// No highlight before hovering (the layers start filtered to match nothing).
|
||
expect(await renderedFeatures(page, ['planes-highlight'])).toHaveLength(0);
|
||
|
||
await row.hover();
|
||
await expect.poll(async () => {
|
||
const feats = (await renderedFeatures(page, ['planes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||
return feats.length > 0 && feats[0].properties.id === starterId;
|
||
}, { timeout: 15_000, message: `waiting for the planes-highlight layer to show ${starterId}` }).toBe(true);
|
||
|
||
await unhover(page);
|
||
await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, {
|
||
timeout: 15_000,
|
||
message: 'waiting for the highlight to clear after mouseleave',
|
||
}).toBe(0);
|
||
});
|
||
|
||
test('hovering an open package highlights its endpoint airports', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page);
|
||
|
||
const pkg = await createPackage(page, HUB, DEST);
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
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 });
|
||
|
||
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen
|
||
// at the default Frankfurt-centered view).
|
||
await fitToAirports(page, [HUB, DEST]);
|
||
|
||
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
|
||
|
||
await row.hover();
|
||
await expect.poll(async () => {
|
||
const feats = (await renderedFeatures(page, ['airports-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
|
||
}, { timeout: 15_000, message: 'waiting for the airports-highlight layer to show both endpoints' })
|
||
.toBe(JSON.stringify([HUB, DEST].sort()));
|
||
|
||
await unhover(page);
|
||
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
|
||
timeout: 15_000,
|
||
message: 'waiting for the highlight to clear after mouseleave',
|
||
}).toBe(0);
|
||
});
|
||
|
||
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
|
||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||
await flyHomeViaApi(page);
|
||
|
||
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
|
||
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
|
||
expect(refuel.status()).toBe(200);
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
|
||
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 fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(fly.status()).toBe(200);
|
||
await setScale(page, SCALE);
|
||
|
||
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
|
||
const block = page.getByTestId('onboard-plane-starter');
|
||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||
|
||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused.
|
||
await postSim(page, { paused: true });
|
||
|
||
await block.hover();
|
||
await expect.poll(async () => {
|
||
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
|
||
return feats.length > 0 && feats[0].properties.planeId === 'plane-starter';
|
||
}, { timeout: 15_000, message: 'waiting for the routes-highlight layer to show the plane-starter leg' }).toBe(true);
|
||
|
||
await unhover(page);
|
||
await expect.poll(async () => (await renderedFeatures(page, ['routes-highlight'])).length, {
|
||
timeout: 15_000,
|
||
message: 'waiting for the highlight to clear after mouseleave',
|
||
}).toBe(0);
|
||
|
||
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
|
||
// onboarding state (starter grounded at EDDF) for the other specs.
|
||
await postSim(page, { timeScale: SCALE, paused: false });
|
||
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);
|
||
|
||
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 === HUB, {
|
||
timeout: 120_000,
|
||
message: 'waiting for plane-starter to land back at the hub',
|
||
})
|
||
.toBe(true);
|
||
});
|