Spaces:
Configuration error
Configuration error
| import { describe, expect, test } from "vitest"; | |
| import { createSatrec, orbitClassOf, parseGpPayload, recordName, recordSatnum, recordTleLines, type GpRecord } from "./gp"; | |
| const OMM_ARRAY = JSON.stringify([ | |
| { | |
| OBJECT_NAME: "ISS (ZARYA)", | |
| OBJECT_ID: "1998-067A", | |
| EPOCH: "2026-07-04T02:07:57.020160", | |
| MEAN_MOTION: 15.48879284, | |
| ECCENTRICITY: 0.00067632, | |
| INCLINATION: 51.6303, | |
| RA_OF_ASC_NODE: 216.4301, | |
| ARG_OF_PERICENTER: 253.0749, | |
| MEAN_ANOMALY: 106.9498, | |
| NORAD_CAT_ID: 25544, | |
| ELEMENT_SET_NO: 999, | |
| BSTAR: 0.00014587488, | |
| MEAN_MOTION_DOT: 7.564e-5, | |
| MEAN_MOTION_DDOT: 0, | |
| }, | |
| ]); | |
| const WORKER_TLE_ARRAY = JSON.stringify([ | |
| { | |
| OBJECT_NAME: "PSEUDO-SAT", | |
| TLE_LINE1: "1 90001U 26001A 26185.00000000 .00000000 00000-0 00000-0 0 9990", | |
| TLE_LINE2: "2 90001 51.6000 000.0000 0000000 000.0000 000.0000 15.50000000000000", | |
| }, | |
| // Missing OBJECT_NAME — name should fall back to the line-1 satnum. | |
| { | |
| TLE_LINE1: "1 90002U 26002A 26185.00000000 .00000000 00000-0 00000-0 0 9991", | |
| TLE_LINE2: "2 90002 51.6000 000.0000 0000000 000.0000 000.0000 15.50000000000000", | |
| }, | |
| ]); | |
| const TLE_3LINE = [ | |
| "ISS (ZARYA)", | |
| "1 25544U 98067A 26185.08885440 .00007564 00000+0 14587-3 0 9998", | |
| "2 25544 51.6303 216.4301 0006763 253.0749 106.9498 15.48879284574378", | |
| ].join("\n"); | |
| const TLE_3LINE_PREFIXED = [ | |
| "0 ISS (ZARYA)", | |
| "1 25544U 98067A 26185.08885440 .00007564 00000+0 14587-3 0 9998", | |
| "2 25544 51.6303 216.4301 0006763 253.0749 106.9498 15.48879284574378", | |
| ].join("\n"); | |
| const TLE_2LINE = ["1 25544U 98067A 26185.08885440 .00007564 00000+0 14587-3 0 9998", "2 25544 51.6303 216.4301 0006763 253.0749 106.9498 15.48879284574378"].join("\n"); | |
| describe("parseGpPayload", () => { | |
| test("parses an OMM JSON array", () => { | |
| const records = parseGpPayload(OMM_ARRAY); | |
| expect(records).toHaveLength(1); | |
| expect(records[0]?.kind).toBe("omm"); | |
| expect(recordName(records[0] as GpRecord)).toBe("ISS (ZARYA)"); | |
| expect(recordSatnum(records[0] as GpRecord)).toBe("25544"); | |
| }); | |
| test("parses a worker TleRecord array, with satnum name fallback", () => { | |
| const records = parseGpPayload(WORKER_TLE_ARRAY); | |
| expect(records).toHaveLength(2); | |
| expect(records[0]?.kind).toBe("tle"); | |
| expect(recordName(records[0] as GpRecord)).toBe("PSEUDO-SAT"); | |
| // Missing OBJECT_NAME → name derived from line-1 satnum. | |
| expect(recordName(records[1] as GpRecord)).toBe("90002"); | |
| }); | |
| test("parses 3-line TLE text", () => { | |
| const records = parseGpPayload(TLE_3LINE); | |
| expect(records).toHaveLength(1); | |
| expect(recordName(records[0] as GpRecord)).toBe("ISS (ZARYA)"); | |
| expect(recordSatnum(records[0] as GpRecord)).toBe("25544"); | |
| }); | |
| test("strips a leading '0 ' name prefix", () => { | |
| const records = parseGpPayload(TLE_3LINE_PREFIXED); | |
| expect(records).toHaveLength(1); | |
| expect(recordName(records[0] as GpRecord)).toBe("ISS (ZARYA)"); | |
| }); | |
| test("parses bare 2-line TLE blocks (no name line)", () => { | |
| const records = parseGpPayload(TLE_2LINE); | |
| expect(records).toHaveLength(1); | |
| expect(records[0]?.kind).toBe("tle"); | |
| // Name defaults to the satnum when no name line is present. | |
| expect(recordName(records[0] as GpRecord)).toBe("25544"); | |
| expect(recordSatnum(records[0] as GpRecord)).toBe("25544"); | |
| }); | |
| test("skips malformed blocks without throwing", () => { | |
| const garbage = ["garbage line one", "another junk line", TLE_3LINE, "trailing junk"].join("\n"); | |
| const records = parseGpPayload(garbage); | |
| // The one valid 3-line block should survive. | |
| expect(records).toHaveLength(1); | |
| expect(recordSatnum(records[0] as GpRecord)).toBe("25544"); | |
| }); | |
| test("returns [] on invalid JSON without throwing", () => { | |
| expect(parseGpPayload("[not valid json")).toEqual([]); | |
| }); | |
| }); | |
| describe("recordSatnum normalization", () => { | |
| test("String(number) for OMM numeric ids", () => { | |
| const records = parseGpPayload(OMM_ARRAY); | |
| expect(recordSatnum(records[0] as GpRecord)).toBe("25544"); | |
| }); | |
| test("strips leading zeros for all-digit satnums", () => { | |
| const omm: GpRecord = { | |
| kind: "omm", | |
| omm: { | |
| OBJECT_NAME: "X", | |
| OBJECT_ID: "", | |
| EPOCH: "", | |
| MEAN_MOTION: 1, | |
| ECCENTRICITY: 0, | |
| INCLINATION: 0, | |
| RA_OF_ASC_NODE: 0, | |
| ARG_OF_PERICENTER: 0, | |
| MEAN_ANOMALY: 0, | |
| NORAD_CAT_ID: "00005", | |
| ELEMENT_SET_NO: 0, | |
| BSTAR: 0, | |
| MEAN_MOTION_DOT: 0, | |
| MEAN_MOTION_DDOT: 0, | |
| }, | |
| }; | |
| expect(recordSatnum(omm)).toBe("5"); | |
| }); | |
| test("keeps alpha-5 designators untouched", () => { | |
| const omm: GpRecord = { | |
| kind: "omm", | |
| omm: { | |
| OBJECT_NAME: "X", | |
| OBJECT_ID: "", | |
| EPOCH: "", | |
| MEAN_MOTION: 1, | |
| ECCENTRICITY: 0, | |
| INCLINATION: 0, | |
| RA_OF_ASC_NODE: 0, | |
| ARG_OF_PERICENTER: 0, | |
| MEAN_ANOMALY: 0, | |
| NORAD_CAT_ID: "E8493", | |
| ELEMENT_SET_NO: 0, | |
| BSTAR: 0, | |
| MEAN_MOTION_DOT: 0, | |
| MEAN_MOTION_DDOT: 0, | |
| }, | |
| }; | |
| expect(recordSatnum(omm)).toBe("E8493"); | |
| }); | |
| }); | |
| describe("createSatrec", () => { | |
| test("builds a satrec from an OMM record", () => { | |
| const records = parseGpPayload(OMM_ARRAY); | |
| const satrec = createSatrec(records[0] as GpRecord); | |
| expect(satrec.error).toBe(0); | |
| expect(String(satrec.satnum)).toBe("25544"); | |
| }); | |
| test("builds a satrec from a TLE record", () => { | |
| const records = parseGpPayload(TLE_3LINE); | |
| const satrec = createSatrec(records[0] as GpRecord); | |
| expect(satrec.error).toBe(0); | |
| expect(String(satrec.satnum)).toBe("25544"); | |
| }); | |
| }); | |
| describe("recordTleLines", () => { | |
| test("returns 3 lines for a TLE record and undefined for OMM", () => { | |
| const tle = parseGpPayload(TLE_3LINE)[0] as GpRecord; | |
| const omm = parseGpPayload(OMM_ARRAY)[0] as GpRecord; | |
| expect(recordTleLines(tle)).toHaveLength(3); | |
| expect(recordTleLines(omm)).toBeUndefined(); | |
| }); | |
| }); | |
| describe("metadata lifting", () => { | |
| const OMM_ISS = JSON.parse(OMM_ARRAY)[0] as Record<string, unknown>; | |
| const [TLE_LINE1, TLE_LINE2] = TLE_2LINE.split("\n"); | |
| test("lifts an OMM record's metadata off the element set", () => { | |
| const payload = JSON.stringify([{ ...OMM_ISS, metadata: { swathStarboardKm: 1000, swathPortKm: 500 } }]); | |
| const record = parseGpPayload(payload)[0]!; | |
| expect(record.metadata).toEqual({ swathStarboardKm: 1000, swathPortKm: 500, orbitClass: "LEO" }); | |
| // The bag must not remain in `omm`: that object is handed to json2satrec and | |
| // rendered as the satellite's element set in the info panel. | |
| expect(record.kind).toBe("omm"); | |
| expect(record.kind === "omm" && "metadata" in record.omm).toBe(false); | |
| }); | |
| test("lifts a worker TLE record's metadata too", () => { | |
| const payload = JSON.stringify([ | |
| { | |
| OBJECT_NAME: "ISS", | |
| TLE_LINE1: TLE_LINE1, | |
| TLE_LINE2: TLE_LINE2, | |
| metadata: { coneFovDeg: 45 }, | |
| }, | |
| ]); | |
| const record = parseGpPayload(payload)[0]!; | |
| expect(record.kind).toBe("tle"); | |
| expect(record.metadata).toEqual({ coneFovDeg: 45, orbitClass: "LEO" }); | |
| }); | |
| test("carries nothing but the derived class on an unenriched record", () => { | |
| // The bag always exists after parsing, because the class is derived for every | |
| // record. Which OTHER keys are present is still what says the satellite table | |
| // had something to say about it. | |
| const record = parseGpPayload(JSON.stringify([OMM_ISS]))[0]!; | |
| expect(record.metadata).toEqual({ orbitClass: "LEO" }); | |
| }); | |
| test("ignores a metadata value that is not an object", () => { | |
| for (const bad of ["nope", 42, [1, 2], null]) { | |
| const record = parseGpPayload(JSON.stringify([{ ...OMM_ISS, metadata: bad }]))[0]!; | |
| expect(record.metadata, JSON.stringify(bad)).toEqual({ orbitClass: "LEO" }); | |
| } | |
| }); | |
| test("does not lift metadata out of legacy TLE text (no place to carry it)", () => { | |
| const record = parseGpPayload(`ISS\n${TLE_LINE1}\n${TLE_LINE2}`)[0]!; | |
| expect(record.metadata).toEqual({ orbitClass: "LEO" }); | |
| }); | |
| }); | |
| describe("orbitClassOf", () => { | |
| // Mean motion (rev/day) and eccentricity are the only fields the classification | |
| // reads; these element sets differ in nothing else. | |
| function ommWithElements(meanMotion: number, eccentricity = 0.0001): GpRecord { | |
| const omm = JSON.stringify([{ ...(JSON.parse(OMM_ARRAY)[0] as Record<string, unknown>), MEAN_MOTION: meanMotion, ECCENTRICITY: eccentricity }]); | |
| return parseGpPayload(omm)[0]!; | |
| } | |
| test("classifies the regimes from mean motion", () => { | |
| expect(orbitClassOf(ommWithElements(15.5))).toBe("LEO"); // ISS-like, ~93 min | |
| expect(orbitClassOf(ommWithElements(2.0))).toBe("MEO"); // GPS-like, ~720 min | |
| expect(orbitClassOf(ommWithElements(1.0027))).toBe("GEO"); // geosynchronous, ~1436 min | |
| }); | |
| test("eccentricity wins over period — a Molniya orbit is HEO, not MEO", () => { | |
| // ~12 h period like a MEO satellite, but e=0.72 puts it nowhere near one. | |
| expect(orbitClassOf(ommWithElements(2.0, 0.72))).toBe("HEO"); | |
| }); | |
| test("reads the same elements out of a TLE, off the fixed columns of line 2", () => { | |
| // Same satellite as OMM_ARRAY, so the two arms must agree. | |
| expect(orbitClassOf(parseGpPayload(TLE_3LINE)[0]!)).toBe("LEO"); | |
| // Molniya 1-91: e=0.7168, mean motion 2.00612 rev/day — the TLE arm has to | |
| // supply the implied leading decimal point on eccentricity to get this right. | |
| const molniya = [ | |
| "MOLNIYA 1-91", | |
| "1 25485U 98054A 26185.00000000 .00000000 00000-0 00000-0 0 9990", | |
| "2 25485 63.4000 000.0000 7168000 270.0000 000.0000 2.00612000000000", | |
| ].join("\n"); | |
| expect(orbitClassOf(parseGpPayload(molniya)[0]!)).toBe("HEO"); | |
| }); | |
| test("covers every satellite, not only those in the satellite table", () => { | |
| // The point of deriving rather than configuring: an arbitrary record classifies. | |
| expect(orbitClassOf(parseGpPayload(WORKER_TLE_ARRAY)[0]!)).toBe("LEO"); | |
| }); | |
| }); | |