orbit-studio / src /modules /util /PolylineBatch.test.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
7.19 kB
// The shared orbit primitive's bookkeeping.
//
// Real GeometryInstance objects — a plain Cesium class that constructs fine in
// the node env. What is faked is the viewer: a clock whose ticks this test drives
// by hand, which is the only way to observe the coalescing window and the
// settled() promise deterministically.
//
// What is not covered is the build itself. Driving a Primitive through its
// creation states calls into Cesium's renderer, which needs a real WebGL context,
// so the cases here stop at the point a build starts and pick up again on the
// paths that never reach one. That still leaves the scheduling, the coalescing,
// the empty-batch teardown and settled().
import { ArcType, Cartesian3, Color, ColorGeometryInstanceAttribute, GeometryInstance, JulianDate, PolylineColorAppearance, PolylineGeometry, SceneMode } from "@cesium/engine";
import type { Viewer } from "@cesium/widgets";
import { describe, expect, test } from "vitest";
import { PolylineBatch } from "./PolylineBatch";
function orbitGeometry(id: string): GeometryInstance {
return new GeometryInstance({
geometry: new PolylineGeometry({
positions: Cartesian3.fromDegreesArray([0, 0, 10, 10]),
width: 2,
arcType: ArcType.NONE,
vertexFormat: PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: { color: ColorGeometryInstanceAttribute.fromColor(new Color(1, 1, 1, 0.15)) },
id,
});
}
/** A viewer with a hand-cranked clock and a primitive collection that records. */
function fakeViewer() {
const listeners = new Set<() => void>();
const primitives = {
added: [] as unknown[],
removed: [] as unknown[],
add(primitive: unknown) {
this.added.push(primitive);
},
remove(primitive: unknown) {
this.removed.push(primitive);
return true;
},
};
let renders = 0;
const viewer = {
clock: {
currentTime: JulianDate.fromIso8601("2026-01-01T00:00:00Z"),
onTick: {
addEventListener(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
scene: {
mode: SceneMode.SCENE3D,
primitives,
frameState: {},
requestRender() {
renders += 1;
},
},
};
return {
viewer: viewer as unknown as Viewer,
primitives,
get renders() {
return renders;
},
get listenerCount() {
return listeners.size;
},
tick(times = 1) {
for (let i = 0; i < times; i += 1) {
[...listeners].forEach((listener) => listener());
}
},
};
}
// Mirrors PolylineBatch's own window; the callback fires on the tick after it.
const COALESCE_TICKS = 30;
const PAST_WINDOW = COALESCE_TICKS + 2;
describe("PolylineBatch", () => {
test("nothing added, nothing pending, settled resolves at once", async () => {
const { viewer } = fakeViewer();
const batch = new PolylineBatch(viewer);
expect(batch.pending).toBe(false);
await expect(batch.settled()).resolves.toBeUndefined();
});
test("an add is pending, and nothing reaches the scene before the window closes", () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer);
batch.add(orbitGeometry("ISS"));
expect(batch.pending).toBe(true);
// Ticking short of the window must not start the build.
host.tick(COALESCE_TICKS - 1);
expect(host.primitives.added).toHaveLength(0);
expect(batch.pending).toBe(true);
});
test("many adds in one window cost one rebuild", () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer);
const before = host.listenerCount;
for (let i = 0; i < 50; i += 1) {
batch.add(orbitGeometry(`SAT-${i}`));
}
// One scheduled rebuild, not fifty: the second add sees `#scheduled` and
// returns rather than registering another tick callback.
expect(host.listenerCount).toBe(before + 1);
});
test("removing the last geometry clears the batch and settles", async () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer);
const geometry = orbitGeometry("ISS");
batch.add(geometry);
batch.remove(geometry);
expect(batch.pending).toBe(true);
const settled = batch.settled();
host.tick(PAST_WINDOW);
await expect(settled).resolves.toBeUndefined();
expect(batch.pending).toBe(false);
});
test("the empty rebuild stops its own tick callback", () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer);
// The permanent inertial-frame updater registered by the constructor.
const permanent = host.listenerCount;
const geometry = orbitGeometry("ISS");
batch.add(geometry);
batch.remove(geometry);
host.tick(PAST_WINDOW);
// The rebuild callback used to `return` out of the empty branch without
// removing itself, leaving a listener requesting a render every 30 ticks for
// the rest of the session — and another one on every subsequent empty pass.
expect(host.listenerCount).toBe(permanent);
});
test("settled() called when idle does not wait for a tick", async () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer);
const geometry = orbitGeometry("ISS");
batch.add(geometry);
batch.remove(geometry);
host.tick(PAST_WINDOW);
await batch.settled();
// Resolves without anything having to tick again.
await expect(batch.settled()).resolves.toBeUndefined();
});
test("replace swaps a member's geometry in place and schedules one rebuild", () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer, "fixed");
const first = orbitGeometry("ISS");
const second = orbitGeometry("ISS");
batch.add(first);
expect(batch.replace(first, second)).toBe(true);
// A swap is not a membership change: the batch is still one geometry long.
expect(batch.size).toBe(1);
expect(batch.pending).toBe(true);
});
test("replace refuses a geometry that is no longer a member", () => {
const host = fakeViewer();
const batch = new PolylineBatch(host.viewer, "fixed");
const stale = orbitGeometry("ISS");
// The satellite disabled its track between the refresh being scheduled and
// it running: the caller has to be told, or the batch grows a line for a
// component nobody is drawing.
expect(batch.replace(stale, orbitGeometry("ISS"))).toBe(false);
expect(batch.size).toBe(0);
});
test("only the inertial batch installs a periodic re-orientation", () => {
const host = fakeViewer();
const before = host.listenerCount;
// The inertial batch keeps a permanent listener to spin its model matrix.
const inertial = new PolylineBatch(host.viewer, "inertial");
expect(inertial.size).toBe(0);
expect(host.listenerCount).toBe(before + 1);
// An Earth-relative one is already in the frame it is drawn in, so there is
// no matrix for a listener to maintain.
const fixed = new PolylineBatch(host.viewer, "fixed");
expect(fixed.size).toBe(0);
expect(host.listenerCount).toBe(before + 1);
});
});