import { test, expect, login } from './fixtures'; /** * Task 6 verification: end-to-end polar behaviour against the REAL stack. * * Unlike the rest of the suite, this spec expects Django on :8000 and the Go * predictor on :8080 with a loaded GFS dataset (./run-stack.py). It drives a * launch at high latitude through the UI, then reads back the coordinates the * globe actually rendered — the numbers the predictor returned, not synthetic * ones. * * Run with: * npx playwright test tests/e2e/polar.spec.ts --reporter=list */ const MERCATOR_LIMIT = 85.051129; /** * Must be reached as localhost, not 127.0.0.1. Django's CSRF_TRUSTED_ORIGINS * defaults to `http://localhost:5173`, so a 127.0.0.1 origin gets 403 on any * POST (including /api/predictions/). run-stack.py binds Vite to 127.0.0.1 and * does not set CSRF_TRUSTED_ORIGINS, so the trusted spelling is the one to use. */ test.use({ baseURL: 'http://localhost:5173' }); /** Seed one workspace whose launch point sits at the given latitude. */ async function seedLaunch(page: import('@playwright/test').Page, lat: number, lng: number) { await page.goto('/'); await page.evaluate( ({ lat, lng }) => { const ws = { id: crypto.randomUUID(), name: `polar-${lat}`, color: '#dc3545', opacity: 1, visible: true, flightParameters: { ascent_rate: 5.0, burst_altitude: 30000.0, dataset: '', descent_rate: 5.0, format: 'json', launch_altitude: 0.0, launch_latitude: lat, launch_longitude: lng, profile: 'standard_profile', version: 2, }, launchDate: new Date().toISOString().split('T')[0], launchTime: '12:00:00', result: null, bboxMargin: 10, bboxVisible: false, }; localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id })); }, { lat, lng }, ); } /** Every latitude Cesium is holding in a rendered workspace polyline. */ function renderedLatitudes(page: import('@playwright/test').Page): Promise { return page.evaluate(() => { /* eslint-disable @typescript-eslint/no-explicit-any */ const viewer: any = (window as any)._lsvMap; if (!viewer?.entities) return []; const Cartographic: any = viewer.camera.positionCartographic.constructor; const out: number[] = []; for (const e of viewer.entities.values) { if (typeof e.id !== 'string' || !e.id.startsWith('ws/')) continue; const positions = e.polyline?.positions?.getValue?.(viewer.clock.currentTime); if (!positions) continue; for (const p of positions) { out.push((Cartographic.fromCartesian(p).latitude * 180) / Math.PI); } } return out; /* eslint-enable @typescript-eslint/no-explicit-any */ }); } /** * This spec needs the real predictor + Django, unlike the rest of the suite * which runs against the mock plugin. Probe for it and skip rather than fail * confusingly when only the mock dev server is up. */ let stackUp = false; test.beforeAll(async () => { try { const res = await fetch('http://127.0.0.1:8080/ready'); stackUp = res.ok; } catch { stackUp = false; } }); test.beforeEach(async ({ context }) => { test.skip(!stackUp, 'requires the real stack — start it with ./run-stack.py'); await login(context); }); for (const lat of [89.5, 89.99]) { test(`polar launch at ${lat}N renders unclamped on the globe`, async ({ page }) => { test.setTimeout(180_000); await seedLaunch(page, lat, 0.1); 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(); // Wait for the real predictor round-trip to paint something. await expect .poll(async () => (await renderedLatitudes(page)).length, { timeout: 150_000, intervals: [2000, 3000, 5000], }) .toBeGreaterThan(0); const lats = await renderedLatitudes(page); const maxLat = Math.max(...lats); // eslint-disable-next-line no-console console.log( ` [${lat}N] vertices=${lats.length} max=${maxLat.toFixed(5)} min=${Math.min(...lats).toFixed(5)}`, ); // Nothing may be NaN, and the track must reach above the Mercator ceiling // that the previous renderer could not represent. expect(lats.every((v) => Number.isFinite(v))).toBe(true); expect(maxLat).toBeGreaterThan(MERCATOR_LIMIT); // And no vertex may sit exactly on the old clamp value, which would mean // something re-introduced Mercator tiling. expect(lats.some((v) => Math.abs(v - MERCATOR_LIMIT) < 1e-6)).toBe(false); // Nothing on /predict auto-frames the result (fitBounds is only wired on // the tracking page), so frame the track's own bounds to capture it — // the same Rectangle maths CesiumMap.fitBounds uses. await page.evaluate(() => { /* eslint-disable @typescript-eslint/no-explicit-any */ const v: any = (window as any)._lsvMap; const Cartographic: any = v.camera.positionCartographic.constructor; const Rectangle: any = v.camera.computeViewRectangle().constructor; const lats: number[] = []; const lngs: number[] = []; for (const e of v.entities.values) { if (typeof e.id !== 'string' || !e.id.startsWith('ws/')) continue; const ps = e.polyline?.positions?.getValue?.(v.clock.currentTime); if (!ps) continue; for (const p of ps) { const c = Cartographic.fromCartesian(p); lats.push((c.latitude * 180) / Math.PI); lngs.push((c.longitude * 180) / Math.PI); } } const rect = Rectangle.fromDegrees( Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats), ); v.camera.flyTo({ destination: rect, duration: 0 }); /* eslint-enable @typescript-eslint/no-explicit-any */ }); // Let the camera move and tile streaming settle before capturing. await page.waitForTimeout(6000); const cam = await page.evaluate(() => { /* eslint-disable @typescript-eslint/no-explicit-any */ const v: any = (window as any)._lsvMap; const c = v.camera.positionCartographic; return { lng: (c.longitude * 180) / Math.PI, lat: (c.latitude * 180) / Math.PI, height: c.height, tilesLoaded: v.scene.globe.tilesLoaded, }; /* eslint-enable @typescript-eslint/no-explicit-any */ }); // eslint-disable-next-line no-console console.log(` [${lat}N] camera ${JSON.stringify(cam)}`); await page.screenshot({ path: `test-results/polar-${lat}N.png`, fullPage: false }); }); }