orbit-studio / src /modules /satelliteGraphics.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
6.12 kB
import { Cartesian3, HeadingPitchRoll, Quaternion, SceneMode, Transforms } from "@cesium/engine";
import { describe, expect, test } from "vitest";
import { SCENE_MODES, SKY_MODE } from "../config/viewModes";
import {
cesiumSceneMode,
coneDescription,
coneOrientation,
groundTrackDescription,
isLeo,
modelUri,
orbitPathTimes,
orbitTrackTimes,
orbitUsesPathGraphic,
GEOMETRY_REFRESH_MAX_SECONDS,
GEOMETRY_REFRESH_MIN_SECONDS,
geometryRefreshSeconds,
} from "./satelliteGraphics";
const ISS_PERIOD_MIN = 92.6;
describe("isLeo", () => {
test("gates on the orbit class itself, so it cannot disagree with the label", () => {
expect(isLeo("LEO")).toBe(true);
expect(isLeo("MEO")).toBe(false);
expect(isLeo("GEO")).toBe(false);
// A highly elliptical orbit can have a short period; a swath corridor under
// one would be meaningless, and the class already rules it out.
expect(isLeo("HEO")).toBe(false);
});
});
describe("orbit path times", () => {
test("path leads and trails half a period plus overlap", () => {
const { leadTime, trailTime } = orbitPathTimes(ISS_PERIOD_MIN);
expect(leadTime).toBeCloseTo((ISS_PERIOD_MIN * 60) / 2 + 5);
expect(trailTime).toBe(leadTime);
});
test("track leads one full period with no trail", () => {
expect(orbitTrackTimes(ISS_PERIOD_MIN)).toEqual({ leadTime: ISS_PERIOD_MIN * 60, trailTime: 0 });
});
});
describe("groundTrackDescription", () => {
test("converts the swath width to meters", () => {
expect(groundTrackDescription("LEO", 290)).toEqual({ widthMeters: 290000 });
});
test("is unavailable for non-LEO satellites", () => {
expect(groundTrackDescription("GEO", 290)).toBeUndefined();
});
});
describe("coneDescription", () => {
test("converts the FOV to a half angle in radians", () => {
const description = coneDescription("LEO", 45);
expect(description).toBeDefined();
expect(description!.radiusMeters).toBe(1000000);
expect(description!.innerHalfAngleRad).toBe(0);
expect(description!.outerHalfAngleRad).toBeCloseTo(Math.PI / 4);
});
test("is unavailable for non-LEO satellites", () => {
expect(coneDescription("GEO", 45)).toBeUndefined();
});
});
describe("coneOrientation", () => {
// Somewhere over Europe at ISS altitude, so the quaternion comes from a position
// a satellite could hold.
const overhead = Cartesian3.fromDegrees(11.5, 48.1, 420000);
test("orients the cone from a known position", () => {
const orientation = coneOrientation(overhead);
expect(orientation).toBeDefined();
// A rotation, not a degenerate quaternion — the visualizer would draw a cone
// pointing anywhere if this came back unnormalized.
expect(Quaternion.magnitude(orientation!)).toBeCloseTo(1);
});
// See `coneOrientation`: the throw runs inside `DataSourceDisplay.update`, so it
// stops Cesium's render loop for the session rather than skipping one cone.
test("declines rather than throwing when there is no position", () => {
expect(() => coneOrientation(undefined)).not.toThrow();
expect(coneOrientation(undefined)).toBeUndefined();
});
// Checked against Cesium, so the guard above rests on a measured throw rather
// than a remembered one. If an upgrade ever makes this tolerate a missing origin,
// this fails and the guard can be reconsidered.
test("Cesium really does throw on the position the guard withholds", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => Transforms.headingPitchRollQuaternion(undefined as any, new HeadingPitchRoll(0, Math.PI, 0))).toThrow(/origin is required/);
});
});
describe("modelUri", () => {
test("prefers the explicit metadata model URL", () => {
expect(modelUri("FOREST-2", "./data/models/custom.glb")).toBe("./data/models/custom.glb");
});
test("falls back to the name-convention path with spaces dashed", () => {
expect(modelUri("ISS (ZARYA)")).toBe("./data/models/ISS-(ZARYA).glb");
});
});
describe("orbitUsesPathGraphic", () => {
test("tracked satellites always use the path graphic", () => {
expect(orbitUsesPathGraphic(true, true)).toBe(true);
expect(orbitUsesPathGraphic(true, false)).toBe(true);
});
test("untracked satellites use the primitive only when the scene supports it", () => {
expect(orbitUsesPathGraphic(false, true)).toBe(false);
expect(orbitUsesPathGraphic(false, false)).toBe(true);
});
});
describe("geometryRefreshSeconds", () => {
test("a handful of tracks refresh at the floor, where the lag would be visible", () => {
expect(geometryRefreshSeconds(0)).toBe(GEOMETRY_REFRESH_MIN_SECONDS);
expect(geometryRefreshSeconds(8)).toBe(GEOMETRY_REFRESH_MIN_SECONDS);
expect(geometryRefreshSeconds(100)).toBe(GEOMETRY_REFRESH_MIN_SECONDS);
});
test("scales with the count between the bounds", () => {
expect(geometryRefreshSeconds(500)).toBe(5);
});
test("clamps at the ceiling, where a rebuild costs more than the lag", () => {
expect(geometryRefreshSeconds(1000)).toBe(GEOMETRY_REFRESH_MAX_SECONDS);
expect(geometryRefreshSeconds(5000)).toBe(GEOMETRY_REFRESH_MAX_SECONDS);
});
});
describe("cesiumSceneMode", () => {
test("maps the three projections and refuses the one that is not", () => {
expect(cesiumSceneMode("3D")).toBe(SceneMode.SCENE3D);
expect(cesiumSceneMode("2D")).toBe(SceneMode.SCENE2D);
expect(cesiumSceneMode("Columbus")).toBe(SceneMode.COLUMBUS_VIEW);
// Sky renders in 3D but is a camera placement, so morphing on its behalf is
// SkyView's business and this must not answer for it.
expect(cesiumSceneMode("Sky")).toBeUndefined();
expect(cesiumSceneMode("nonsense")).toBeUndefined();
});
test("every projection name in the app's vocabulary maps", () => {
// A mode added to SCENE_MODES without a projection here would silently stop
// morphing, so the list is checked rather than restated.
const unmapped = SCENE_MODES.filter((mode) => mode !== SKY_MODE && cesiumSceneMode(mode) === undefined);
expect(unmapped).toEqual([]);
});
});