Files
KSP-MissionControl/apps/live-map/tests/scene.test.ts
T
Mavis 9e76ec9328
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Phase 2: 3D live map driven by API WebSocket
- apps/live-map/src/hooks/useLiveState.ts: WebSocket subscription
  with exponential-backoff reconnect, polling fallback, status tracking
- apps/live-map/src/scene/Scene.tsx: refactored Three.js scene with
  per-frame orbit propagation, vessel marker color-coding by owner,
  orbit-line visibility tied to focus filters, smooth camera follow
  on selected vessel/body
- apps/live-map/src/scene/layout.ts: bodyPositionAt / vesselPositionAt
  helpers (heliocentric frame, walk up the parent chain), logScale
  helpers for the system view
- apps/live-map/src/scene/color.ts: per-body and per-owner color maps
- apps/live-map/src/panels/TimeControls.tsx: play/pause/reverse/reset
  buttons, ×1/×10/×100/×1k/×10k/×100k speeds, UT scrub slider,
  live-edge indicator (LIVE / Nh behind / Nh ahead)
- apps/live-map/src/panels/VesselList.tsx: vessel sidebar with click-
  to-track; color-coded by owner (KASA=blue, SPES=orange)
- apps/live-map/src/panels/FocusPanel.tsx: planet/moon/vessel orbit
  visibility toggles
- apps/live-map/src/panels/StatusPill.tsx: WS status (LIVE/POLLING/
  OFFLINE/STALE), body + vessel + message counts
- tests/scene.test.ts: 10 tests for layout helpers (periodicity,
  vessel-centered positioning, logScale round-trips)

End-to-end verified: mock publisher → API → live-map WebSocket →
scene re-renders with the new vessel positions and orbits.
2026-06-02 19:18:22 +00:00

167 lines
4.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
bodyPositionAt,
vesselPositionAt,
findBodyMu,
logScale,
inverseLogScale,
} from '../src/scene/layout.js';
import type { CelestialBody, Vessel } from '@kerbal-rt/shared-types';
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,
},
};
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, // circular for predictable test math
inclination: 0,
longitudeOfAscendingNode: 0,
argumentOfPeriapsis: 0,
meanAnomalyAtEpoch: 0,
epoch: 0,
},
};
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,
epoch: 0,
},
};
const bodies: CelestialBody[] = [kerbol, kerbin, mun];
describe('bodyPositionAt', () => {
it('returns origin for the star', () => {
const p = bodyPositionAt(bodies, 'kerbol', 100);
expect(p.x).toBe(0);
expect(p.y).toBe(0);
expect(p.z).toBe(0);
});
it('returns a non-zero position for a planet at t > 0', () => {
const p = bodyPositionAt(bodies, 'kerbin', 1_000_000);
const dist = Math.hypot(p.x, p.y, p.z);
// SMA is 13.6 billion meters, so position is on that order
expect(dist).toBeGreaterThan(1e10);
expect(dist).toBeLessThan(2e10);
});
it('returns origin for a body not in the catalog', () => {
const p = bodyPositionAt(bodies, 'unknown', 1);
expect(p).toEqual({ x: 0, y: 0, z: 0 });
});
it('is periodic (returns near the same position after one full orbit)', () => {
// Kerbin orbits Kerbol, so the period is computed with kerbol's μ
const p1 = bodyPositionAt(bodies, 'kerbin', 0);
const period =
(2 * Math.PI) / Math.sqrt(kerbol.gravitationalParameter / Math.pow(13_599_840_256, 3));
const p2 = bodyPositionAt(bodies, 'kerbin', period);
expect(p1.x).toBeCloseTo(p2.x, -2);
expect(p1.y).toBeCloseTo(p2.y, -2);
});
});
describe('vesselPositionAt', () => {
const vessel: Vessel = {
id: 'v1',
name: 'Test',
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,
};
it('returns the kerbin-centered position when reference body is kerbin', () => {
// At t=0, vessel is at (7e6, 0, 0) in kerbin frame
const p = vesselPositionAt(bodies, vessel, 0);
// kerbin is at (sma, 0, 0) = (13.6e9, 0, 0)
// so vessel should be at roughly (13.6e9 + 7e6, 0, 0)
expect(p.x).toBeCloseTo(13_599_840_256 + 7_000_000, -2);
expect(p.y).toBeCloseTo(0, 2);
});
});
describe('findBodyMu', () => {
it("returns the body's own gravitational parameter", () => {
expect(findBodyMu(bodies, 'kerbin')).toBe(kerbin.gravitationalParameter);
});
it('returns 0 for null id', () => {
expect(findBodyMu(bodies, null)).toBe(0);
});
it('returns 0 for unknown id', () => {
expect(findBodyMu(bodies, 'nope')).toBe(0);
});
});
describe('logScale', () => {
it('is the inverse of inverseLogScale', () => {
for (const t of [0, 4, 8, 12]) {
const d = logScale(t);
expect(inverseLogScale(d)).toBeCloseTo(t, 6);
}
});
it('produces reasonable distances for camera placement', () => {
// t=4 → ~5.5e9 m (good for showing Kerbin at 13.6e9)
expect(logScale(4)).toBeGreaterThan(1e9);
expect(logScale(4)).toBeLessThan(1e10);
// t=10 → ~22e12 m (good for showing all planets)
expect(logScale(10)).toBeGreaterThan(1e12);
});
});