import { describe, expect, it } from "vitest"; import { SYNODIC_PERIOD_DAYS, currentWindow, elongation, mercuryLongitude, retrogradeWindows, } from "./retrograde"; /** * These tests are the reason the site is allowed to publish retrograde dates at * all. The dates are computed, not copied, so the arithmetic has to be checked * against facts that are independently known — the synodic period, the number * of retrogrades a year, how long one lasts. If the orbital mechanics were * wrong, none of these would come out right. */ const DAY = 86_400_000; const windows = retrogradeWindows(new Date("2024-01-01T00:00:00Z"), new Date("2031-01-01T00:00:00Z")); const midpoint = (w: { start: Date; end: Date }) => (w.start.getTime() + w.end.getTime()) / 2; describe("mercuryLongitude", () => { it("stays within a valid longitude range", () => { for (let i = 0; i < 400; i++) { const lon = mercuryLongitude(new Date(Date.UTC(2026, 0, 1) + i * 3 * DAY)); expect(lon).toBeGreaterThanOrEqual(0); expect(lon).toBeLessThan(360); } }); it("circles the ecliptic once a year, not three times", () => { // Worth stating because it is the opposite of the obvious guess. Mercury // orbits the Sun in 88 days, but seen from Earth it never leaves the Sun's // neighbourhood, so its geocentric longitude tracks the Sun — one circuit a // year, with the retrograde wiggles superimposed. An earlier version of // this test asserted three circuits and was simply wrong about the sky. let previous = mercuryLongitude(new Date("2026-01-01T00:00:00Z")); let travelled = 0; for (let i = 1; i <= 365; i++) { const lon = mercuryLongitude(new Date(Date.UTC(2026, 0, 1) + i * DAY)); let d = lon - previous; if (d > 180) d -= 360; if (d < -180) d += 360; travelled += d; previous = lon; } expect(travelled / 360).toBeCloseTo(1, 1); }); it("never strays further from the Sun than Mercury can go", () => { // Maximum elongation is about 28°, set by the ratio of the orbits. If the // geometry were wrong — wrong plane, wrong origin, wrong sign — this bound // would be violated somewhere in a decade of samples. let maximum = 0; for (let i = 0; i < 3650; i++) { maximum = Math.max(maximum, elongation(new Date(Date.UTC(2024, 0, 1) + i * DAY))); } expect(maximum).toBeGreaterThan(17); expect(maximum).toBeLessThan(29); }); it("is always close to the Sun during a retrograde", () => { // Retrograde brackets inferior conjunction, so the midpoint of a window // should find Mercury nearly in line with the Sun. for (const window of windows.slice(0, 6)) { expect(elongation(new Date(midpoint(window)))).toBeLessThan(10); } }); }); describe("retrogradeWindows", () => { it("finds three or four per year", () => { // The well-known figure, and a direct consequence of the synodic period. const perYear = windows.length / 7; expect(perYear).toBeGreaterThanOrEqual(3); expect(perYear).toBeLessThanOrEqual(3.6); }); it("gives each window a duration of about three weeks", () => { for (const window of windows) { expect(window.days).toBeGreaterThanOrEqual(17); expect(window.days).toBeLessThanOrEqual(26); } }); it("spaces successive windows by Mercury's synodic period", () => { // The strongest check available without an external ephemeris: if the // elements or Kepler solver were wrong, this interval would drift away // from the known 115.88 days. const gaps: number[] = []; for (let i = 1; i < windows.length; i++) { gaps.push((midpoint(windows[i]) - midpoint(windows[i - 1])) / DAY); } const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length; expect(mean).toBeCloseTo(SYNODIC_PERIOD_DAYS, 0); // No individual gap should be wildly off either. for (const gap of gaps) { expect(Math.abs(gap - SYNODIC_PERIOD_DAYS)).toBeLessThan(12); } }); it("returns windows in order and without overlap", () => { for (let i = 1; i < windows.length; i++) { expect(windows[i].start.getTime()).toBeGreaterThan(windows[i - 1].end.getTime()); } }); it("actually contains retrograde motion, and the gaps do not", () => { // Verify the classification directly rather than trusting the scan: inside // a window the longitude must decrease end-to-end, and between windows it // must increase. const delta = (from: Date, to: Date) => { let d = mercuryLongitude(to) - mercuryLongitude(from); if (d > 180) d -= 360; if (d < -180) d += 360; return d; }; for (const window of windows.slice(0, 8)) { const inside = delta(new Date(window.start.getTime() + DAY), new Date(window.end.getTime() - DAY)); expect(inside, `expected retrograde inside ${window.start.toISOString()}`).toBeLessThan(0); } for (let i = 1; i < 8; i++) { const between = delta( new Date(windows[i - 1].end.getTime() + 2 * DAY), new Date(windows[i].start.getTime() - 2 * DAY), ); expect(between, "expected direct motion between windows").toBeGreaterThan(0); } }); it("returns nothing for an interval too short to contain a station", () => { const start = new Date(windows[0].start.getTime() + 3 * DAY); const brief = retrogradeWindows(start, new Date(start.getTime() + 2 * DAY)); expect(brief).toEqual([]); }); }); describe("currentWindow", () => { it("identifies a date inside a window, and rejects one outside", () => { const window = windows[0]; const inside = new Date(midpoint(window)); expect(currentWindow(inside, windows)).toEqual(window); const outside = new Date(window.end.getTime() + 10 * DAY); expect(currentWindow(outside, windows)).toBeNull(); }); });