Files
Flight-Ops/e2e/map.spec.ts
T

269 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 LFRS (Nantes Atlantique) to screen space via the dev hook, then click it.
// Nantes is used because the v0.9.0 three-pane layout narrows the map column to ~660 px at
// the default 1280×720 viewport, which puts UKBB Boryspil (~475 px east of the [10,52] center)
// outside the initial view; LFRS is now the most isolated large_airport marker inside it — its
// nearest neighbour is LFRB Nantes–Montoir ~78 px away at the initial zoom, 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([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
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('Nantes Atlantique Airport');
await expect(popup).toContainText('LFRS');
});
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). MapLibre's diff-based setStyle removes our
// custom source/layer before ensureAirportsLayer re-adds it on the next styledata event, so
// retry the click until the popup opens instead of asserting on a single shot (the marker is
// clickable again within milliseconds; toHaveCount(1) below still catches duplicate listeners).
await expect.poll(async () => {
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
});
await page.mouse.click(pt.x, pt.y);
return (await page.getByTestId('airport-popup').count()) > 0;
}, { timeout: 15_000 }).toBe(true);
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');
});
test('falls back to remote OFM styles when the local asset pack is absent', async ({ page }) => {
// Simulates a fresh clone without the generated asset pack (no volume mount):
// the manifest probe gets a 404, so the app must load its style from
// openfreemap.org. Only style fetches are allowed through; all other OFM traffic
// (tiles/fonts/sprites) is aborted for speed — the GeoJSON airport layer needs none of it.
// Routes match in reverse declaration order: manifest 404 > styles continue > abort-all.
await page.route('**://*.openfreemap.org/**', (route) => route.abort());
await page.route('**://*.openfreemap.org/styles/**', (route) => route.continue());
await page.route('**/map-assets-manifest.json', (route) => route.fulfill({ status: 404 }));
await page.goto(APP_URL);
// The map must have been created from the REMOTE style — its glyphs still point at OFM.
const glyphs = () => page.evaluate(() => (window as any).__flightMap?.getStyle()?.glyphs ?? '');
await expect.poll(glyphs, { timeout: 15_000 }).toContain('tiles.openfreemap.org');
// The app is fully usable without the pack: airport count renders…
const count = await waitForAirportCount(page);
expect(count).toBeGreaterThan(0);
// …and markers are rendered and clickable.
await expect.poll(async () =>
page.evaluate(
() =>
(window as any).__flightMap?.queryRenderedFeatures(undefined, {
layers: ['airports-circles'],
})?.length ?? 0,
),
{ timeout: 15_000 },
).toBeGreaterThan(0);
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique — see the marker-click test above for why
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('Nantes Atlantique Airport');
await expect(popup).toContainText('LFRS');
});