2026-05-23 19:00:53 +08:00
package basic
import (
"encoding/json"
"math"
"os"
"testing"
"time"
)
type moonPlanetConjunctionBaselineSample struct {
Planet string ` json:"planet" `
Year int ` json:"year" `
Month int ` json:"month" `
TimeUTC string ` json:"time_utc" `
}
type moonPlanetConjunctionBaseline struct {
Samples [ ] moonPlanetConjunctionBaselineSample ` json:"samples" `
}
func loadMoonPlanetConjunctionBaseline ( t * testing . T ) moonPlanetConjunctionBaseline {
t . Helper ( )
paths := [ ] [ ] string {
{
"testdata/moon_planet_conjunction_baseline.json" ,
"basic/testdata/moon_planet_conjunction_baseline.json" ,
} ,
{
"testdata/moon_planet_conjunction_baseline_samples.json" ,
"basic/testdata/moon_planet_conjunction_baseline_samples.json" ,
} ,
}
var merged moonPlanetConjunctionBaseline
for index , candidates := range paths {
var (
data [ ] byte
err error
)
for _ , path := range candidates {
data , err = os . ReadFile ( path )
if err == nil {
var baseline moonPlanetConjunctionBaseline
if err := json . Unmarshal ( data , & baseline ) ; err != nil {
t . Fatalf ( "decode baseline %s: %v" , path , err )
}
merged . Samples = append ( merged . Samples , baseline . Samples ... )
break
}
}
if err != nil && index == 0 {
t . Fatalf ( "read baseline: %v" , err )
}
}
if len ( merged . Samples ) == 0 {
t . Fatal ( "empty moon-planet conjunction baseline" )
}
return merged
}
func TestMoonPlanetConjunctionsMatchHorizonsBaseline ( t * testing . T ) {
baseline := loadMoonPlanetConjunctionBaseline ( t )
type conjunctionCase struct {
planet MoonPlanetConjunctionPlanet
next func ( float64 , MoonPlanetConjunctionPlanet ) float64
}
cases := map [ string ] conjunctionCase {
"mercury" : { planet : MoonPlanetConjunctionMercury , next : NextMoonPlanetConjunction } ,
"venus" : { planet : MoonPlanetConjunctionVenus , next : NextMoonPlanetConjunction } ,
"mars" : { planet : MoonPlanetConjunctionMars , next : NextMoonPlanetConjunction } ,
"jupiter" : { planet : MoonPlanetConjunctionJupiter , next : NextMoonPlanetConjunction } ,
"saturn" : { planet : MoonPlanetConjunctionSaturn , next : NextMoonPlanetConjunction } ,
"uranus" : { planet : MoonPlanetConjunctionUranus , next : NextMoonPlanetConjunction } ,
"neptune" : { planet : MoonPlanetConjunctionNeptune , next : NextMoonPlanetConjunction } ,
}
const tolerance = 20 * time . Second
var maxDiff time . Duration
seen := make ( map [ string ] int , len ( cases ) )
for _ , sample := range baseline . Samples {
tc , ok := cases [ sample . Planet ]
if ! ok {
t . Fatalf ( "unknown planet %q" , sample . Planet )
}
wantTime , err := time . Parse ( time . RFC3339Nano , sample . TimeUTC )
if err != nil {
t . Fatalf ( "parse sample time %q: %v" , sample . TimeUTC , err )
}
queryTT := TD2UT ( Date2JDE ( wantTime . Add ( - 12 * time . Hour ) . UTC ( ) ) , true )
gotUT := tc . next ( queryTT , tc . planet )
gotTime := JDE2DateByZone ( gotUT , time . UTC , false )
diff := gotTime . Sub ( wantTime )
if diff < 0 {
diff = - diff
}
if diff > maxDiff {
maxDiff = diff
}
if diff > tolerance {
t . Fatalf ( "%s %04d-%02d time mismatch: got %s want %s tolerance %v" , sample . Planet , sample . Year , sample . Month , gotTime . Format ( time . RFC3339Nano ) , sample . TimeUTC , tolerance )
}
delta := math . Abs ( moonPlanetConjunctionDeltaAt ( TD2UT ( gotUT , true ) , tc . planet , - 1 ) )
if delta > 0.01 {
t . Fatalf ( "%s %04d-%02d event not near conjunction: delta=%.8f deg" , sample . Planet , sample . Year , sample . Month , delta )
}
seen [ sample . Planet ] ++
}
for planet := range cases {
if seen [ planet ] == 0 {
t . Fatalf ( "missing baseline samples for %s" , planet )
}
}
t . Logf ( "moon-planet conjunction max diff: time=%v" , maxDiff )
}
2026-05-23 23:08:05 +08:00
func TestMoonPlanetConjunctionDirectionalConsistencyAtComputedEvent ( t * testing . T ) {
2026-05-23 19:00:53 +08:00
baseline := loadMoonPlanetConjunctionBaseline ( t )
planets := map [ string ] MoonPlanetConjunctionPlanet {
"mercury" : MoonPlanetConjunctionMercury ,
"venus" : MoonPlanetConjunctionVenus ,
"mars" : MoonPlanetConjunctionMars ,
"jupiter" : MoonPlanetConjunctionJupiter ,
"saturn" : MoonPlanetConjunctionSaturn ,
"uranus" : MoonPlanetConjunctionUranus ,
"neptune" : MoonPlanetConjunctionNeptune ,
}
for _ , sample := range baseline . Samples {
planet , ok := planets [ sample . Planet ]
if ! ok {
t . Fatalf ( "unknown planet %q" , sample . Planet )
}
wantTime , err := time . Parse ( time . RFC3339Nano , sample . TimeUTC )
if err != nil {
t . Fatalf ( "parse sample time %q: %v" , sample . TimeUTC , err )
}
2026-05-23 23:08:05 +08:00
seedTT := TD2UT ( Date2JDE ( wantTime . Add ( - 12 * time . Hour ) . UTC ( ) ) , true )
eventUT := NextMoonPlanetConjunction ( seedTT , planet )
eventTime := JDE2DateByZone ( eventUT , time . UTC , false )
queryAtTT := TD2UT ( Date2JDE ( eventTime . UTC ( ) ) , true )
queryAfterTT := TD2UT ( Date2JDE ( eventTime . Add ( time . Hour ) . UTC ( ) ) , true )
2026-05-23 19:00:53 +08:00
exactNext := NextMoonPlanetConjunction ( queryAtTT , planet )
exactClosest := ClosestMoonPlanetConjunction ( queryAtTT , planet )
exactLastAfter := LastMoonPlanetConjunction ( queryAfterTT , planet )
for name , gotUT := range map [ string ] float64 {
"exactNext" : exactNext ,
"exactClosest" : exactClosest ,
"lastAfterEvent" : exactLastAfter ,
} {
gotTime := JDE2DateByZone ( gotUT , time . UTC , false )
2026-05-23 23:08:05 +08:00
if diff := math . Abs ( gotUT - eventUT ) ; diff > 1e-9 {
t . Fatalf ( "%s %s mismatch: got %s want %s diff=%v" , sample . Planet , name , gotTime . Format ( time . RFC3339Nano ) , eventTime . Format ( time . RFC3339Nano ) , diff * 86400 )
2026-05-23 19:00:53 +08:00
}
}
}
}
func TestMoonPlanetConjunctionRejectsOppositionBranchJump ( t * testing . T ) {
query := time . Date ( 1900 , 11 , 10 , 12 , 0 , 0 , 0 , time . UTC )
queryTT := TD2UT ( Date2JDE ( query ) , true )
lastUT := LastMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionSaturn )
nextUT := NextMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionSaturn )
if math . Abs ( lastUT - Date2JDE ( query ) ) <= 5.0 / 86400.0 {
t . Fatalf ( "last returned query time on branch jump: got %s" , JDE2DateByZone ( lastUT , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
if math . Abs ( nextUT - Date2JDE ( query ) ) <= 5.0 / 86400.0 {
t . Fatalf ( "next returned query time on branch jump: got %s" , JDE2DateByZone ( nextUT , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
for name , gotUT := range map [ string ] float64 {
"last" : lastUT ,
"next" : nextUT ,
} {
delta := math . Abs ( moonPlanetConjunctionDeltaAt ( TD2UT ( gotUT , true ) , MoonPlanetConjunctionSaturn , - 1 ) )
if delta > moonPlanetConjunctionEventTolerance {
t . Fatalf ( "%s returned non-event candidate: delta=%.8f event=%s" , name , delta , JDE2DateByZone ( gotUT , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
}
}
func TestMoonPlanetConjunctionDirectionalOrderingOnSampleQueries ( t * testing . T ) {
samples := [ ] struct {
planet MoonPlanetConjunctionPlanet
query time . Time
} {
{ planet : MoonPlanetConjunctionSaturn , query : time . Date ( 1700 , 4 , 15 , 12 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionMercury , query : time . Date ( 1900 , 1 , 14 , 12 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionVenus , query : time . Date ( 1950 , 6 , 3 , 12 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionMars , query : time . Date ( 2000 , 2 , 29 , 18 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionJupiter , query : time . Date ( 2026 , 5 , 20 , 0 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionSaturn , query : time . Date ( 2100 , 8 , 17 , 6 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionUranus , query : time . Date ( 2200 , 11 , 2 , 9 , 0 , 0 , 0 , time . UTC ) } ,
{ planet : MoonPlanetConjunctionNeptune , query : time . Date ( 2300 , 4 , 24 , 3 , 0 , 0 , 0 , time . UTC ) } ,
}
for _ , sample := range samples {
queryTT := TD2UT ( Date2JDE ( sample . query . UTC ( ) ) , true )
lastUT := LastMoonPlanetConjunction ( queryTT , sample . planet )
nextUT := NextMoonPlanetConjunction ( queryTT , sample . planet )
closestUT := ClosestMoonPlanetConjunction ( queryTT , sample . planet )
if math . IsNaN ( lastUT ) || math . IsNaN ( nextUT ) || math . IsNaN ( closestUT ) {
t . Fatalf ( "planet=%v query=%s returned NaN event(s): last=%v next=%v closest=%v" , sample . planet , sample . query . Format ( time . RFC3339 ) , lastUT , nextUT , closestUT )
}
if ! eventUTQueryBeforeOrEqual ( lastUT , queryTT ) {
t . Fatalf ( "planet=%v last after query: last=%s query=%s" , sample . planet , JDE2DateByZone ( lastUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , sample . query . Format ( time . RFC3339Nano ) )
}
if ! eventUTQueryAfterOrEqual ( nextUT , queryTT ) {
t . Fatalf ( "planet=%v next before query: next=%s query=%s" , sample . planet , JDE2DateByZone ( nextUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , sample . query . Format ( time . RFC3339Nano ) )
}
if closestUT != closestEventUTToQueryTT ( queryTT , lastUT , nextUT ) {
t . Fatalf ( "planet=%v closest mismatch: got=%s want=%s" , sample . planet , JDE2DateByZone ( closestUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , JDE2DateByZone ( closestEventUTToQueryTT ( queryTT , lastUT , nextUT ) , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
for name , gotUT := range map [ string ] float64 {
"last" : lastUT ,
"next" : nextUT ,
"closest" : closestUT ,
} {
delta := math . Abs ( moonPlanetConjunctionDeltaAt ( TD2UT ( gotUT , true ) , sample . planet , - 1 ) )
if delta > moonPlanetConjunctionEventTolerance {
t . Fatalf ( "planet=%v %s returned non-event candidate: delta=%.8f event=%s" , sample . planet , name , delta , JDE2DateByZone ( gotUT , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
}
}
}
func TestMoonPlanetConjunctionKeepsImmediateNeighborEvents ( t * testing . T ) {
query := time . Date ( 1700 , 4 , 15 , 12 , 0 , 0 , 0 , time . UTC )
queryTT := TD2UT ( Date2JDE ( query . UTC ( ) ) , true )
lastUT := LastMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionSaturn )
nextUT := NextMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionSaturn )
closestUT := ClosestMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionSaturn )
wantLast := time . Date ( 1700 , 4 , 15 , 11 , 55 , 59 , 115569293 , time . UTC )
wantNext := time . Date ( 1700 , 5 , 13 , 0 , 35 , 5 , 981616675 , time . UTC )
const tolerance = 5.0 / 86400.0
if diff := math . Abs ( lastUT - Date2JDE ( wantLast ) ) ; diff > tolerance {
t . Fatalf ( "last mismatch: got=%s want=%s diff=%.3fs" , JDE2DateByZone ( lastUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , wantLast . Format ( time . RFC3339Nano ) , diff * 86400 )
}
if diff := math . Abs ( nextUT - Date2JDE ( wantNext ) ) ; diff > tolerance {
t . Fatalf ( "next mismatch: got=%s want=%s diff=%.3fs" , JDE2DateByZone ( nextUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , wantNext . Format ( time . RFC3339Nano ) , diff * 86400 )
}
if ! sameEventJD ( closestUT , lastUT ) {
t . Fatalf ( "closest should keep immediate previous event: closest=%s last=%s" , JDE2DateByZone ( closestUT , time . UTC , false ) . Format ( time . RFC3339Nano ) , JDE2DateByZone ( lastUT , time . UTC , false ) . Format ( time . RFC3339Nano ) )
}
}
2026-05-23 23:08:05 +08:00
func TestMoonPlanetConjunctionNextAdvancesPastReturnedEvent ( t * testing . T ) {
seed := TD2UT ( Date2JDE ( time . Date ( 2026 , 5 , 1 , 0 , 0 , 0 , 0 , time . UTC ) ) , true )
eventUT := NextMoonPlanetConjunction ( seed , MoonPlanetConjunctionMercury )
query := JDE2DateByZone ( eventUT , time . UTC , false ) . Add ( time . Second )
queryTT := TD2UT ( Date2JDE ( query . UTC ( ) ) , true )
nextUT := NextMoonPlanetConjunction ( queryTT , MoonPlanetConjunctionMercury )
if eventUTQueryTTDelta ( nextUT , queryTT ) <= 0 {
t . Fatalf ( "expected next conjunction after query: query=%s next=%s delta=%.6fs" ,
query . Format ( time . RFC3339Nano ) ,
JDE2DateByZone ( nextUT , time . UTC , false ) . Format ( time . RFC3339Nano ) ,
eventUTQueryTTDelta ( nextUT , queryTT ) * 86400 ,
)
}
if sameEventJD ( nextUT , eventUT ) {
t . Fatalf ( "next conjunction should advance to a later event: event=%s next=%s" ,
JDE2DateByZone ( eventUT , time . UTC , false ) . Format ( time . RFC3339Nano ) ,
JDE2DateByZone ( nextUT , time . UTC , false ) . Format ( time . RFC3339Nano ) ,
)
}
}