217 lines
8.9 KiB
TypeScript
217 lines
8.9 KiB
TypeScript
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<number> {
|
|
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: it keeps
|
|
// the headless main thread idle and makes the test fast and independent of
|
|
// tile-server latency. Styles/fonts/sprites are self-hosted now — anything from
|
|
// openfreemap.org is a tile request, so abort all of them.
|
|
await page.route('**://*.openfreemap.org/**', (route) => 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);
|
|
});
|
|
|
|
test('style switcher defaults to Fiord, switches styles, and keeps the airports layer', async ({ page }) => {
|
|
// Styles/fonts/sprites are self-hosted; only tiles come from openfreemap — abort
|
|
// them for speed (the GeoJSON airport layer renders without basemap tiles).
|
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
|
await page.goto(APP_URL);
|
|
|
|
const select = page.getByTestId('style-switch');
|
|
await expect(select).toBeVisible();
|
|
await expect(select.locator('option')).toHaveCount(5);
|
|
// Fiord is the default style.
|
|
await expect(select).toHaveValue('fiord');
|
|
|
|
await waitForAirportCount(page);
|
|
|
|
const renderedCircles = () =>
|
|
page.evaluate(
|
|
() =>
|
|
(window as any).__flightMap?.queryRenderedFeatures(undefined, {
|
|
layers: ['airports-circles'],
|
|
})?.length ?? 0,
|
|
);
|
|
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
|
|
|
const layerIds = () =>
|
|
page.evaluate(() => (window as any).__flightMap?.getStyle()?.layers?.map((l: { id: string }) => l.id) ?? []);
|
|
const before = await layerIds();
|
|
expect(before.length).toBeGreaterThan(0);
|
|
|
|
// Switch to Dark.
|
|
await select.selectOption('dark');
|
|
await expect(select).toHaveValue('dark');
|
|
|
|
// The applied style must actually change (each OFM style has a different layer set).
|
|
await expect.poll(layerIds, { timeout: 15_000 }).not.toEqual(before);
|
|
|
|
// Regression guard for the setStyle-wipes-layers bug: map.setStyle() removes every
|
|
// source/layer not in the new style, so the airports layer must be re-established —
|
|
// count still shown and markers rendered again.
|
|
await expect(page.getByTestId('airport-count')).toContainText(/EU airports/);
|
|
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
|
|
|
|
// Clicking a marker after the switch opens exactly one popup (the click handler is
|
|
// re-wired idempotently — no duplicate listeners).
|
|
const pt = await page.evaluate(() => {
|
|
const map = (window as any).__flightMap;
|
|
const p = map.project([30.894699, 50.345001]); // UKBB Boryspil
|
|
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 popups = page.getByTestId('airport-popup');
|
|
await expect(popups.first()).toBeVisible();
|
|
await expect(popups).toHaveCount(1);
|
|
});
|
|
|
|
test('style choice persists across reloads via localStorage', async ({ page }) => {
|
|
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
|
|
await page.goto(APP_URL);
|
|
|
|
const select = page.getByTestId('style-switch');
|
|
await expect(select).toHaveValue('fiord');
|
|
|
|
await select.selectOption('positron');
|
|
const stored = await page.evaluate(() => localStorage.getItem('flight-ops-style'));
|
|
expect(stored).toBe('positron');
|
|
|
|
// Reload: the persisted choice is restored after mount (SSR-safe path — server and
|
|
// first paint use the default, then the effect applies the saved style).
|
|
await page.reload();
|
|
await expect(select).toHaveValue('positron');
|
|
});
|