feat(globe): port to CeliumJS
This commit is contained in:
parent
ec03425067
commit
eb7698e034
51 changed files with 3521 additions and 1992 deletions
123
tests/e2e/bbox.spec.ts
Normal file
123
tests/e2e/bbox.spec.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* Browser-side checks for the restricted-area box. The geometry itself is
|
||||
* covered in tests/unit/boundingBox.spec.ts, which runs in Node against the pure
|
||||
* module; what only a browser can answer is whether the thing rasterises and
|
||||
* what the operator ends up reading on screen.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** Seed one workspace at the given launch point, run it, and draw its box. */
|
||||
async function runAt(page: import('@playwright/test').Page, lat: number, lng: number) {
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ lat, lng }) => {
|
||||
const ws = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `bbox-${lat}`,
|
||||
color: '#dc3545',
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
flightParameters: {
|
||||
ascent_rate: 5,
|
||||
burst_altitude: 30000,
|
||||
dataset: '',
|
||||
descent_rate: 5,
|
||||
format: 'json',
|
||||
launch_altitude: 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: 5,
|
||||
bboxVisible: false,
|
||||
};
|
||||
localStorage.setItem('workspaces', JSON.stringify({ items: [ws], activeId: ws.id }));
|
||||
},
|
||||
{ lat, lng },
|
||||
);
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
.locator('.map-container canvas')
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout: 60_000 });
|
||||
|
||||
const panel = page.locator('.panel-container-right');
|
||||
await panel
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), { timeout: 90_000, intervals: [1000, 2000, 3000] })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await panel.getByRole('button', { name: /Построить рамку|Generate bounding box/ }).click();
|
||||
await expect.poll(() => sceneObjectCount(page, 'bbox'), { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: the box must actually rasterise.
|
||||
*
|
||||
* An earlier attempt made a pole-touching box circumpolar with west=-180 and
|
||||
* east=180. Both meridional edges then lay on the antimeridian, and Cesium's
|
||||
* splitLongitude pass — which cuts geometry at the IDL — produced mismatched
|
||||
* attribute lists and killed the render loop:
|
||||
*
|
||||
* DeveloperError: All attribute lists must have the same number of attributes.
|
||||
* at k.splitLongitude / S.combineGeometry
|
||||
*
|
||||
* Assertions on polyline.positions could not see this, because that is the entity
|
||||
* definition and geometry is combined later in a worker. This listens for the
|
||||
* render failure itself.
|
||||
*/
|
||||
test('drawing a polar box does not break the renderer', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runAt(page, 89.99, 30);
|
||||
|
||||
await page.evaluate(() => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const v: any = (window as any)._lsvMap;
|
||||
(window as any).__renderErrors = [];
|
||||
v.scene.renderError.addEventListener((_s: unknown, e: any) =>
|
||||
(window as any).__renderErrors.push(String(e?.message ?? e)),
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
await page.waitForTimeout(6000);
|
||||
|
||||
const errors = await page.evaluate(
|
||||
() => (window as never as { __renderErrors: string[] }).__renderErrors,
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
// Cesium swaps in an error panel when the render loop dies.
|
||||
await expect(page.locator('.cesium-widget-errorPanel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the panel reports a corridor-sized area at the pole, not a polar cap', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
const panel = await runAt(page, 89.99, 30);
|
||||
|
||||
// Near a pole the four corners are unreadable on their own — a box whose north
|
||||
// edge passes over the pole comes back down the far side — so the size line is
|
||||
// what the operator actually checks. The lat/lon-rectangle form reported the
|
||||
// whole cap north of 88.93, 44 200 km^2, for a corridor of about 1 700.
|
||||
const size = panel.locator('.font-monospace', { hasText: 'km @' }).first();
|
||||
await expect(size).toBeVisible();
|
||||
const text = (await size.textContent()) ?? '';
|
||||
const [w, h] = text.split('km @')[0].split('×').map((s) => parseFloat(s));
|
||||
|
||||
expect(w).toBeGreaterThan(0);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
expect(w * h).toBeLessThan(10_000);
|
||||
});
|
||||
121
tests/e2e/export.spec.ts
Normal file
121
tests/e2e/export.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { test, expect, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
/**
|
||||
* Prediction export. The UI shell (format select + Export button) existed with
|
||||
* no handler wired at all, so nothing was ever produced.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await login(context);
|
||||
});
|
||||
|
||||
/** Run a prediction so there is a result to export. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Pick a format in the export select and click Export; return the file text. */
|
||||
async function exportAs(
|
||||
page: import('@playwright/test').Page,
|
||||
format: string,
|
||||
): Promise<{ name: string; text: string }> {
|
||||
const group = page.locator('.panel-container-left .input-group', {
|
||||
has: page.getByRole('button', { name: /Экспорт|Export/ }),
|
||||
});
|
||||
await group.locator('select').selectOption(format);
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
group.getByRole('button', { name: /Экспорт|Export/ }).click(),
|
||||
]);
|
||||
const path = await download.path();
|
||||
if (!path) throw new Error('download produced no file');
|
||||
return { name: download.suggestedFilename(), text: readFileSync(path, 'utf8') };
|
||||
}
|
||||
|
||||
test('exports the trajectory as CSV', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'CSV');
|
||||
expect(name).toMatch(/\.csv$/);
|
||||
|
||||
const lines = text.trim().split('\n');
|
||||
expect(lines[0]).toBe('datetime,latitude,longitude,altitude');
|
||||
// One row per trajectory point.
|
||||
expect(lines.length).toBeGreaterThan(50);
|
||||
// Every data row: ISO timestamp + three numbers.
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line.split(',');
|
||||
expect(cols).toHaveLength(4);
|
||||
expect(cols[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
for (const n of cols.slice(1)) expect(Number.isFinite(Number(n))).toBe(true);
|
||||
}
|
||||
// Altitude must actually rise — proves real data, not zeros.
|
||||
const alts = lines.slice(1).map((l) => Number(l.split(',')[3]));
|
||||
expect(Math.max(...alts)).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
test('exports the trajectory as JSON', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'JSON');
|
||||
expect(name).toMatch(/\.json$/);
|
||||
|
||||
const data = JSON.parse(text) as {
|
||||
launch: { latitude: number; longitude: number; datetime: string };
|
||||
burst: { altitude: number };
|
||||
landing: { latitude: number; longitude: number };
|
||||
flight_time: number;
|
||||
trajectory: { datetime: string; latitude: number; longitude: number; altitude: number }[];
|
||||
};
|
||||
expect(data.trajectory.length).toBeGreaterThan(50);
|
||||
expect(Number.isFinite(data.launch.latitude)).toBe(true);
|
||||
expect(data.burst.altitude).toBeGreaterThan(10_000);
|
||||
expect(Number.isFinite(data.landing.longitude)).toBe(true);
|
||||
expect(data.flight_time).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('exports the trajectory as KML with absolute altitude', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await runPrediction(page);
|
||||
|
||||
const { name, text } = await exportAs(page, 'KML');
|
||||
expect(name).toMatch(/\.kml$/);
|
||||
|
||||
expect(text).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||
expect(text).toContain('<kml xmlns="http://www.opengis.net/kml/2.2">');
|
||||
// A balloon track is not a ground feature: it must carry its own altitude,
|
||||
// otherwise Google Earth drapes the 30 km arc onto the terrain.
|
||||
expect(text).toContain('<altitudeMode>absolute</altitudeMode>');
|
||||
expect(text).toContain('<LineString>');
|
||||
// Placemarks for the three key events.
|
||||
for (const n of ['Launch', 'Burst', 'Landing']) expect(text).toContain(`<name>${n}</name>`);
|
||||
|
||||
// Coordinates are lon,lat,alt triples — note the order differs from CSV.
|
||||
const coords = /<coordinates>([\s\S]*?)<\/coordinates>/.exec(text);
|
||||
expect(coords).not.toBeNull();
|
||||
const triples = (coords as RegExpExecArray)[1].trim().split(/\s+/);
|
||||
expect(triples.length).toBeGreaterThan(50);
|
||||
const alts = triples.map((tr) => Number(tr.split(',')[2]));
|
||||
expect(Math.max(...alts)).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
|
@ -65,6 +65,25 @@ export const test = base.extend({
|
|||
|
||||
export { expect };
|
||||
|
||||
/**
|
||||
* Count map objects belonging to scenes whose name starts with `prefix`.
|
||||
*
|
||||
* Renderer-specific: CesiumScene scopes every entity id as `<scene>__<id>`
|
||||
* (see src/lib/map/cesium-scene.ts), so scene membership is read off the id.
|
||||
* Kept in fixtures so a renderer swap touches one place, not every spec.
|
||||
*/
|
||||
export function sceneObjectCount(page: Page, prefix: string): Promise<number> {
|
||||
return page.evaluate((p) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const viewer: any = (window as any)._lsvMap;
|
||||
if (!viewer?.entities) return 0;
|
||||
return viewer.entities.values.filter((e: { id?: string }) =>
|
||||
typeof e.id === 'string' ? e.id.startsWith(p) : false,
|
||||
).length;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}, prefix);
|
||||
}
|
||||
|
||||
export async function openPredict(page: Page) {
|
||||
await page.goto('/predict');
|
||||
await page
|
||||
|
|
|
|||
144
tests/e2e/globe.spec.ts
Normal file
144
tests/e2e/globe.spec.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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<number[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
291
tests/e2e/map-controls.spec.ts
Normal file
291
tests/e2e/map-controls.spec.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
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<number | null> {
|
||||
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<number | null> {
|
||||
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<lng>`
|
||||
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<string, string> = {
|
||||
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);
|
||||
});
|
||||
}
|
||||
191
tests/e2e/polar.spec.ts
Normal file
191
tests/e2e/polar.spec.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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<number[]> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ test.beforeEach(async ({ context }) => {
|
|||
await login(context);
|
||||
});
|
||||
|
||||
test('predict page loads and mounts the MapLibre canvas', async ({ page }) => {
|
||||
test('predict page loads and mounts the map canvas', async ({ page }) => {
|
||||
await page.goto('/predict');
|
||||
await expect(page.locator('.map-container canvas').first()).toBeAttached({
|
||||
timeout: 15_000,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, openPredict, login } from './fixtures';
|
||||
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await login(context);
|
||||
|
|
@ -6,14 +6,9 @@ test.beforeEach(async ({ context, page }) => {
|
|||
await page.evaluate(() => localStorage.removeItem('workspaces'));
|
||||
});
|
||||
|
||||
/** Count map layers whose scoped id belongs to a bounding-box scene. */
|
||||
/** Count map objects whose scoped id belongs to a bounding-box scene. */
|
||||
function bboxLayerCount(page: import('@playwright/test').Page) {
|
||||
return page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map.getStyle().layers.filter((l: { id: string }) => l.id.startsWith('bbox')).length;
|
||||
});
|
||||
return sceneObjectCount(page, 'bbox');
|
||||
}
|
||||
|
||||
// Regression: the bounding box is drawn only by WorkspaceRenderer (predict-only).
|
||||
|
|
@ -32,18 +27,10 @@ test('selected forecast bounding box is drawn on the tracking page', async ({ pa
|
|||
|
||||
// Wait for the run to complete (workspace scene appears).
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map
|
||||
.getStyle()
|
||||
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||
}),
|
||||
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||
)
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 75_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Enable the bounding box for this forecast.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, openPredict, login } from './fixtures';
|
||||
import { test, expect, openPredict, login, sceneObjectCount } from './fixtures';
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await login(context);
|
||||
|
|
@ -52,17 +52,75 @@ test('workspace render pipeline adds a map scene after a run', async ({ page })
|
|||
|
||||
// Wait for the prediction request to complete and layers to be added.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const map: any = (window as any)._lsvMap;
|
||||
if (!map) return 0;
|
||||
return map
|
||||
.getStyle()
|
||||
.layers.filter((l: { id: string }) => l.id.startsWith('ws/')).length;
|
||||
}),
|
||||
{ timeout: 75_000, intervals: [1000, 2000, 3000] },
|
||||
)
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 75_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Regression: a flight is only tens of km across, which is a few dozen pixels
|
||||
// at the default camera height — the track ends up hidden under its own launch/
|
||||
// burst/landing markers and looks like a single dot. A fresh result must be
|
||||
// framed by the camera.
|
||||
test('camera frames the trajectory after a run', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await openPredict(page);
|
||||
|
||||
const camera = () =>
|
||||
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,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
|
||||
const before = await camera();
|
||||
|
||||
await workspacesPanel(page)
|
||||
.locator('.workspace-row')
|
||||
.first()
|
||||
.getByRole('button', { name: /Рассчитать|Run/ })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => sceneObjectCount(page, 'ws/'), {
|
||||
timeout: 90_000,
|
||||
intervals: [1000, 2000, 3000],
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Give the framing flight time to finish.
|
||||
await expect
|
||||
.poll(async () => (await camera()).height, { timeout: 20_000, intervals: [500, 1000] })
|
||||
.toBeLessThan(before.height / 2);
|
||||
|
||||
const after = await camera();
|
||||
// The camera must sit inside the track's own bounds, not at the default centre.
|
||||
const bounds = 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 la: number[] = [];
|
||||
const lo: number[] = [];
|
||||
for (const e of v.entities.values) {
|
||||
if (!String(e.id).endsWith('__path')) continue;
|
||||
for (const p of e.polyline.positions.getValue(v.clock.currentTime)) {
|
||||
const c = C.fromCartesian(p);
|
||||
la.push((c.latitude * 180) / Math.PI);
|
||||
lo.push((c.longitude * 180) / Math.PI);
|
||||
}
|
||||
}
|
||||
return { latMin: Math.min(...la), latMax: Math.max(...la), lngMin: Math.min(...lo), lngMax: Math.max(...lo) };
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
});
|
||||
expect(after.lat).toBeGreaterThanOrEqual(bounds.latMin - 0.5);
|
||||
expect(after.lat).toBeLessThanOrEqual(bounds.latMax + 0.5);
|
||||
expect(after.lng).toBeGreaterThanOrEqual(bounds.lngMin - 0.5);
|
||||
expect(after.lng).toBeLessThanOrEqual(bounds.lngMax + 0.5);
|
||||
});
|
||||
|
|
|
|||
200
tests/unit/boundingBox.spec.ts
Normal file
200
tests/unit/boundingBox.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
computeBoundingBox,
|
||||
boundingBoxRing,
|
||||
type BoundingBox,
|
||||
} from '../../src/lib/domain/boundingBox';
|
||||
import type { LatLngTuple } from '../../src/lib/domain/geo';
|
||||
|
||||
/**
|
||||
* The restricted area filed with the regulator.
|
||||
*
|
||||
* It is a rectangle in kilometres, axis-aligned to east/north at its own centre,
|
||||
* with four lat/lon corners joined by great circles. Not a rectangle in degrees:
|
||||
* that form cannot work near a pole, because every meridian passes through the
|
||||
* pole, so any lat/lon rectangle containing one spans all 360 deg of longitude.
|
||||
* The measured cost of that was a 44 200 km^2 cap standing in for a 1 695 km^2
|
||||
* corridor.
|
||||
*
|
||||
* These tests run in Node, not a browser — the module under test is pure
|
||||
* geometry and imports only a type.
|
||||
*/
|
||||
|
||||
const R_KM = 6371;
|
||||
const rad = (d: number) => (d * Math.PI) / 180;
|
||||
const deg = (r: number) => (r * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Standard spherical destination-point formula, written out here rather than
|
||||
* imported so the tests do not check the implementation against itself.
|
||||
* Undefined starting exactly at a pole, which is why the polar cases use 89.99.
|
||||
*/
|
||||
function destination(lat: number, lng: number, bearingDeg: number, distKm: number): LatLngTuple {
|
||||
const d = distKm / R_KM;
|
||||
const br = rad(bearingDeg);
|
||||
const p1 = rad(lat);
|
||||
const l1 = rad(lng);
|
||||
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(br));
|
||||
const l2 =
|
||||
l1 +
|
||||
Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
|
||||
return [deg(p2), deg(l2)];
|
||||
}
|
||||
|
||||
/** A straight meridional track: identical in kilometres at any latitude. */
|
||||
function meridionalTrack(lat: number, lng: number, lengthKm: number, n = 40): LatLngTuple[] {
|
||||
return Array.from({ length: n }, (_, i) => destination(lat, lng, 180, (lengthKm * i) / (n - 1)));
|
||||
}
|
||||
|
||||
function toVec([lat, lng]: LatLngTuple): [number, number, number] {
|
||||
const p = rad(lat);
|
||||
const l = rad(lng);
|
||||
return [Math.cos(p) * Math.cos(l), Math.cos(p) * Math.sin(l), Math.sin(p)];
|
||||
}
|
||||
|
||||
const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: number[], b: number[]): [number, number, number] => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
|
||||
/**
|
||||
* Signed clearance in km from a point to the great circle through two corners,
|
||||
* positive on the side the box interior is on.
|
||||
*
|
||||
* This is measured against the edge the regulator would draw — the great circle
|
||||
* between the filed corners — not against the projected rectangle. The two are
|
||||
* not the same: a great-circle edge bows toward the centre of the box relative
|
||||
* to its chord in the projection, by roughly (half-edge)^2 / 2R. On a 500 km
|
||||
* edge that is 4.9 km, so it silently eats a 5 km margin whole.
|
||||
*/
|
||||
function clearanceKm(point: LatLngTuple, from: LatLngTuple, to: LatLngTuple, inside: LatLngTuple) {
|
||||
const n = cross(toVec(from), toVec(to));
|
||||
const len = Math.hypot(...n) || 1;
|
||||
const unit = n.map((c) => c / len);
|
||||
const sign = Math.sign(dot(unit, toVec(inside))) || 1;
|
||||
return sign * Math.asin(Math.max(-1, Math.min(1, dot(unit, toVec(point))))) * R_KM;
|
||||
}
|
||||
|
||||
/** Smallest clearance from any track point to any of the four filed edges. */
|
||||
function worstClearanceKm(box: BoundingBox, path: LatLngTuple[]): number {
|
||||
const c = box.corners;
|
||||
const edges: [LatLngTuple, LatLngTuple][] = [
|
||||
[c[0], c[1]],
|
||||
[c[1], c[2]],
|
||||
[c[2], c[3]],
|
||||
[c[3], c[0]],
|
||||
];
|
||||
let worst = Infinity;
|
||||
for (const p of path) {
|
||||
for (const [a, b] of edges) {
|
||||
const d = clearanceKm(p, a, b, box.centre);
|
||||
if (d < worst) worst = d;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
/** Spherical excess area of the filed quad, km^2. */
|
||||
function areaKm2(box: BoundingBox): number {
|
||||
const v = box.corners.map(toVec);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// Interior angle at vertex i, between the planes of its two edges.
|
||||
const prev = v[(i + 3) % 4];
|
||||
const next = v[(i + 1) % 4];
|
||||
const n1 = cross(v[i], prev);
|
||||
const n2 = cross(v[i], next);
|
||||
const l1 = Math.hypot(...n1) || 1;
|
||||
const l2 = Math.hypot(...n2) || 1;
|
||||
sum += Math.acos(Math.max(-1, Math.min(1, -dot(n1, n2) / (l1 * l2))));
|
||||
}
|
||||
return (sum - 2 * Math.PI) * R_KM * R_KM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real trajectory from a launch at 89.99 N, 0 E, decimated. Under the old
|
||||
* lat/lon-rectangle form this produced south 88.93, north 90, west -180,
|
||||
* east 180: the entire cap, 44 200 km^2. In 1 degree of latitude it sweeps
|
||||
* 79 degrees of longitude, because near a pole a short displacement crosses
|
||||
* many meridians.
|
||||
*/
|
||||
const POLAR_TRACK: LatLngTuple[] = [
|
||||
[89.99, 0],
|
||||
[89.9564, 60.5627],
|
||||
[89.8954, 70.1192],
|
||||
[89.8095, 73.9615],
|
||||
[89.6924, 76.1248],
|
||||
[89.563, 77.1293],
|
||||
[89.494, 77.2284],
|
||||
[89.4553, 77.0498],
|
||||
[89.4281, 76.8977],
|
||||
[89.4109, 76.7301],
|
||||
[89.3981, 76.6866],
|
||||
[89.388, 76.9395],
|
||||
[89.3823, 77.663],
|
||||
[89.3771, 78.6642],
|
||||
[89.3748, 79.0682],
|
||||
[89.3736, 79.2029],
|
||||
[89.3589, 79.2173],
|
||||
[89.3177, 78.9415],
|
||||
[89.2028, 78.9972],
|
||||
[89.0974, 79.125],
|
||||
[89.0269, 79.0071],
|
||||
[88.9812, 78.8571],
|
||||
[88.9782, 78.8563],
|
||||
];
|
||||
|
||||
const MARGIN = 5;
|
||||
|
||||
test('every trajectory point clears the filed edges by the full margin', () => {
|
||||
// 500 km is where the great-circle bow matters: the east and west edges span
|
||||
// +-255 km, and a chord-to-arc sag of 255^2/2R = 5.1 km would consume the
|
||||
// entire 5 km margin and put the edge inside the trajectory.
|
||||
const path = meridionalTrack(52.2, 0.1, 500);
|
||||
const box = computeBoundingBox(path, MARGIN);
|
||||
|
||||
expect(box).not.toBeNull();
|
||||
expect(worstClearanceKm(box!, path)).toBeGreaterThanOrEqual(MARGIN - 0.01);
|
||||
});
|
||||
|
||||
test('every trajectory point clears the filed edges by the full margin near the pole', () => {
|
||||
const box = computeBoundingBox(POLAR_TRACK, MARGIN);
|
||||
|
||||
expect(box).not.toBeNull();
|
||||
expect(worstClearanceKm(box!, POLAR_TRACK)).toBeGreaterThanOrEqual(MARGIN - 0.01);
|
||||
});
|
||||
|
||||
test('a box at the pole covers the corridor, not the whole polar cap', () => {
|
||||
const box = computeBoundingBox(POLAR_TRACK, MARGIN);
|
||||
|
||||
// The lat/lon rectangle gave 44 200 km^2 for this track. The corridor it
|
||||
// actually flies is 13.7 x 123.4 km.
|
||||
expect(areaKm2(box!)).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('the same track in kilometres gives the same box at 52 N and at 89.99 N', () => {
|
||||
// A meridional track is the one shape whose kilometre extent is independent
|
||||
// of latitude, so any difference here is the code treating a pole specially.
|
||||
const mid = computeBoundingBox(meridionalTrack(52.2, 0.1, 120), MARGIN)!;
|
||||
const polar = computeBoundingBox(meridionalTrack(89.99, 0, 120), MARGIN)!;
|
||||
|
||||
expect(polar.heightKm).toBeCloseTo(mid.heightKm, 1);
|
||||
expect(polar.widthKm).toBeCloseTo(mid.widthKm, 1);
|
||||
});
|
||||
|
||||
test('the drawn ring closes and never spans the antimeridian in one segment', () => {
|
||||
// Cesium cuts geometry at the IDL; a single segment straddling it with
|
||||
// degenerate endpoints is what stopped the render loop before. Dense samples
|
||||
// along each edge keep every segment short and the drawn shape equal to the
|
||||
// filed one.
|
||||
const ring = boundingBoxRing(computeBoundingBox(POLAR_TRACK, MARGIN)!);
|
||||
|
||||
expect(ring.length).toBeGreaterThan(16);
|
||||
expect(ring[0]).toEqual(ring[ring.length - 1]);
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
const step = Math.abs(ring[i][1] - ring[i - 1][1]);
|
||||
expect(Math.min(step, 360 - step)).toBeLessThan(90);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue