Files
Flight-Ops/e2e/map.spec.ts
T
kamilkrzywda 0aca4d63aa
CI / build + unit + e2e (pull_request) Successful in 14m5s
v0.70.0: e2e wheel-clamp test asserts MapLibre height fit floor
2026-09-23 23:35:22 +00:00

315 lines
14 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 = 0; // v0.70.0: lowered 3 → 0 so the whole earth is visible when fully zoomed out
const MAX_ZOOM = 11;
// LFRS Nantes Atlantique — the marker-click target of this file (and e2e/map-interactions.spec.ts,
// e2e/relocation.spec.ts). The v0.70.0 default view ([0,0] z2) shows both Africa + Europe with
// overlapping markers (nearest neighbour LFRB Brest is only ~13–19 px away at z2), so every click
// test first frames LFRS at z5 — its nearest marker is then ~104 px away and the projected center
// cannot hit a neighbouring circle.
const LFRS: [number, number] = [-1.61073, 47.153198];
async function frameLfrs(page: Page): Promise<void> {
await page.evaluate((c) => (window as any).__flightMap.jumpTo({ center: c, zoom: 5 }), LFRS);
// Wait until LFRS's marker is actually rendered at the new camera before clicking it. The
// coordinate is passed AS AN ARGUMENT — evaluate callbacks run in the browser, where Node-scope
// values don't exist (same trap as e2e/relocation.spec.ts).
await expect.poll(async () =>
page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project(c);
return (map.queryRenderedFeatures([p.x, p.y], { layers: ['airports-circles'] }) as Array<{ properties?: Record<string, unknown> }>)
.some((f) => f.properties?.icao === 'LFRS');
}, LFRS),
{ timeout: 15_000 },
).toBe(true);
}
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 world airport count', async ({ page }) => {
await page.goto(APP_URL);
await expect(page.getByTestId('map')).toBeVisible();
const count = await waitForAirportCount(page);
expect(count).toBeGreaterThan(0);
// v0.70.0 default view: null island [0,0] at zoom 2 (Africa + Europe both visible). The map
// instance exists by the time the airport count renders — it is created before airports load.
const view = await page.evaluate(() => {
const m = (window as any).__flightMap;
return { lng: m.getCenter().lng, lat: m.getCenter().lat, z: m.getZoom() };
});
expect(view.z).toBeCloseTo(2);
expect(view.lng).toBeCloseTo(0);
expect(view.lat).toBeCloseTo(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}`);
// ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md.
await expect(page.getByTestId('app-version')).toHaveAttribute(
'href',
'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md',
);
});
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);
// Frame LFRS (Nantes Atlantique) so its marker is isolated again — see frameLfrs above for why
// the v0.70.0 default view needs this — then project it to screen space via the dev hook and click.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, LFRS);
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 to the effective minimum. MapLibre won't zoom out past the point where
// the world no longer covers the viewport height (fit floor = log2(h/512)), so with
// MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see
// the whole Earth", which is exactly what min zoom should give.
const mapBox = await page.getByTestId('map').boundingBox();
if (!mapBox) throw new Error('map bounding box unavailable');
const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512));
await jumpTo(minLimit + 1);
await wheelToLimit(600, (z) => z <= minLimit + 1e-9);
await waitForStableZoom();
const zoom = await getZoom();
expect(zoom).toBeCloseTo(minLimit);
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(/world airports/);
await expect.poll(renderedCircles, { timeout: 15_000 }).toBeGreaterThan(0);
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
// 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);
// Frame LFRS at z5 first — see frameLfrs above for why the v0.70.0 default view needs this.
await frameLfrs(page);
const pt = await page.evaluate((c) => {
const map = (window as any).__flightMap;
const p = map.project(c); // LFRS Nantes Atlantique — see frameLfrs above for why it's framed at z5
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, LFRS);
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');
});