320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
import { authenticate } from './auth';
|
||
|
||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||
|
||
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');
|
||
});
|