import { test, expect, login, sceneObjectCount } from './fixtures'; /** * Regression guard for the Mercator -> globe migration. * * The MapLibre implementation tiled every GeoJSON source through * @maplibre/geojson-vt, whose projectY() clamps its result to [0,1] — so any * vertex above 85.051129° collapsed onto that parallel and a polar trajectory * rendered as a straight line along it. Cesium converts degrees straight to * Cartesian3, so latitude must survive intact. If a Mercator-tiled render path * is ever reintroduced, this test fails. */ const MERCATOR_LIMIT = 85.051129; test.beforeEach(async ({ context }) => { await login(context); }); test('globe preserves latitude above the Mercator limit', async ({ page }) => { // Cesium ships ~7 MB of workers/assets; first paint is slower than MapLibre. test.setTimeout(90_000); await page.goto('/predict'); await page .locator('.map-container canvas') .first() .waitFor({ state: 'attached', timeout: 60_000 }); // Map.svelte exposes the raw Cesium Viewer on window._lsvMap in dev builds. await page.waitForFunction( () => (window as unknown as { _lsvMap?: { scene?: unknown } })._lsvMap?.scene !== undefined, undefined, { timeout: 60_000 }, ); const latitudes: number[] = await page.evaluate((limit) => { /* eslint-disable @typescript-eslint/no-explicit-any */ const viewer = (window as any)._lsvMap; // Cesium is an ES module, not a global. Reach its constructors through // live objects the Viewer already holds: camera.position is a Cartesian3 // and positionCartographic is a Cartographic, both carrying the statics. const Cartesian3: any = viewer.camera.position.constructor; const Cartographic: any = viewer.camera.positionCartographic.constructor; const wanted = [89.0, limit + 0.001, 89.99, 90.0]; const positions = Cartesian3.fromDegreesArray(wanted.flatMap((lat: number) => [10, lat])); viewer.entities.add({ polyline: { positions, width: 3 } }); return positions.map( (p: unknown) => (Cartographic.fromCartesian(p).latitude * 180) / Math.PI, ); /* eslint-enable @typescript-eslint/no-explicit-any */ }, MERCATOR_LIMIT); expect(latitudes).toHaveLength(4); // Every vertex round-trips to the latitude it was given. expect(latitudes[0]).toBeCloseTo(89.0, 6); expect(latitudes[1]).toBeCloseTo(MERCATOR_LIMIT + 0.001, 6); expect(latitudes[2]).toBeCloseTo(89.99, 6); expect(latitudes[3]).toBeCloseTo(90.0, 6); // And none got pinned to the old Mercator ceiling. for (const lat of latitudes.slice(1)) { expect(lat).toBeGreaterThan(MERCATOR_LIMIT); } }); /** * The flight_path tuples carry altitude as an optional third element * (domain/geo.ts: LatLngTuple), filled in by parsePrediction. MapLibre could * not use it, so it sat unread; on a globe it must become real geometry — * otherwise a 30 km balloon flight renders as a flat line on the ground. */ test.describe('altitude', () => { test.beforeEach(async ({ context }) => { await login(context); }); /** Heights (metres) of every vertex of the rendered workspace path. */ function pathHeights(page: import('@playwright/test').Page): Promise { return page.evaluate(() => { /* eslint-disable @typescript-eslint/no-explicit-any */ const v: any = (window as any)._lsvMap; const C: any = v.camera.positionCartographic.constructor; const e = v.entities.values.find((x: any) => String(x.id).endsWith('__path')); if (!e) return []; return e.polyline.positions .getValue(v.clock.currentTime) .map((p: unknown) => C.fromCartesian(p).height); /* eslint-enable @typescript-eslint/no-explicit-any */ }); } async function runPrediction(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 .locator('.panel-container-right') .locator('.workspace-row') .first() .getByRole('button', { name: /Рассчитать|Run/ }) .click(); await expect .poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000], }) .toBeGreaterThan(0); } test('trajectory vertices carry real altitude, not ground level', async ({ page }) => { test.setTimeout(150_000); await runPrediction(page); const heights = await pathHeights(page); expect(heights.length).toBeGreaterThan(10); // Burst is at 30 km; the apex must be somewhere near it, definitely not 0. expect(Math.max(...heights)).toBeGreaterThan(10_000); // Launch/landing sit on the ground, so the minimum stays low. expect(Math.min(...heights)).toBeLessThan(1_000); }); test('burst marker sits at its altitude, not on the ground', async ({ page }) => { test.setTimeout(150_000); await runPrediction(page); const burstHeight = 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 e = v.entities.values.find((x: any) => String(x.id).endsWith('__burst')); if (!e) return null; return C.fromCartesian(e.position.getValue(v.clock.currentTime)).height; /* eslint-enable @typescript-eslint/no-explicit-any */ }); expect(burstHeight).not.toBeNull(); expect(burstHeight as number).toBeGreaterThan(10_000); }); });