2026-05-01 22:38:44 +08:00
package eclipse
import (
"testing"
"time"
)
func TestLunarEclipseLocalDayBoundsRespectDST ( t * testing . T ) {
loc , err := time . LoadLocation ( "America/New_York" )
if err != nil {
t . Skipf ( "tzdata unavailable: %v" , err )
}
testCases := [] struct {
name string
date time . Time
wantDuration time . Duration
}{
{
name : "spring forward 2025-03-09" ,
date : time . Date ( 2025 , 3 , 9 , 8 , 0 , 0 , 0 , loc ),
wantDuration : 23 * time . Hour ,
},
{
name : "fall back 2025-11-02" ,
date : time . Date ( 2025 , 11 , 2 , 8 , 0 , 0 , 0 , loc ),
wantDuration : 25 * time . Hour ,
},
}
for _ , tc := range testCases {
t . Run ( tc . name , func ( t * testing . T ) {
dayStart , dayMid , dayEnd := lunarEclipseLocalDayBounds ( tc . date )
if dayStart . Hour () != 0 || dayStart . Minute () != 0 || dayStart . Second () != 0 {
t . Fatalf ( "dayStart should be local midnight, got %v" , dayStart )
}
if dayMid . Hour () != 12 || dayMid . Minute () != 0 || dayMid . Second () != 0 {
t . Fatalf ( "dayMid should be local noon, got %v" , dayMid )
}
if dayEnd . Hour () != 0 || dayEnd . Minute () != 0 || dayEnd . Second () != 0 {
t . Fatalf ( "dayEnd should be next local midnight, got %v" , dayEnd )
}
if got := dayEnd . Sub ( dayStart ); got != tc . wantDuration {
t . Fatalf ( "day length mismatch: got %v want %v" , got , tc . wantDuration )
}
})
}
}
2026-09-17 12:27:40 +08:00
func TestLocalLunarEclipseFindsHighLatitudeMidEvent ( t * testing . T ) {
date := time . Date ( 2022 , 11 , 8 , 12 , 0 , 0 , 0 , time . UTC )
info , ok := LocalLunarEclipseOnDate ( date , - 174 , - 70 , 0 )
if ! ok {
t . Fatal ( "expected the eclipse to be visible during the local penumbral interval" )
}
if info . MoonAltitude <= 0 {
t . Fatalf ( "maximum altitude=%v, want above horizon" , info . MoonAltitude )
}
}
2026-05-01 22:38:44 +08:00
func TestLunarEclipseOnDateByLocalDay ( t * testing . T ) {
loc := time . FixedZone ( "UTC-05" , - 5 * 3600 )
testCases := [] struct {
name string
date time . Time
want bool
}{
{
name : "day before no eclipse" ,
date : time . Date ( 2025 , 3 , 12 , 12 , 0 , 0 , 0 , loc ),
want : false ,
},
{
name : "local start day overlaps" ,
date : time . Date ( 2025 , 3 , 13 , 12 , 0 , 0 , 0 , loc ),
want : true ,
},
{
name : "local end day overlaps" ,
date : time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , loc ),
want : true ,
},
{
name : "day after no eclipse" ,
date : time . Date ( 2025 , 3 , 15 , 12 , 0 , 0 , 0 , loc ),
want : false ,
},
}
for _ , tc := range testCases {
t . Run ( tc . name , func ( t * testing . T ) {
info , ok := LunarEclipseOnDate ( tc . date )
if ok != tc . want {
t . Fatalf ( "LunarEclipseOnDate(%v) got %v want %v" , tc . date , ok , tc . want )
}
if ! ok {
return
}
if info . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected eclipse type: got %s want %s" , info . Type , LunarEclipseTotal )
}
if info . Maximum . Location () != loc {
t . Fatalf ( "maximum location mismatch: got %q want %q" , info . Maximum . Location (), loc )
}
if info . PenumbralStart . Day () != 13 || info . PenumbralEnd . Day () != 14 {
t . Fatalf ( "unexpected local date span: start=%v end=%v" , info . PenumbralStart , info . PenumbralEnd )
}
})
}
}
func TestLunarEclipseSearchSemantics ( t * testing . T ) {
loc := time . FixedZone ( "CST" , 8 * 3600 )
current := ClosestLunarEclipseDanjon ( time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , loc ))
if current . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected current eclipse type: got %s want %s" , current . Type , LunarEclipseTotal )
}
assertSameEclipse ( t , "ClosestLunarEclipse(default)" , ClosestLunarEclipse ( current . Maximum ), current , time . Second )
last := LastLunarEclipseDanjon ( current . Maximum )
assertSameEclipse ( t , "LastLunarEclipseDanjon(current.Maximum)" , last , current , time . Second )
closest := ClosestLunarEclipseDanjon ( current . Maximum )
assertSameEclipse ( t , "ClosestLunarEclipseDanjon(current.Maximum)" , closest , current , time . Second )
next := NextLunarEclipseDanjon ( current . Maximum )
if ! next . Maximum . After ( current . Maximum ) {
t . Fatalf ( "NextLunarEclipseDanjon should be strictly future: current=%v next=%v" , current . Maximum , next . Maximum )
}
if next . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected next eclipse type: got %s want %s" , next . Type , LunarEclipseTotal )
}
wantNextMax := time . Date ( 2025 , 9 , 8 , 2 , 12 , 58 , 0 , loc )
assertTimeClose ( t , "next.Maximum" , next . Maximum , wantNextMax , 2 * time . Minute )
}
func TestLunarEclipseInfoKeepsLocation ( t * testing . T ) {
loc := time . FixedZone ( "UTC+08" , 8 * 3600 )
testCases := [] struct {
name string
calc func ( time . Time ) LunarEclipseInfo
}{
{ name : "danjon" , calc : ClosestLunarEclipseDanjon },
{ name : "chauvenet" , calc : ClosestLunarEclipseChauvenet },
}
for _ , tc := range testCases {
t . Run ( tc . name , func ( t * testing . T ) {
info := tc . calc ( time . Date ( 2023 , 10 , 29 , 12 , 0 , 0 , 0 , loc ))
if info . Type != LunarEclipsePartial {
t . Fatalf ( "unexpected eclipse type: got %s want %s" , info . Type , LunarEclipsePartial )
}
for _ , item := range [] struct {
name string
tm time . Time
}{
{ name : "PenumbralStart" , tm : info . PenumbralStart },
{ name : "PartialStart" , tm : info . PartialStart },
{ name : "Maximum" , tm : info . Maximum },
{ name : "PartialEnd" , tm : info . PartialEnd },
{ name : "PenumbralEnd" , tm : info . PenumbralEnd },
} {
if item . tm . Location () != loc {
t . Fatalf ( "%s location mismatch: got %q want %q" , item . name , item . tm . Location (), loc )
}
}
for _ , point := range info . ContactPoints {
if point . Time . Location () != loc {
t . Fatalf ( "contact %s location mismatch: got %q want %q" , point . Label , point . Time . Location (), loc )
}
}
})
}
}
func TestLunarEclipseContactPoints ( t * testing . T ) {
loc := time . FixedZone ( "UTC+08" , 8 * 3600 )
info := ClosestLunarEclipse ( time . Date ( 2026 , 3 , 3 , 12 , 0 , 0 , 0 , loc ))
if info . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected eclipse type: got %s want %s" , info . Type , LunarEclipseTotal )
}
if got , want := len ( info . ContactPoints ), 6 ; got != want {
t . Fatalf ( "contact point count = %d, want %d" , got , want )
}
points := make ( map [ string ] LunarEclipseContactPoint , len ( info . ContactPoints ))
for _ , point := range info . ContactPoints {
points [ point . Label ] = point
}
u1 := points [ "U1" ]
assertFloatClose ( t , "U1.ContactPositionAngle" , u1 . ContactPositionAngle , 96.181711 , 1e-3 )
assertFloatClose ( t , "U1.ContactClockwiseAngle" , u1 . ContactClockwiseAngle , 263.818289 , 1e-3 )
u2 := points [ "U2" ]
assertFloatClose ( t , "U2.ContactPositionAngle" , u2 . ContactPositionAngle , 243.025171 , 1e-3 )
assertFloatClose ( t , "U2.MoonCenterPositionAngle" , u2 . MoonCenterPositionAngle , 243.025171 , 1e-3 )
}
func TestLunarEclipseChauvenetRemainsAvailable ( t * testing . T ) {
date := time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , time . UTC )
defaultInfo := ClosestLunarEclipse ( date )
chauvenetInfo := ClosestLunarEclipseChauvenet ( date )
2026-09-23 18:55:12 +08:00
assertFloatClose ( t , "Chauvenet.PenumbralMagnitude" , chauvenetInfo . PenumbralMagnitude , 2.285436461 , 1e-6 )
assertFloatClose ( t , "Chauvenet.UmbralMagnitude" , chauvenetInfo . UmbralMagnitude , 1.182806541 , 1e-6 )
2026-05-01 22:38:44 +08:00
if !( chauvenetInfo . PenumbralMagnitude > defaultInfo . PenumbralMagnitude ) {
t . Fatalf ( "expected Chauvenet penumbral magnitude > Danjon: chauvenet=%.6f danjon=%.6f" , chauvenetInfo . PenumbralMagnitude , defaultInfo . PenumbralMagnitude )
}
if !( chauvenetInfo . PenumbralStart . Before ( defaultInfo . PenumbralStart ) && chauvenetInfo . PenumbralEnd . After ( defaultInfo . PenumbralEnd )) {
t . Fatalf ( "expected Chauvenet penumbral span to be wider: chauvenet=(%v,%v) danjon=(%v,%v)" , chauvenetInfo . PenumbralStart , chauvenetInfo . PenumbralEnd , defaultInfo . PenumbralStart , defaultInfo . PenumbralEnd )
}
}
func TestLunarEclipseDefaultFallsBackForUltraShallowPenumbralEdge ( t * testing . T ) {
date := time . Date ( - 780 , 12 , 13 , 12 , 0 , 0 , 0 , time . UTC )
defaultInfo := ClosestLunarEclipse ( date )
danjonInfo := ClosestLunarEclipseDanjon ( date )
chauvenetInfo := ClosestLunarEclipseChauvenet ( date )
if defaultInfo . Type != LunarEclipsePenumbral {
t . Fatalf ( "default type mismatch: got %s want %s" , defaultInfo . Type , LunarEclipsePenumbral )
}
if ! defaultInfo . HasSaros || defaultInfo . Saros . Series != 61 || defaultInfo . Saros . Member != 1 || defaultInfo . Saros . Count != 78 {
t . Fatalf ( "default shallow Saros mismatch: got has=%v saros=%+v" , defaultInfo . HasSaros , defaultInfo . Saros )
}
if danjonInfo . Maximum . Equal ( defaultInfo . Maximum ) {
t . Fatalf ( "default fallback should differ from explicit Danjon in this edge case: default=%v danjon=%v" , defaultInfo . Maximum , danjonInfo . Maximum )
}
if ! defaultInfo . Maximum . Equal ( chauvenetInfo . Maximum ) {
t . Fatalf ( "default fallback should reuse Chauvenet edge event timing: default=%v chauvenet=%v" , defaultInfo . Maximum , chauvenetInfo . Maximum )
}
if !( defaultInfo . PenumbralMagnitude > 0 && defaultInfo . PenumbralMagnitude <= lunarEclipseDefaultFallbackMaxPenumbralMagnitude ) {
t . Fatalf ( "default fallback penumbral magnitude out of narrow edge range: %.9f" , defaultInfo . PenumbralMagnitude )
}
}
func TestLunarEclipseAgainstNASABaseline ( t * testing . T ) {
// NASA GSFC lunar eclipse catalog / plot pages:
// - 2023 Oct 28 partial: LE2023Oct28P.pdf
// - 2025 Mar 14 total: LE2025Mar14T.pdf
testCases := [] struct {
name string
date time . Time
wantType LunarEclipseType
wantPenumbralMag float64
wantUmbralMag float64
wantPenumbralStart time . Time
wantPartialStart time . Time
wantTotalStart time . Time
wantMaximum time . Time
wantTotalEnd time . Time
wantPartialEnd time . Time
wantPenumbralEnd time . Time
}{
{
name : "2023-10-28 partial" ,
date : time . Date ( 2023 , 10 , 28 , 0 , 0 , 0 , 0 , time . UTC ),
wantType : LunarEclipsePartial ,
wantPenumbralMag : 1.1181 ,
wantUmbralMag : 0.122 ,
wantPenumbralStart : time . Date ( 2023 , 10 , 28 , 18 , 1 , 43 , 0 , time . UTC ),
wantPartialStart : time . Date ( 2023 , 10 , 28 , 19 , 35 , 18 , 0 , time . UTC ),
wantMaximum : time . Date ( 2023 , 10 , 28 , 20 , 14 , 6 , 0 , time . UTC ),
wantPartialEnd : time . Date ( 2023 , 10 , 28 , 20 , 52 , 53 , 0 , time . UTC ),
wantPenumbralEnd : time . Date ( 2023 , 10 , 28 , 22 , 26 , 19 , 0 , time . UTC ),
},
{
name : "2025-03-14 total" ,
date : time . Date ( 2025 , 3 , 14 , 0 , 0 , 0 , 0 , time . UTC ),
wantType : LunarEclipseTotal ,
wantPenumbralMag : 2.2595 ,
wantUmbralMag : 1.1784 ,
wantPenumbralStart : time . Date ( 2025 , 3 , 14 , 3 , 57 , 28 , 0 , time . UTC ),
wantPartialStart : time . Date ( 2025 , 3 , 14 , 5 , 9 , 40 , 0 , time . UTC ),
wantTotalStart : time . Date ( 2025 , 3 , 14 , 6 , 26 , 6 , 0 , time . UTC ),
wantMaximum : time . Date ( 2025 , 3 , 14 , 6 , 58 , 41 , 0 , time . UTC ),
wantTotalEnd : time . Date ( 2025 , 3 , 14 , 7 , 31 , 26 , 0 , time . UTC ),
wantPartialEnd : time . Date ( 2025 , 3 , 14 , 8 , 47 , 56 , 0 , time . UTC ),
wantPenumbralEnd : time . Date ( 2025 , 3 , 14 , 10 , 0 , 9 , 0 , time . UTC ),
},
}
const timeTolerance = 2 * time . Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
for _ , tc := range testCases {
t . Run ( tc . name , func ( t * testing . T ) {
assertSameEclipse ( t , "ClosestLunarEclipse(default)" , ClosestLunarEclipse ( tc . date ), ClosestLunarEclipseDanjon ( tc . date ), time . Second )
info := ClosestLunarEclipse ( tc . date )
if info . Type != tc . wantType {
t . Fatalf ( "type mismatch: got %s want %s" , info . Type , tc . wantType )
}
assertFloatClose ( t , "PenumbralMagnitude" , info . PenumbralMagnitude , tc . wantPenumbralMag , penumbralMagnitudeTolerance )
assertFloatClose ( t , "UmbralMagnitude" , info . UmbralMagnitude , tc . wantUmbralMag , umbralMagnitudeTolerance )
assertTimeClose ( t , "PenumbralStart" , info . PenumbralStart , tc . wantPenumbralStart , timeTolerance )
assertTimeClose ( t , "PartialStart" , info . PartialStart , tc . wantPartialStart , timeTolerance )
assertTimeClose ( t , "TotalStart" , info . TotalStart , tc . wantTotalStart , timeTolerance )
assertTimeClose ( t , "Maximum" , info . Maximum , tc . wantMaximum , timeTolerance )
assertTimeClose ( t , "TotalEnd" , info . TotalEnd , tc . wantTotalEnd , timeTolerance )
assertTimeClose ( t , "PartialEnd" , info . PartialEnd , tc . wantPartialEnd , timeTolerance )
assertTimeClose ( t , "PenumbralEnd" , info . PenumbralEnd , tc . wantPenumbralEnd , timeTolerance )
})
}
}
func TestPenumbralLunarEclipseKeepsNegativeUmbralMagnitude ( t * testing . T ) {
info := ClosestLunarEclipse ( time . Date ( 2024 , 3 , 25 , 0 , 0 , 0 , 0 , time . UTC ))
if info . Type != LunarEclipsePenumbral {
t . Fatalf ( "type mismatch: got %s want %s" , info . Type , LunarEclipsePenumbral )
}
if !( info . UmbralMagnitude < 0 ) {
t . Fatalf ( "expected negative umbral magnitude for penumbral eclipse, got %.12f" , info . UmbralMagnitude )
}
if !( info . PenumbralMagnitude > 0 ) {
t . Fatalf ( "expected positive penumbral magnitude, got %.12f" , info . PenumbralMagnitude )
}
}
func assertSameEclipse ( t * testing . T , name string , got , want LunarEclipseInfo , tolerance time . Duration ) {
t . Helper ()
if got . Type != want . Type {
t . Fatalf ( "%s type mismatch: got %s want %s" , name , got . Type , want . Type )
}
assertTimeClose ( t , name + ".Maximum" , got . Maximum , want . Maximum , tolerance )
}
func assertTimeClose ( t * testing . T , name string , got , want time . Time , tolerance time . Duration ) {
t . Helper ()
diff := got . Sub ( want )
if diff < 0 {
diff = - diff
}
if diff > tolerance {
t . Fatalf ( "%s mismatch: got %v want %v diff=%v" , name , got , want , diff )
}
}
func assertFloatClose ( t * testing . T , name string , got , want , tolerance float64 ) {
t . Helper ()
diff := got - want
if diff < 0 {
diff = - diff
}
if diff > tolerance {
t . Fatalf ( "%s mismatch: got %.6f want %.6f diff=%.6f" , name , got , want , diff )
}
}