Port to CeliumJS #16
4 changed files with 271 additions and 17 deletions
|
|
@ -3,4 +3,4 @@ export { telemetryApi, buildWsUrl, type RawTelemetryPacket } from './telemetry';
|
||||||
export { pointsApi } from './points';
|
export { pointsApi } from './points';
|
||||||
export { profilesApi } from './profiles';
|
export { profilesApi } from './profiles';
|
||||||
export { scenariosApi } from './scenarios';
|
export { scenariosApi } from './scenarios';
|
||||||
export { predictionsApi, getLatestDataset, buildLaunchDateTime } from './predictions';
|
export { predictionsApi, buildLaunchDateTime } from './predictions';
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,6 @@
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { FlightParameters, RawPrediction } from '$domain';
|
import type { FlightParameters, RawPrediction } from '$domain';
|
||||||
|
|
||||||
/**
|
|
||||||
* GFS datasets are published every 6 hours with a ~6 hour processing lag.
|
|
||||||
* Round down to the most recent available slot.
|
|
||||||
*/
|
|
||||||
export function getLatestDataset(now: Date = new Date()): string {
|
|
||||||
// const rounded = new Date(now);
|
|
||||||
// rounded.setUTCHours(Math.floor(rounded.getUTCHours() / 6) * 6, 0, 0, 0);
|
|
||||||
// rounded.setUTCHours(rounded.getUTCHours() - 6);
|
|
||||||
// return rounded.toISOString();
|
|
||||||
return "2025-04-06T00:00:00Z";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLaunchDateTime(date: string, time: string): string {
|
export function buildLaunchDateTime(date: string, time: string): string {
|
||||||
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
const fullTime = time.split(':').length === 2 ? `${time}:00` : time;
|
||||||
return new Date(`${date}T${fullTime}Z`).toISOString();
|
return new Date(`${date}T${fullTime}Z`).toISOString();
|
||||||
|
|
@ -24,11 +12,17 @@ export interface PredictionResponse {
|
||||||
|
|
||||||
export const predictionsApi = {
|
export const predictionsApi = {
|
||||||
run: (params: FlightParameters, launchDateTime: string) => {
|
run: (params: FlightParameters, launchDateTime: string) => {
|
||||||
const payload: FlightParameters & { launch_datetime: string } = {
|
// `dataset` carries only what the operator actually chose. It used to fall
|
||||||
...params,
|
// back to a client-side guess at which GFS run the server holds — and the
|
||||||
dataset: params.dataset || getLatestDataset(),
|
// guess had degenerated into a hardcoded 2025-04-06, over a year stale. The
|
||||||
|
// client cannot know which runs are stored; the predictor refuses one it
|
||||||
|
// does not have, so an unset value must stay unset and let the server pick.
|
||||||
|
const { dataset, ...rest } = params;
|
||||||
|
const payload = {
|
||||||
|
...rest,
|
||||||
|
...(dataset ? { dataset } : {}),
|
||||||
launch_datetime: launchDateTime,
|
launch_datetime: launchDateTime,
|
||||||
};
|
} as FlightParameters & { launch_datetime: string };
|
||||||
if (payload.start_point === -1) delete payload.start_point;
|
if (payload.start_point === -1) delete payload.start_point;
|
||||||
return api.post<PredictionResponse>('/predictions/', payload);
|
return api.post<PredictionResponse>('/predictions/', payload);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
77
tests/e2e/dataset-param.spec.ts
Normal file
77
tests/e2e/dataset-param.spec.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { test, expect, login } from './fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client must not invent a dataset.
|
||||||
|
*
|
||||||
|
* `predictionsApi.run` used to fall back to a client-side guess at which GFS run
|
||||||
|
* the server held, and the guess had rotted into a hardcoded "2025-04-06T00:00:00Z"
|
||||||
|
* — over a year stale. That was harmless only for as long as Django dropped the
|
||||||
|
* parameter on the floor. Now that the predictor honours it and refuses a run it
|
||||||
|
* does not hold, sending an invented epoch fails every prediction with
|
||||||
|
* "dataset 2025-04-06T00:00:00Z is not stored".
|
||||||
|
*
|
||||||
|
* Which runs exist is server knowledge. An unchosen dataset must stay absent so
|
||||||
|
* the server picks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await login(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prediction request carries no dataset the operator did not choose', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
const bodies: string[] = [];
|
||||||
|
await page.route('**/predictions/', async (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
bodies.push(route.request().postData() ?? '');
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const ws = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'dataset-param',
|
||||||
|
color: '#dc3545',
|
||||||
|
opacity: 1,
|
||||||
|
visible: true,
|
||||||
|
flightParameters: {
|
||||||
|
ascent_rate: 5,
|
||||||
|
burst_altitude: 30000,
|
||||||
|
dataset: '', // the UI's "choose automatically"
|
||||||
|
descent_rate: 5,
|
||||||
|
format: 'json',
|
||||||
|
launch_altitude: 0,
|
||||||
|
launch_latitude: 52.2,
|
||||||
|
launch_longitude: 0.1,
|
||||||
|
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(() => bodies.length, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const payload = JSON.parse(bodies[0]);
|
||||||
|
expect(payload).not.toHaveProperty('dataset');
|
||||||
|
// The rest of the request must still be intact.
|
||||||
|
expect(payload.launch_latitude).toBe(52.2);
|
||||||
|
expect(payload.launch_datetime).toBeTruthy();
|
||||||
|
});
|
||||||
183
tests/e2e/western-launch.spec.ts
Normal file
183
tests/e2e/western-launch.spec.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue