feat(globe): port to CeliumJS

This commit is contained in:
gili8420 2026-08-03 22:12:32 +09:00
parent ec03425067
commit eb7698e034
51 changed files with 3521 additions and 1992 deletions

191
tests/e2e/polar.spec.ts Normal file
View 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 });
});
}