import { test, expect, login, sceneObjectCount } from './fixtures'; /** * A launch west of Greenwich must work. * * The app holds longitudes in [-180, 180] — wrapLongitude produces that, the map * reports clicks that way, and the predictor accepts [-180, 360). Django's * PredictionRequestSerializer declared launch_longitude with min_value=0, so every * western coordinate was refused before it ever reached the predictor. That is * Canada, Greenland and Alaska: most of the Arctic launch sites this product is * being built for. * * Nuuk (64.1, -51.7) is the fixture. 308.3 is the same meridian written in the * [0, 360) convention, which is the control: if one is refused and the other is * accepted, the constraint is about notation, not about the place. */ test.beforeEach(async ({ context }) => { await login(context); }); const NUUK = { lat: 64.1, lngSigned: -51.7, lngUnsigned: 308.3 }; /** POST straight to Django from the page, so the browser's session and CSRF apply. */ async function postPrediction(page: import('@playwright/test').Page, lat: number, lng: number) { return page.evaluate( async ({ lat, lng }) => { const cookie = (name: string) => document.cookie .split('; ') .find((c) => c.startsWith(name + '=')) ?.split('=')[1] ?? ''; await fetch('/api/csrf/', { credentials: 'include' }); const res = await fetch('/api/predictions/', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': cookie('csrftoken') }, body: JSON.stringify({ launch_latitude: lat, launch_longitude: lng, launch_datetime: '2026-08-05T12:00:00Z', launch_altitude: 0, ascent_rate: 5, burst_altitude: 30000, descent_rate: 5, profile: 'standard_profile', }), }); return { status: res.status, body: (await res.text()).slice(0, 300) }; }, { lat, lng }, ); } test('the operator can run a prediction west of Greenwich', async ({ page }) => { test.setTimeout(150_000); const posted: string[] = []; const answered: string[] = []; page.on('request', (r) => { if (r.url().includes('/predictions/') && r.method() === 'POST') posted.push(r.postData() ?? ''); }); page.on('response', async (r) => { if (r.url().includes('/predictions/')) answered.push(`${r.status()} ${(await r.text().catch(() => '')).slice(0, 200)}`); }); // A default workspace is created on first visit; nothing is seeded, so the // coordinates below come from the input fields the way an operator enters them. await page.goto('/predict'); await page .locator('.map-container canvas') .first() .waitFor({ state: 'attached', timeout: 60_000 }); // The coordinate fields live on the Conditions tab; the Scenario tab is open by // default, so nothing numeric is mounted until this click. await page .locator('.panel-container-left') .getByRole('button', { name: /Условия|Conditions/ }) .first() .click(); // The latitude input is the only number field bounded to +-90; longitude is the // number field immediately after it. const latInput = page.locator('.panel-container-left input[type="number"][min="-90"]').first(); await expect(latInput).toBeVisible(); const lngInput = latInput.locator('xpath=following::input[@type="number"][1]'); await latInput.fill(String(NUUK.lat)); await lngInput.fill(String(NUUK.lngSigned)); // What the UI holds after its own normalisation. wrapLongitude keeps west // negative rather than folding it to 308.3, which is why the value Django sees // is negative. expect(Number(await lngInput.inputValue())).toBeCloseTo(NUUK.lngSigned, 6); await page .locator('.panel-container-right .workspace-row') .first() .getByRole('button', { name: /Рассчитать|Run/ }) .click(); await expect.poll(() => answered.length, { timeout: 90_000 }).toBeGreaterThan(0); console.log(' POST body :', posted[0]); console.log(' response :', answered[0]); // Compared numerically: wrapLongitude's modular arithmetic returns // -51.69999999999999, about a nanometre off, which is not worth chasing. expect(JSON.parse(posted[0]).launch_longitude).toBeCloseTo(NUUK.lngSigned, 6); expect(answered[0]).toMatch(/^2\d\d /); // And the trajectory actually renders. await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 30_000 }).toBeGreaterThan(0); }); test('the same point is accepted in either longitude convention', async ({ page }) => { test.setTimeout(150_000); await page.goto('/predict'); const west = await postPrediction(page, NUUK.lat, NUUK.lngSigned); const east = await postPrediction(page, NUUK.lat, NUUK.lngUnsigned); console.log(` lng=${NUUK.lngSigned} -> ${west.status} ${west.body.slice(0, 120)}`); console.log(` lng=${NUUK.lngUnsigned} -> ${east.status} ${east.body.slice(0, 120)}`); // The control: the unsigned form of the same meridian was always accepted. expect(east.status).toBeLessThan(300); expect(west.status).toBe(east.status); }); test('the parsed trajectory is folded back to the signed convention', async ({ page }) => { test.setTimeout(150_000); // v1 publishes [0, 360) — the convention upstream Tawhiri publishes, which is // why v1 exists. The frontend holds [-180, 180], and domain/prediction.ts is // the single place that folds: parsePrediction runs normalizeLng over the // flight path and over launch/burst/landing. This checks that fold actually // happened, since a missed one would put Greenland at 308 E — on the far side // of the globe — with no error anywhere. await page.goto('/'); await page.evaluate(() => { const ws = { id: crypto.randomUUID(), name: 'nuuk', color: '#dc3545', opacity: 1, visible: true, flightParameters: { ascent_rate: 5, burst_altitude: 30000, dataset: '', descent_rate: 5, format: 'json', launch_altitude: 0, launch_latitude: 64.1, launch_longitude: -51.7, profile: 'standard_profile', version: 2, }, launchDate: new Date().toISOString().split('T')[0], launchTime: '12:00:00', result: null, }; localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id })); }); await page.goto('/predict'); await page.locator('.map-container canvas').first().waitFor({ state: 'attached', timeout: 60_000 }); await page .locator('.panel-container-right .workspace-row') .first() .getByRole('button', { name: /Рассчитать|Run/ }) .click(); await expect.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000] }) .toBeGreaterThan(0); // Read the drawn polyline, not the store: what matters is where the trajectory // ends up on the globe, and Cesium holds it as earth-centred vectors that have // to be read back through Cartographic. const geom = 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).includes('ws/') && x.polyline); if (!e) return null; const ps = e.polyline.positions.getValue(v.clock.currentTime); const lngs = ps.map((p: any) => (C.fromCartesian(p).longitude * 180) / Math.PI); return { n: ps.length, min: Math.min(...lngs), max: Math.max(...lngs), first: lngs[0] }; /* eslint-enable @typescript-eslint/no-explicit-any */ }); expect(geom).not.toBeNull(); console.log(' drawn longitudes:', geom!.min.toFixed(4), '..', geom!.max.toFixed(4), `(${geom!.n} pts)`); // Nuuk and its whole flight stay near -51, not near +308 on the far side. expect(geom!.max).toBeLessThan(0); expect(geom!.min).toBeGreaterThan(-90); expect(geom!.first).toBeCloseTo(-51.7, 3); });