import { JulianDate } from "@cesium/engine"; import dayjs from "dayjs"; import { describe, expect, test } from "vitest"; import Orbit from "./Orbit"; import { PassPredictor, compassPoint, filterPasses, formatCountdown, passMinutes, passQuality, passSummary, stationPasses, toPassRows, type GroundStation, type Pass, } from "./PassPredictor"; import { parseGpPayload, type GpRecord } from "./util/gp"; import { InlinePassSource, type PassPredictorSource, type PassQuery } from "./util/passSource"; const T0 = Date.UTC(2026, 6, 1, 12, 0, 0); // 2026-07-01T12:00:00Z const NOW = JulianDate.fromDate(new Date(T0)); function elevationPass(startOffsetMs: number, endOffsetMs: number, groundStationName = "Munich"): Pass { return { name: "ISS", start: T0 + startOffsetMs, end: T0 + endOffsetMs, duration: endOffsetMs - startOffsetMs, azimuthStart: 10, azimuthApex: 123.456, azimuthEnd: 200, maxElevation: 56.7, groundStationName, }; } function swathPass(startOffsetMs: number, endOffsetMs: number): Pass { return { name: "ISS", start: T0 + startOffsetMs, end: T0 + endOffsetMs, duration: endOffsetMs - startOffsetMs, minDistance: 12.34, minDistanceTime: T0 + startOffsetMs, swathWidth: 1234.5, groundStationName: "Munich", }; } const MIN = 60 * 1000; const HOUR = 60 * MIN; const DAY = 24 * HOUR; const TLE = "ISS (ZARYA)\n1 25544U 98067A 18342.69352573 .00002284 00000-0 41838-4 0 9992\n2 25544 51.6407 229.0798 0005166 124.8351 329.3296 15.54069892145658"; // Time near the TLE epoch so SGP4 propagation stays meaningful. const EPOCH_TIME = JulianDate.fromDate(dayjs("2018-12-08").toDate()); const MUNICH: GroundStation = { name: "Munich", position: { latitude: 48.177, longitude: 11.7476, height: 0 } }; const VIENNA: GroundStation = { name: "Vienna", position: { latitude: 48.2082, longitude: 16.3738, height: 0 } }; /** * A predictor over an inline source, with every query recorded. * * The queries are what the assertions watch. Spying on the test's own `Orbit` * would prove nothing now that prediction happens behind the source — the source * builds its own from the record, so a spy here would never fire whether the * predictor asked or not. */ function issPredictor(swathKm = 500): { orbit: Orbit; predictor: PassPredictor; queries: PassQuery[] } { const record = parseGpPayload(TLE)[0] as GpRecord; const orbit = new Orbit("ISS", record); const inline = new InlinePassSource(); const bound = inline.predictorFor(orbit.satnum, record); const queries: PassQuery[] = []; const source: PassPredictorSource = { passes: (query) => { queries.push(query); return bound.passes(query); }, }; // A symmetric split of the total, which is what a satellite without per-side // extents of its own gets from SatelliteProperties. const predictor = new PassPredictor(orbit, () => ({ starboardKm: swathKm / 2, portKm: swathKm / 2 }), source); return { orbit, predictor, queries }; } describe("PassPredictor", () => { test("asks for nothing without a ground station", async () => { const { predictor, queries } = issPredictor(); expect(await predictor.ensurePasses(EPOCH_TIME)).toHaveLength(0); expect(queries).toHaveLength(0); }); test("computes passes with the station name attached and intervals in sync", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH]; const passes = await predictor.ensurePasses(EPOCH_TIME); expect(passes.length).toBeGreaterThan(0); expect(passes.every((pass) => pass.groundStationName === "Munich")).toBe(true); // Stamped on this side — see PassPredictor's #apply. expect(passes.every((pass) => pass.name === "ISS")).toBe(true); expect(predictor.passIntervals.length).toBe(passes.length); }); test("a read before the answer lands is empty rather than blocking", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH]; // The whole point of the change: this used to be 8 ms of SGP4 inline. expect(predictor.passes(EPOCH_TIME)).toHaveLength(0); await predictor.ensurePasses(EPOCH_TIME); expect(predictor.passes(EPOCH_TIME).length).toBeGreaterThan(0); }); test("asks again only when time leaves the pass window", async () => { const { predictor, queries } = issPredictor(); predictor.groundStations = [MUNICH]; await predictor.ensurePasses(EPOCH_TIME); expect(queries).toHaveLength(1); // Inside the ±1 day window: no new request. await predictor.ensurePasses(JulianDate.addSeconds(EPOCH_TIME, 3600, new JulianDate())); expect(queries).toHaveLength(1); // A jump beyond the window forces one. await predictor.ensurePasses(JulianDate.addDays(EPOCH_TIME, 2, new JulianDate())); expect(queries).toHaveLength(2); }); test("only one request is outstanding, however many reads arrive", async () => { const { predictor, queries } = issPredictor(); predictor.groundStations = [MUNICH]; const first = predictor.ensurePasses(EPOCH_TIME); predictor.passes(JulianDate.addDays(EPOCH_TIME, 3, new JulianDate())); predictor.passes(JulianDate.addDays(EPOCH_TIME, 5, new JulianDate())); await first; // Three reads, two requests: the one that was already out, then a single // re-ask for the latest time the other two wanted. At a fast clock the reads // outrun the replies, and queueing each would only grow the queue. expect(queries).toHaveLength(2); expect(queries[1]!.startEpochMs).toBeGreaterThan(queries[0]!.startEpochMs); }); test("changing ground stations clears the computed passes", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH]; expect((await predictor.ensurePasses(EPOCH_TIME)).length).toBeGreaterThan(0); predictor.groundStations = []; expect(await predictor.ensurePasses(EPOCH_TIME)).toHaveLength(0); expect(predictor.passIntervals.length).toBe(0); }); test("a reply computed against stations that have since changed is dropped", async () => { const { predictor, queries } = issPredictor(); predictor.groundStations = [MUNICH]; const inFlight = predictor.ensurePasses(EPOCH_TIME); // The station goes away while the request is out. predictor.groundStations = []; await inFlight; expect(queries).toHaveLength(1); expect(predictor.passes(EPOCH_TIME)).toHaveLength(0); expect(predictor.passIntervals.length).toBe(0); }); test("swath mode asks with the per-side extents", async () => { const { predictor, queries } = issPredictor(290); predictor.groundStations = [MUNICH]; predictor.mode = "swath"; const passes = await predictor.ensurePasses(EPOCH_TIME); expect(queries).toHaveLength(1); expect(queries[0]!.mode).toBe("swath"); expect(queries[0]!.swath).toEqual({ starboardKm: 145, portKm: 145 }); expect(passes.every((pass) => "minDistance" in pass)).toBe(true); }); test("setting the same mode keeps the window intact", async () => { const { predictor, queries } = issPredictor(); predictor.groundStations = [MUNICH]; await predictor.ensurePasses(EPOCH_TIME); predictor.mode = "elevation"; await predictor.ensurePasses(EPOCH_TIME); expect(queries).toHaveLength(1); }); test("notifies listeners when a pass list lands", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH]; let notified = 0; const unsubscribe = predictor.onChanged(() => { notified += 1; }); await predictor.ensurePasses(EPOCH_TIME); expect(notified).toBe(1); unsubscribe(); await predictor.ensurePasses(JulianDate.addDays(EPOCH_TIME, 2, new JulianDate())); expect(notified).toBe(1); }); }); describe("stationPasses", () => { test("keeps only the named station's passes, sorted by start", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH, VIENNA]; const all = await predictor.ensurePasses(EPOCH_TIME); expect(all.some((pass) => pass.groundStationName === "Vienna")).toBe(true); const munich = stationPasses([predictor], EPOCH_TIME, "Munich"); expect(munich.length).toBeGreaterThan(0); expect(munich.every((pass) => pass.groundStationName === "Munich")).toBe(true); expect(munich).toEqual(munich.toSorted((a, b) => a.start - b.start)); }); test("drops passes starting beyond the deltaHours horizon", async () => { const { predictor } = issPredictor(); predictor.groundStations = [MUNICH]; await predictor.ensurePasses(EPOCH_TIME); const horizon = stationPasses([predictor], EPOCH_TIME, "Munich", 1); const startLimit = JulianDate.toDate(EPOCH_TIME).getTime() + 2 * HOUR; expect(horizon.every((pass) => pass.start < startLimit)).toBe(true); }); }); describe("filterPasses", () => { test("drops passes that have already ended", () => { const passes = [elevationPass(-2 * HOUR, -1 * HOUR), elevationPass(-5 * MIN, 5 * MIN), elevationPass(1 * HOUR, 2 * HOUR)]; const upcoming = filterPasses(passes, NOW, false); expect(upcoming).toHaveLength(2); expect(upcoming[0]!.start).toBe(T0 - 5 * MIN); }); test("drops an ended pass interleaved after an ongoing one", () => { // Aggregated ground-station lists are sorted by start, so a finished pass // of one satellite can follow the ongoing pass of another. const passes = [elevationPass(-2 * HOUR, 5 * MIN), elevationPass(-90 * MIN, -1 * HOUR), elevationPass(1 * HOUR, 2 * HOUR)]; const upcoming = filterPasses(passes, NOW, false); expect(upcoming).toHaveLength(2); expect(upcoming.every((pass) => pass.end > T0)).toBe(true); }); test("returns empty array when all passes are over", () => { const passes = [elevationPass(-2 * HOUR, -1 * HOUR)]; expect(filterPasses(passes, NOW, false)).toHaveLength(0); }); test("keeps every pass when past passes are shown", () => { const passes = [elevationPass(-2 * HOUR, -1 * HOUR), elevationPass(-5 * MIN, 5 * MIN), elevationPass(1 * HOUR, 2 * HOUR)]; expect(filterPasses(passes, NOW, true)).toHaveLength(3); }); }); describe("formatCountdown", () => { test("ongoing and ended passes are named rather than counted", () => { expect(formatCountdown(T0, elevationPass(-5 * MIN, 5 * MIN))).toBe("ongoing"); expect(formatCountdown(T0, elevationPass(-2 * HOUR, -1 * HOUR))).toBe("ended"); }); test("two units at most, coarsening as the pass gets further away", () => { expect(formatCountdown(T0, elevationPass(42 * 1000, 10 * MIN))).toBe("42 s"); expect(formatCountdown(T0, elevationPass(3 * MIN + 7 * 1000, 10 * MIN))).toBe("3 m 7 s"); expect(formatCountdown(T0, elevationPass(3 * HOUR + 27 * MIN, 4 * HOUR))).toBe("3 h 27 m"); expect(formatCountdown(T0, elevationPass(1 * DAY + 2 * HOUR + 3 * MIN, 2 * DAY))).toBe("1 d 2 h"); }); test("seconds drop out past the first minute, so a distant countdown mostly stops ticking", () => { // The point of the format: thirty rows re-rendering every second was noise. It // still changes once a minute. A second either side of the minute boundary is // the one case that does move, which is why the offset here sits off it. const pass = elevationPass(3 * HOUR + 30 * 1000, 4 * HOUR); expect(formatCountdown(T0, pass)).toBe("3 h 0 m"); expect(formatCountdown(T0 + 1000, pass)).toBe("3 h 0 m"); expect(formatCountdown(T0 + 31 * 1000, pass)).toBe("2 h 59 m"); }); }); describe("passMinutes and passSummary", () => { test("duration is milliseconds, and reads out as whole minutes", () => { expect(passMinutes(elevationPass(0, 8 * MIN + 20 * 1000))).toBe(8); }); test("elevation mode quotes the window, the length and the apex as a compass point", () => { // The fixture's azimuthApex is 123.456, which is ESE. const summary = passSummary(elevationPass(1 * HOUR, 1 * HOUR + 10 * MIN)); expect(summary).toBe("13:00–13:10 UTC · 10 min · 57° max, apex ESE"); }); test("swath mode quotes the offset and the footprint instead", () => { expect(passSummary(swathPass(1 * HOUR, 1 * HOUR + 10 * MIN))).toContain("off track"); expect(passSummary(swathPass(1 * HOUR, 1 * HOUR + 10 * MIN))).toContain("swath"); }); }); describe("passQuality", () => { test("elevation mode is the max elevation over 90 degrees", () => { // The fixture's maxElevation is 56.7. expect(passQuality(elevationPass(0, 10 * MIN))).toBeCloseTo(56.7 / 90, 5); }); test("swath mode is how close to the footprint centre the station falls", () => { // Clamped either way, so a station outside the footprint cannot go negative. expect(passQuality(swathPass(0, 10 * MIN))).toBeGreaterThanOrEqual(0); expect(passQuality(swathPass(0, 10 * MIN))).toBeLessThanOrEqual(1); }); }); describe("compassPoint", () => { test("names the sixteen points, and wraps rather than running off the end", () => { expect(compassPoint(0)).toBe("N"); expect(compassPoint(90)).toBe("E"); expect(compassPoint(180)).toBe("S"); expect(compassPoint(359)).toBe("N"); expect(compassPoint(-90)).toBe("W"); }); }); describe("toPassRows", () => { test("elevation mode uses maxElevation/azimuthApex and the given name field", () => { const [row] = toPassRows([elevationPass(1 * HOUR, 1 * HOUR + 10 * MIN)], NOW, "groundStationName", "elevation"); expect(row!.name).toBe("Munich"); expect(row!.primary).toBe("57°"); expect(row!.secondary).toBe("123.46°"); expect(row!.startLabel).toBe("01.07 13:00:00"); expect(row!.endLabel).toBe("13:10:00"); expect(row!.startMs).toBe(T0 + 1 * HOUR); }); test("swath mode uses minDistance/swathWidth", () => { const [row] = toPassRows([swathPass(1 * HOUR, 1 * HOUR + 10 * MIN)], NOW, "name", "swath"); expect(row!.name).toBe("ISS"); expect(row!.primary).toBe("12.3km"); expect(row!.secondary).toBe("1235km"); }); test("elevation pass in swath mode falls back to elevation columns", () => { const [row] = toPassRows([elevationPass(1 * HOUR, 2 * HOUR)], NOW, "name", "swath"); expect(row!.primary).toBe("57°"); expect(row!.secondary).toBe("123.46°"); }); });