267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
|
|
// v0.11.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
|
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
|
// deterministic generated weather), plane markers whose positions come from the
|
|
// /api/state polls, active contract route lines (pending dashed vs in-flight
|
|
// solid), and the plane status popup. Contracts are created on a known EDDF→EDDV
|
|
// route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1).
|
|
|
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
|
// Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable
|
|
// window for the click assertions below (the nearest airport would land too fast).
|
|
const DEST = 'EDDV';
|
|
const SCALE = 60;
|
|
|
|
interface PlaneState {
|
|
id: string;
|
|
model: string;
|
|
status: 'available' | 'flying';
|
|
fuel: number;
|
|
maxFuel: number;
|
|
locationIcao: string;
|
|
contractId: string | null;
|
|
lat: number;
|
|
lon: number;
|
|
}
|
|
|
|
interface ContractState {
|
|
id: string;
|
|
originIcao: string;
|
|
originName: string;
|
|
destIcao: string;
|
|
destName: string;
|
|
reward: number;
|
|
status: 'open' | 'accepted' | 'completed' | 'expired';
|
|
planeId: string | null;
|
|
}
|
|
|
|
interface StateBody {
|
|
account: { cash: number; reputation: number };
|
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
|
planes: PlaneState[];
|
|
contracts: ContractState[];
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// 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)));
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// 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 contract on a known route.
|
|
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
|
const created = await page.request.post('/api/test/create-contract', {
|
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
|
});
|
|
expect(created.status()).toBe(201);
|
|
return (await created.json()) as ContractState;
|
|
}
|
|
|
|
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
|
// scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
|
async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
|
|
const contract = await createContract(page, fromIcao, toIcao);
|
|
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
|
expect(acc.status()).toBe(200);
|
|
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
|
expect(disp.status()).toBe(200);
|
|
await postSim(page, { timeScale: SCALE });
|
|
await expect
|
|
.poll(async () => {
|
|
const body = await getState(page);
|
|
const p = body.planes.find((x) => x.id === 'plane-starter');
|
|
return !!p && p.status === 'available' && p.locationIcao === toIcao;
|
|
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
|
.toBe(true);
|
|
return contract;
|
|
}
|
|
|
|
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
|
function sourceFeatures(page: Page, sourceId: string) {
|
|
return page.evaluate((srcId: string) => {
|
|
const m = (window as any).__flightMap;
|
|
if (!m || !m.getSource(srcId)) return [];
|
|
try {
|
|
return m.querySourceFeatures(srcId);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, sourceId);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
test('airport popup shows name, ICAO, local time and generated weather', async ({ page }) => {
|
|
await page.goto('/');
|
|
await waitForAirportCount(page);
|
|
|
|
// Wait until the map instance exists and airport circles are actually rendered.
|
|
await expect.poll(async () =>
|
|
page.evaluate(
|
|
() => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'] })?.length ?? 0,
|
|
),
|
|
{ timeout: 15_000 },
|
|
).toBeGreaterThan(0);
|
|
|
|
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts.
|
|
const pt = await page.evaluate(() => {
|
|
const map = (window as any).__flightMap;
|
|
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
|
|
const rect = map.getContainer().getBoundingClientRect();
|
|
return { x: rect.left + p.x, y: rect.top + p.y };
|
|
});
|
|
await page.mouse.click(pt.x, pt.y);
|
|
|
|
const popup = page.getByTestId('airport-popup');
|
|
await expect(popup).toBeVisible();
|
|
// Pre-v0.11.0 fields still present (regression guard for the existing map specs).
|
|
await expect(popup).toContainText('Nantes Atlantique Airport');
|
|
await expect(popup).toContainText('LFRS');
|
|
|
|
// New: approximate local time (HH:MM) and generated weather (condition + wind + visibility).
|
|
const localTime = page.getByTestId('airport-local-time');
|
|
await expect(localTime).toBeVisible();
|
|
expect((await localTime.textContent()) ?? '').toMatch(/\d{2}:\d{2}/);
|
|
|
|
const weather = page.getByTestId('airport-weather');
|
|
await expect(weather).toBeVisible();
|
|
const wText = (await weather.textContent()) ?? '';
|
|
expect(wText).toMatch(/(Clear|Cloudy|Rain|Fog)/);
|
|
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
|
|
});
|
|
|
|
test('plane markers move while flying, the active route line is highlighted, and the plane popup shows status + route', async ({ page }) => {
|
|
test.setTimeout(300_000); // generous cap — two short flights at scale 60 plus map waits
|
|
|
|
await page.goto('/');
|
|
await waitForAirportCount(page);
|
|
|
|
// Self-heal first: a previous interrupted run may have left the starter plane
|
|
// mid-flight or at another airport. Let any flight finish, then fly it home so
|
|
// the test starts from the canonical onboarding state (plane grounded at EDDF).
|
|
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
|
expect(foundStarter).toBeDefined();
|
|
if (foundStarter!.status === 'flying') {
|
|
await postSim(page, { timeScale: SCALE });
|
|
await expect
|
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
|
timeout: 120_000,
|
|
message: 'waiting for an in-flight plane-starter to land',
|
|
})
|
|
.toBe(true);
|
|
}
|
|
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
|
if (starterLoc !== HUB) {
|
|
await flyContract(page, starterLoc, HUB);
|
|
}
|
|
|
|
// 1) Accept the hook-created contract — before dispatch it must draw a PENDING route line.
|
|
const contract = await createContract(page, HUB, DEST);
|
|
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
|
expect(acc.status()).toBe(200);
|
|
|
|
// The map updates from the shell's /api/state polls — poll until the pending line lands.
|
|
await expect
|
|
.poll(async () => {
|
|
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
|
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending');
|
|
}, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' })
|
|
.toBe(true);
|
|
|
|
// 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature.
|
|
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
|
expect(disp.status()).toBe(200);
|
|
await postSim(page, { timeScale: SCALE });
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
|
|
return feats.some((f) => f.properties.id === 'plane-starter' && f.properties.flying === true);
|
|
}, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' })
|
|
.toBe(true);
|
|
|
|
// 3) Position CHANGES across two /api/state polls while flying (server computes
|
|
// lat/lon analytically each tick; the map just renders the polled values).
|
|
const p1 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
|
expect(p1.status).toBe('flying');
|
|
await sleep(1500);
|
|
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
|
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
|
|
|
|
// 4) The route line for this contract is now IN-FLIGHT (solid layer), not pending.
|
|
await expect
|
|
.poll(async () => {
|
|
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
|
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight');
|
|
}, { timeout: 15_000, message: 'waiting for the in-flight route line' })
|
|
.toBe(true);
|
|
|
|
// 5) Click the flying plane → status popup with model, status, fuel and the route.
|
|
// Fetch + project in one evaluate so the click lands on a fresh position (the
|
|
// marker drifts only ~1 px/s of screen space at scale 60).
|
|
const pt = await page.evaluate(async () => {
|
|
const m = (window as any).__flightMap;
|
|
if (!m) return null;
|
|
const res = await fetch('/api/state', { cache: 'no-store' });
|
|
const body = (await res.json()) as StateBody;
|
|
const p = body.planes.find((x) => x.id === 'plane-starter');
|
|
if (!p || p.status !== 'flying') return null;
|
|
const projected = m.project([p.lon, p.lat]);
|
|
const rect = m.getContainer().getBoundingClientRect();
|
|
return { x: rect.left + projected.x, y: rect.top + projected.y };
|
|
});
|
|
expect(pt).not.toBeNull(); // the flight (~33 s at scale 60) is still in progress here
|
|
|
|
await page.mouse.click(pt!.x, pt!.y);
|
|
const popup = page.getByTestId('plane-popup');
|
|
await expect(popup).toBeVisible({ timeout: 5_000 });
|
|
await expect(popup).toContainText('ATR 72-600'); // catalog label via the snapshot
|
|
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
|
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
|
|
|
|
const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!;
|
|
await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`);
|
|
|
|
// 6) Let the flight finish, then fly home so the live instance stays in its
|
|
// onboarding state (starter grounded at EDDF) for the other specs.
|
|
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);
|
|
|
|
await flyContract(page, DEST, HUB);
|
|
});
|