import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { test, expect, type Page } from '@playwright/test'; const APP_URL = '/'; // Read at test time so the assertion tracks package.json automatically. const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string }; // Must match MIN_ZOOM / MAX_ZOOM exported from components/Map.tsx. const MIN_ZOOM = 3; const MAX_ZOOM = 11; async function waitForAirportCount(page: Page): Promise { 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); const text = (await countEl.textContent()) ?? ''; return parseInt(text.match(/(\d+)/)?.[1] ?? '0', 10); } test('page loads with the map and EU airport count', async ({ page }) => { await page.goto(APP_URL); await expect(page.getByTestId('map')).toBeVisible(); const count = await waitForAirportCount(page); expect(count).toBeGreaterThan(0); }); test('header shows the app version sourced from package.json', async ({ page }) => { await page.goto(APP_URL); const header = page.getByTestId('app-header'); await expect(header).toContainText(`v${pkg.version}`); }); test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => { await page.goto(APP_URL); await waitForAirportCount(page); // Wait until the map instance exists and airport circles are actually rendered // (the GeoJSON source is parsed in a worker, so features arrive after addSource). await expect.poll(async () => page.evaluate( () => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'], })?.length ?? 0, ), { timeout: 15_000 }, ).toBeGreaterThan(0); // Project UKBB (Boryspil) to screen space via the dev hook, then click it. // Boryspil is used because with only large_airport rows remaining it is the // most isolated marker inside the initial zoom-4 viewport: its nearest // surviving airport is Chişinău (LUKK), ~126 px away at the initial zoom // (Kyiv Zhuliany, geographically closer, was a medium_airport and got cut), // so its marker cannot be overlapped by a neighbouring circle. const pt = await page.evaluate(() => { const map = (window as any).__flightMap; const p = map.project([30.894699, 50.345001]); 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(); await expect(popup).toContainText('Boryspil International Airport'); await expect(popup).toContainText('UKBB'); }); test('wheel zoom is clamped between min and max zoom', async ({ page }) => { // Zoom clamping is pure camera behavior, so block basemap tile fetches (the // style JSON still loads): it keeps the headless main thread idle and makes the // test fast and independent of tile-server latency. await page.route('**://*.openfreemap.org/**', (route) => { if (route.request().url().includes('/styles/')) return route.continue(); return route.abort(); }); await page.goto(APP_URL); const getZoom = () => page.evaluate(() => (window as any).__flightMap?.getZoom() ?? -1); // Wait until the map instance exists and its style is fully applied, so the // camera state is stable before we start wheeling. await expect.poll( () => page.evaluate(() => { const m = (window as any).__flightMap; return m && m.isStyleLoaded() ? m.getZoom() : -1; }), { timeout: 15_000 }, ).toBeGreaterThan(0); // Wheel events must land on the canvas. const box = (await page.getByTestId('map').boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); // Position the camera close to a limit so the wheeling under test has short // travel — headless tile rendering is slow across many zoom levels. const jumpTo = async (zoom: number) => { await page.evaluate((z) => (window as any).__flightMap.jumpTo({ zoom: z }), zoom); await expect.poll(getZoom, { timeout: 5_000 }).toBeCloseTo(zoom); }; // MapLibre consumes all accumulated wheel delta in a single render frame, capped // at one zoom level per frame — so keep feeding large deltas while polling until // the camera reaches (or passes) the limit; frames consume them as they run. const wheelToLimit = async (deltaY: number, atLimit: (z: number) => boolean) => { await expect.poll(async () => { if ((await getZoom()) === -1) return 'pending'; if (atLimit(await getZoom())) return 'done'; for (let i = 0; i < 10; i++) await page.mouse.wheel(0, deltaY); return 'pending'; }, { timeout: 20_000 }).toBe('done'); }; // Wait until the camera stops moving so we assert on the settled value, not a // mid-animation one (each wheel eases for ~200ms). const waitForStableZoom = async () => { let prev = await getZoom(); let stable = 0; await expect.poll(async () => { const z = await getZoom(); stable = Math.abs(z - prev) < 1e-9 ? stable + 1 : 0; prev = z; return stable >= 3 ? 'stable' : 'pending'; }, { timeout: 5_000 }).toBe('stable'); }; // Zoom in well past MAX_ZOOM with several large deltas. await jumpTo(MAX_ZOOM - 2); await wheelToLimit(-600, (z) => z >= MAX_ZOOM - 1e-9); await waitForStableZoom(); expect(await getZoom()).toBeCloseTo(MAX_ZOOM); // Zoom out well below MIN_ZOOM with several large deltas. await jumpTo(MIN_ZOOM + 1); await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9); await waitForStableZoom(); const zoom = await getZoom(); expect(zoom).toBeCloseTo(MIN_ZOOM); expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM); expect(zoom).toBeLessThanOrEqual(MAX_ZOOM); });