import { describe, it, expect } from 'vitest'; import { findEclipseWindows, computeShadowFraction } from '../src/calculators/eclipse.js'; import { findOverpasses, type Target } from '../src/calculators/overpass.js'; import type { CelestialBody, GroundStation, Vessel } from '@kerbal-rt/shared-types'; const KSP_DAY = 6 * 3600; // ─── Minimal solar system: Kerbol + Kerbin + Mun (a simple eclipse scenario) const kerbol: CelestialBody = { id: 'kerbol', name: 'Kerbol', kind: 'star', parentId: null, radius: 261_600_000, sphereOfInfluence: 1e30, gravitationalParameter: 1.172e18, rotationPeriod: 432_000, axialTilt: 0, orbit: { semiMajorAxis: 0, eccentricity: 0, inclination: 0, longitudeOfAscendingNode: 0, argumentOfPeriapsis: 0, meanAnomalyAtEpoch: 0, epoch: 0, }, }; // Kerbin in a circular orbit at 13.6e9 m const kerbin: CelestialBody = { id: 'kerbin', name: 'Kerbin', kind: 'planet', parentId: 'kerbol', radius: 600_000, sphereOfInfluence: 84_159_286, gravitationalParameter: 3.5316e12, rotationPeriod: 21_600, axialTilt: 0, orbit: { semiMajorAxis: 13_599_840_256, eccentricity: 0, inclination: 0, longitudeOfAscendingNode: 0, argumentOfPeriapsis: 0, meanAnomalyAtEpoch: 0, epoch: 0, }, }; // Mun in a circular orbit around Kerbin at 12e6 m const mun: CelestialBody = { id: 'mun', name: 'Mun', kind: 'moon', parentId: 'kerbin', radius: 200_000, sphereOfInfluence: 2_429_559, gravitationalParameter: 6.514e10, rotationPeriod: 138_984, axialTilt: 0, orbit: { semiMajorAxis: 12_000_000, eccentricity: 0, inclination: 0, longitudeOfAscendingNode: 0, argumentOfPeriapsis: 0, meanAnomalyAtEpoch: 0, // start at (12e6, 0, 0) in kerbin frame epoch: 0, }, }; const systemBodies = [kerbol, kerbin, mun]; describe('computeShadowFraction', () => { it('returns 0 when eclipser is on the far side of the observer from the sun', () => { // t=0, Mun meanAnomalyAtEpoch=0 → Mun at (+12e6, 0, 0) in kerbin frame // → Mun world position (13.6e9 + 12e6, 0, 0) — BEHIND Kerbin from the sun. // Sun is at (-x) from Kerbin; Mun is at (+x). No eclipse. const munBehind: CelestialBody = { ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: 0 }, }; const sys = [kerbol, kerbin, munBehind]; const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); expect(f).toBe(0); }); it('returns high fraction when occluder sits between observer and sun', () => { // Set up Mun directly between Kerbin and Kerbol (anti-aligned). // Mun's meanAnomalyAtEpoch = π → Mun at (-12e6, 0, 0) in kerbin frame, // i.e. world position (13.6e9 - 12e6, 0, 0). Sun at (0,0,0). // Kerbin is at (13.6e9, 0, 0). So Mun is between them. const munAntialigned: CelestialBody = { ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI }, }; const sys = [kerbol, kerbin, munAntialigned]; const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); // Should be ≥ 0.5 (Mun is ~0.6 Mm radius, observer is 12 Mm from it; // angular size is small but the center of Mun is exactly on the // sun-line so the umbra is total) expect(f).toBeGreaterThan(0.5); }); it('returns 0 for self-eclipse (observer == eclipser)', () => { // findEclipseWindows early-returns on this, but computeShadowFraction // would compute a 1.0 trivially. Either is fine; just verify the API // returns a number. const f = computeShadowFraction(systemBodies, 'kerbin', 'kerbin', 0); expect(typeof f).toBe('number'); }); it('returns 1 when occluder is exactly on the sun-line (centered eclipse)', () => { // Mun anti-aligned → directly between Kerbin and Kerbol at t=0 const munAntialigned: CelestialBody = { ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI }, }; const sys = [kerbol, kerbin, munAntialigned]; const f = computeShadowFraction(sys, 'kerbin', 'mun', 0); expect(f).toBeGreaterThan(0.99); }); it('returns a value in [0, 1]', () => { const f = computeShadowFraction(systemBodies, 'kerbin', 'mun', 0); expect(f).toBeGreaterThanOrEqual(0); expect(f).toBeLessThanOrEqual(1); }); }); describe('findEclipseWindows', () => { it('returns empty array for self-eclipse', () => { const w = findEclipseWindows(systemBodies, { observerId: 'kerbin', eclipserId: 'kerbin', startUt: 0, }); expect(w).toEqual([]); }); it('returns empty array for unknown bodies', () => { const w = findEclipseWindows(systemBodies, { observerId: 'kerbin', eclipserId: 'unknown', startUt: 0, }); expect(w).toEqual([]); }); it('finds an eclipse window when Mun passes between Kerbin and Kerbol', () => { // Set up a system where Mun is currently in front of the sun from Kerbin's // perspective. The Mun orbits Kerbin in ~6.8 days, so we should find // an eclipse within a few days of t=0. const sys = [ kerbol, kerbin, { ...mun, orbit: { ...mun.orbit, meanAnomalyAtEpoch: Math.PI } }, // start anti-aligned ]; const windows = findEclipseWindows(sys, { observerId: 'kerbin', eclipserId: 'mun', startUt: 0, count: 1, stepSec: 300, // 5 KSP minutes for the coarse scan }); expect(windows.length).toBeGreaterThan(0); if (windows[0]) { expect(windows[0].utStart).toBeGreaterThanOrEqual(0); expect(windows[0].utEnd).toBeGreaterThan(windows[0].utStart); expect(windows[0].utPeak).toBeGreaterThanOrEqual(windows[0].utStart); expect(windows[0].utPeak).toBeLessThanOrEqual(windows[0].utEnd); expect(windows[0].maxFraction).toBeGreaterThan(0); } }); }); // ─── Overpass tests ─────────────────────────────────────────────────────── const vesselA: Vessel = { id: 'v-a', name: 'Vessel A', type: 'Probe', owner: 'KASA', situation: 'ORBITING', status: 'ACTIVE', orbit: { semiMajorAxis: 7_000_000, eccentricity: 0, inclination: 0, longitudeOfAscendingNode: 0, argumentOfPeriapsis: 0, meanAnomalyAtEpoch: 0, epoch: 0, }, referenceBodyId: 'kerbin', createdAt: '2026-01-01T00:00:00Z', retiredAt: null, }; const vesselB: Vessel = { id: 'v-b', name: 'Vessel B', type: 'Probe', owner: 'SPES', situation: 'ORBITING', status: 'ACTIVE', orbit: { semiMajorAxis: 7_000_000, eccentricity: 0, inclination: 0, longitudeOfAscendingNode: 0, argumentOfPeriapsis: 0, meanAnomalyAtEpoch: Math.PI, // opposite side epoch: 0, }, referenceBodyId: 'kerbin', createdAt: '2026-01-01T00:00:00Z', retiredAt: null, }; const station: GroundStation = { id: 'montana', name: 'Montana DSN', bodyId: 'kerbin', lat: 47.0, lon: -110.0, alt: 1200, }; describe('findOverpasses', () => { it('finds a close approach between two vessels in different orbits', () => { // Two vessels in different circular orbits around Kerbin. They will // occasionally align and approach each other. Use a small step to // catch the close approach. const vesselBDifferent: Vessel = { ...vesselB, orbit: { ...vesselB.orbit, semiMajorAxis: 7_500_000 }, // different SMA }; const passes = findOverpasses({ observer: vesselA, target: { kind: 'vessel', id: 'v-b', name: 'Vessel B' }, bodies: systemBodies, vessels: [vesselA, vesselBDifferent], groundStations: [], startUt: 0, count: 1, stepSec: 60, // 1 min coarse scan distanceThreshold: 5_000_000, // 5000 km maxSearchTime: 426 * 6 * 3600, // 1 KSP year }); expect(passes.length).toBeGreaterThan(0); if (passes[0]) { expect(passes[0].minDistance).toBeLessThan(5_000_000); expect(passes[0].utPeak).toBeGreaterThan(0); } }); it('handles body target (observer passes near a body)', () => { // Vessel A is in LKO around Kerbin, so distance to Mun varies a lot. // We should find at least one "close" approach (within 100 Mm). const passes = findOverpasses({ observer: vesselA, target: { kind: 'body', id: 'mun', name: 'Mun' }, bodies: systemBodies, vessels: [vesselA], groundStations: [], startUt: 0, count: 1, stepSec: 3600, distanceThreshold: 100_000_000, // 100 Mm }); // Just verify the API works; we can't easily assert on the value expect(Array.isArray(passes)).toBe(true); }); it('handles ground station target', () => { const passes = findOverpasses({ observer: vesselA, target: { kind: 'station', id: 'montana', name: 'Montana' }, bodies: systemBodies, vessels: [vesselA], groundStations: [station], startUt: 0, count: 1, stepSec: 60, distanceThreshold: 50_000_000, // 50 Mm }); expect(Array.isArray(passes)).toBe(true); }); it('returns empty when target is unknown', () => { const target: Target = { kind: 'vessel', id: 'unknown', name: '?' }; const passes = findOverpasses({ observer: vesselA, target, bodies: systemBodies, vessels: [vesselA], groundStations: [], startUt: 0, }); expect(passes).toEqual([]); }); });