leaflet_svelte/tests/e2e/map-controls.spec.ts
2026-08-03 22:12:32 +09:00

291 lines
10 KiB
TypeScript

import { test, expect, login } from './fixtures';
/**
* Coordinate bounds, graticule and basemap switching.
*
* Bounds are now geographic reality rather than a workaround: the predictor
* integrates along great circles and handles latitude 90 exactly, so the old
* 89.999 mitigation is gone. Latitude clamps because a pole is a real barrier;
* longitude wraps because a meridian is not.
*/
const MAX_LAUNCH_LAT = 90;
test.beforeEach(async ({ context }) => {
await login(context);
});
/** The active workspace's persisted launch latitude. */
function storedLaunchLat(page: import('@playwright/test').Page): Promise<number | null> {
return page.evaluate(() => {
const raw = localStorage.getItem('workspaces');
if (!raw) return null;
const slice = JSON.parse(raw) as {
items: { flightParameters?: { launch_latitude?: number } }[];
};
return slice.items[0]?.flightParameters?.launch_latitude ?? null;
});
}
/** The active workspace's persisted launch longitude. */
function storedLaunchLng(page: import('@playwright/test').Page): Promise<number | null> {
return page.evaluate(() => {
const raw = localStorage.getItem('workspaces');
if (!raw) return null;
const slice = JSON.parse(raw) as {
items: { flightParameters?: { launch_longitude?: number } }[];
};
return slice.items[0]?.flightParameters?.launch_longitude ?? null;
});
}
async function openConditions(page: import('@playwright/test').Page) {
await page.goto('/');
await page.evaluate(() => localStorage.removeItem('workspaces'));
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
await page.getByRole('button', { name: /Условия|Conditions/ }).click();
}
test('latitude is clamped to the real poles, not to a workaround value', async ({ page }) => {
test.setTimeout(120_000);
await openConditions(page);
const latInput = page.locator('.panel-container-left input[type="number"]').first();
await latInput.fill('91');
await latInput.dispatchEvent('input');
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(MAX_LAUNCH_LAT);
await latInput.fill('-91');
await latInput.dispatchEvent('input');
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(-MAX_LAUNCH_LAT);
});
test('latitudes the old mitigation rejected are now accepted verbatim', async ({ page }) => {
test.setTimeout(120_000);
await openConditions(page);
const latInput = page.locator('.panel-container-left input[type="number"]').first();
for (const v of [89.9999, 90, -90]) {
await latInput.fill(String(v));
await latInput.dispatchEvent('input');
await expect.poll(() => storedLaunchLat(page), { timeout: 10_000 }).toBe(v);
}
});
test('longitude wraps rather than clamping', async ({ page }) => {
test.setTimeout(120_000);
await openConditions(page);
// A meridian is not a barrier: 200 E is the same place as -160, so wrapping
// keeps the launch site the user meant. Clamping to 180 would move it.
const lngInput = page.locator('.panel-container-left input[type="number"]').nth(1);
for (const [typed, want] of [
[181, -179],
[-181, 179],
[400, 40],
[-200, 160],
[129.1234, 129.1234],
] as const) {
await lngInput.fill(String(typed));
await lngInput.dispatchEvent('input');
await expect.poll(() => storedLaunchLng(page), { timeout: 10_000 }).toBeCloseTo(want, 6);
}
});
test('graticule is drawn by default', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
const grid = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const lines = v.entities.values.filter((e: any) => String(e.id).startsWith('graticule'));
return {
count: lines.length,
allPolylines: lines.every((e: any) => !!e.polyline),
};
/* eslint-enable @typescript-eslint/no-explicit-any */
});
// Sparse on purpose: few enough lines to stay readable at any zoom.
expect(grid.count).toBeGreaterThan(4);
expect(grid.count).toBeLessThan(40);
expect(grid.allPolylines).toBe(true);
});
test('switching the base layer swaps the imagery provider', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
const providerUrl = () =>
page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
for (let i = v.imageryLayers.length - 1; i >= 0; i--) {
const u = v.imageryLayers.get(i).imageryProvider?.url;
if (typeof u === 'string') return u;
}
return null;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(await providerUrl()).toContain('openstreetmap');
// Flip the persisted setting the way the settings panel does.
await page.evaluate(() => {
const raw = localStorage.getItem('settings');
const s = raw ? JSON.parse(raw) : {};
s.map = { ...(s.map ?? {}), baseLayer: 'satellite' };
localStorage.setItem('settings', JSON.stringify(s));
});
await page.reload();
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
await expect.poll(providerUrl, { timeout: 15_000 }).toContain('arcgisonline');
});
test('meridians converge at both poles', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/predict');
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
const meridians = await page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const C: any = v.camera.positionCartographic.constructor;
const out: { id: string; minLat: number; maxLat: number }[] = [];
for (const e of v.entities.values) {
// graticule meridians are scoped `graticule__m<lng>`
if (!/graticule__m-?\d+$/.test(String(e.id))) continue;
const lats = e.polyline.positions
.getValue(v.clock.currentTime)
.map((p: unknown) => (C.fromCartesian(p).latitude * 180) / Math.PI);
out.push({ id: String(e.id), minLat: Math.min(...lats), maxLat: Math.max(...lats) });
}
return out;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
expect(meridians.length).toBeGreaterThan(4);
// Every meridian must run all the way from pole to pole, so they all meet
// at a single point at each end — that is what a globe graticule looks like.
for (const m of meridians) {
expect(m.maxLat).toBeCloseTo(90, 3);
expect(m.minLat).toBeCloseTo(-90, 3);
}
});
/**
* The Esri "satellite" layer is Web Mercator, so it has no tiles above
* 85.0511° and the pole renders as blank blue. A polar-capable layer must use
* a geographic (EPSG:4326) tiling scheme, which covers ±90° by construction.
*/
test('polar base layer uses a geographic tiling scheme', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/predict');
await page.evaluate(() => {
const raw = localStorage.getItem('settings');
const s = raw ? JSON.parse(raw) : { locale: 'ru' };
s.map = { ...(s.map ?? {}), baseLayer: 'polar' };
localStorage.setItem('settings', JSON.stringify(s));
});
await page.reload();
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
const readLayer = () =>
page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
for (let i = v.imageryLayers.length - 1; i >= 0; i--) {
const p = v.imageryLayers.get(i).imageryProvider;
if (typeof p?.url === 'string') {
return {
url: p.url as string,
scheme: (p.tilingScheme?.constructor?.name ?? null) as string | null,
rectNorthDeg: (p.rectangle.north * 180) / Math.PI,
};
}
}
return null;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
await expect.poll(async () => (await readLayer()) !== null, { timeout: 15_000 }).toBe(true);
const layer = await readLayer();
expect(layer).not.toBeNull();
const l = layer as { url: string; scheme: string | null; rectNorthDeg: number };
// Served from our own origin: the polar basemap must not depend on a third
// party that can throttle it into blank navy tiles.
expect(l.url).toContain('/cesium/Assets/Textures/NaturalEarthII');
expect(l.url).not.toContain('gibs.earthdata.nasa.gov');
expect(l.scheme).toBe('GeographicTilingScheme');
// Coverage must reach the pole, not stop at the Mercator limit.
expect(l.rectNorthDeg).toBeCloseTo(90, 3);
});
/**
* Each base layer must be built by Cesium's protocol-specific provider rather
* than a hand-written URL template. A template forces us to guess the row
* convention, max level, extent and tiling scheme — and a wrong {reverseY}
* silently mirrors every tile into the wrong latitude band, which is exactly
* how the Esri layer broke. The dedicated providers read those facts from the
* service, so the guess has nowhere to live.
*/
const EXPECTED_PROVIDER: Record<string, string> = {
osm: 'OpenStreetMapImageryProvider',
satellite: 'ArcGisMapServerImageryProvider',
polar: 'TileMapServiceImageryProvider',
};
for (const [layer, expected] of Object.entries(EXPECTED_PROVIDER)) {
test(`${layer} layer uses ${expected}`, async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/predict');
await page.evaluate((l) => {
const raw = localStorage.getItem('settings');
const s = raw ? JSON.parse(raw) : { locale: 'ru' };
s.map = { ...(s.map ?? {}), baseLayer: l };
localStorage.setItem('settings', JSON.stringify(s));
}, layer);
await page.reload();
await page
.locator('.map-container canvas')
.first()
.waitFor({ state: 'attached', timeout: 60_000 });
const providerName = () =>
page.evaluate(() => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const v: any = (window as any)._lsvMap;
const n = v.imageryLayers.length;
if (n === 0) return null;
return v.imageryLayers.get(n - 1).imageryProvider?.constructor?.name ?? null;
/* eslint-enable @typescript-eslint/no-explicit-any */
});
await expect.poll(providerName, { timeout: 20_000 }).toBe(expected);
});
}