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 { 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 { 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` 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 = { 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); }); }