feat: v0.11.0 — map interactions per PLAN.md: expanded airport popup (approx local time + deterministic generated weather), animated plane markers from /api/state polls with status popups, active contract route lines (pending dashed vs in-flight solid); pure libs lib/weather.ts, lib/localTime.ts, lib/mapData.ts
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user