2026-05-01 22:38:44 +08:00
package eclipse
import (
"testing"
"time"
)
func TestLocalLunarEclipseOnDateByLocalDay ( t * testing . T ) {
loc := time . FixedZone ( "CDT" , - 5 * 3600 )
lon , lat , height := - 87.65 , 41.85 , 0.0
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 := LocalLunarEclipseOnDate ( tc . date , lon , lat , height )
if ok != tc . want {
t . Fatalf ( "LocalLunarEclipseOnDate(%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 TestLocalLunarEclipseVisibilityFilter ( t * testing . T ) {
chicagoLoc := time . FixedZone ( "CDT" , - 5 * 3600 )
chicagoDate := time . Date ( 2023 , 10 , 28 , 12 , 0 , 0 , 0 , chicagoLoc )
chicagoLon , chicagoLat , chicagoHeight := - 87.65 , 41.85 , 0.0
geometricInfo , geometricOK := GeometricLocalLunarEclipseOnDate ( chicagoDate , chicagoLon , chicagoLat , chicagoHeight )
if ! geometricOK {
t . Fatalf ( "expected geometric local eclipse on date" )
}
if geometricInfo . Type != LunarEclipsePartial {
t . Fatalf ( "unexpected geometric eclipse type: got %s want %s" , geometricInfo . Type , LunarEclipsePartial )
}
if geometricInfo . VisibleAtMaximum {
t . Fatalf ( "expected geometric eclipse to be below horizon at maximum: %+v" , geometricInfo )
}
visibleInfo , visibleOK := LocalLunarEclipseOnDate ( chicagoDate , chicagoLon , chicagoLat , chicagoHeight )
if visibleOK {
t . Fatalf ( "expected visible filter to reject invisible eclipse, got %+v" , visibleInfo )
}
londonDate := time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , time . UTC )
londonInfo , londonOK := LocalLunarEclipseOnDate ( londonDate , - 0.1278 , 51.5074 , 0 )
if ! londonOK {
t . Fatalf ( "expected visible local eclipse in London" )
}
if londonInfo . VisibleAtMaximum {
t . Fatalf ( "expected London maximum to be below horizon, got %+v" , londonInfo )
}
}
func TestLocalLunarEclipseSearchSemantics ( t * testing . T ) {
loc := time . UTC
lon , lat , height := - 0.1278 , 51.5074 , 0.0
current := ClosestLocalLunarEclipseDanjon ( time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , loc ) , lon , lat , height )
if current . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected current eclipse type: got %s want %s" , current . Type , LunarEclipseTotal )
}
assertSameLocalLunarEclipse ( t , "ClosestLocalLunarEclipse(default)" , ClosestLocalLunarEclipse ( current . Maximum , lon , lat , height ) , current , time . Second )
last := LastLocalLunarEclipseDanjon ( current . Maximum , lon , lat , height )
assertSameLocalLunarEclipse ( t , "LastLocalLunarEclipseDanjon(current.Maximum)" , last , current , time . Second )
closest := ClosestLocalLunarEclipseDanjon ( current . Maximum , lon , lat , height )
assertSameLocalLunarEclipse ( t , "ClosestLocalLunarEclipseDanjon(current.Maximum)" , closest , current , time . Second )
next := NextLocalLunarEclipseDanjon ( current . Maximum , lon , lat , height )
if ! next . Maximum . After ( current . Maximum ) {
t . Fatalf ( "NextLocalLunarEclipseDanjon 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 , 7 , 18 , 11 , 49 , 0 , loc )
assertTimeClose ( t , "next.Maximum" , next . Maximum , wantNextMax , 2 * time . Minute )
}
func TestLocalLunarEclipseSearchBeyondFiveYears ( t * testing . T ) {
loc := time . FixedZone ( "CDT" , - 5 * 3600 )
lon , lat , height := - 87.65 , 41.85 , 0.0
current := ClosestLocalLunarEclipseDanjon ( time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , loc ) , lon , lat , height )
if current . Type != LunarEclipseTotal {
t . Fatalf ( "unexpected current eclipse type: got %s want %s" , current . Type , LunarEclipseTotal )
}
next := NextLocalLunarEclipseDanjon ( current . Maximum , lon , lat , height )
if next . Type == LunarEclipseNone || next . Maximum . IsZero ( ) {
t . Fatalf ( "expected a future visible local lunar eclipse beyond the old 60-lunation window" )
}
if ! next . Maximum . After ( current . Maximum ) {
t . Fatalf ( "expected strictly future local lunar eclipse: current=%v next=%v" , current . Maximum , next . Maximum )
}
}
2026-05-03 19:00:08 +08:00
func TestLocalTotalLunarEclipseSearch ( t * testing . T ) {
loc := time . FixedZone ( "CDT" , - 5 * 3600 )
lon , lat , height := - 87.65 , 41.85 , 0.0
date := time . Date ( 2025 , 3 , 13 , 0 , 0 , 0 , 0 , loc )
next , ok := NextLocalTotalLunarEclipse ( date , lon , lat , height )
if ! ok {
t . Fatal ( "expected to find next local total lunar eclipse" )
}
if next . Type != LunarEclipseTotal || ! next . HasTotal {
t . Fatalf ( "unexpected next total lunar eclipse: %+v" , next )
}
assertTimeClose ( t , "NextLocalTotalLunarEclipse" , next . Maximum , time . Date ( 2025 , 3 , 14 , 1 , 58 , 47 , 0 , loc ) , 2 * time . Minute )
last , ok := LastLocalTotalLunarEclipse ( next . Maximum , lon , lat , height )
if ! ok {
t . Fatal ( "expected to find previous local total lunar eclipse" )
}
if last . Type != LunarEclipseTotal || ! last . HasTotal {
t . Fatalf ( "unexpected last total lunar eclipse: %+v" , last )
}
assertTimeClose ( t , "LastLocalTotalLunarEclipse" , last . Maximum , next . Maximum , time . Second )
}
func TestLocalTotalLunarEclipseClosest ( t * testing . T ) {
loc := time . FixedZone ( "CDT" , - 5 * 3600 )
lon , lat , height := - 87.65 , 41.85 , 0.0
date := time . Date ( 2025 , 3 , 14 , 0 , 0 , 0 , 0 , loc )
info , ok := ClosestLocalTotalLunarEclipse ( date , lon , lat , height )
if ! ok {
t . Fatal ( "expected to find closest local total lunar eclipse" )
}
if info . Type != LunarEclipseTotal || ! info . HasTotal {
t . Fatalf ( "unexpected closest total lunar eclipse: %+v" , info )
}
assertTimeClose ( t , "ClosestLocalTotalLunarEclipse" , info . Maximum , time . Date ( 2025 , 3 , 14 , 1 , 58 , 47 , 0 , loc ) , 2 * time . Minute )
}
func TestLocalTotalLunarEclipseVisibleRequiresTotalPhaseVisibility ( t * testing . T ) {
info , ok := LocalLunarEclipseOnDate ( time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , time . UTC ) , - 0.1278 , 51.5074 , 0 )
if ! ok {
t . Fatalf ( "expected visible local eclipse in London" )
}
if info . Type != LunarEclipseTotal || ! info . HasTotal {
t . Fatalf ( "unexpected eclipse type: %+v" , info )
}
if ! localLunarEclipseVisible ( info ) {
t . Fatalf ( "expected some phase to be visible" )
}
if localTotalLunarEclipseVisible ( info ) {
t . Fatalf ( "expected total phase below horizon to be rejected" )
}
}
2026-05-01 22:38:44 +08:00
func TestLocalLunarEclipseInfoKeepsLocation ( t * testing . T ) {
loc := time . FixedZone ( "JST" , 9 * 3600 )
lon , lat , height := 139.6917 , 35.6895 , 1234.0
testCases := [ ] struct {
name string
calc func ( time . Time , float64 , float64 , float64 ) LocalLunarEclipseInfo
} {
{ name : "danjon" , calc : ClosestLocalLunarEclipseDanjon } ,
{ name : "chauvenet" , calc : ClosestLocalLunarEclipseChauvenet } ,
}
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 ) , lon , lat , height )
if info . Type != LunarEclipsePartial {
t . Fatalf ( "unexpected eclipse type: got %s want %s" , info . Type , LunarEclipsePartial )
}
if info . Longitude != lon || info . Latitude != lat || info . Height != height {
t . Fatalf ( "observer metadata mismatch: got (%f,%f,%f)" , info . Longitude , info . Latitude , info . Height )
}
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 )
}
}
} )
}
}
func TestLocalLunarEclipseChauvenetRemainsAvailable ( t * testing . T ) {
date := time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) )
lon , lat , height := - 87.65 , 41.85 , 0.0
defaultInfo := ClosestLocalLunarEclipse ( date , lon , lat , height )
chauvenetInfo := ClosestLocalLunarEclipseChauvenet ( date , lon , lat , height )
assertFloatClose ( t , "Chauvenet.PenumbralMagnitude" , chauvenetInfo . PenumbralMagnitude , 2.285431290 , 1e-6 )
assertFloatClose ( t , "Chauvenet.UmbralMagnitude" , chauvenetInfo . UmbralMagnitude , 1.182811712 , 1e-6 )
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 TestLocalLunarEclipseAgainstNASABaseline ( t * testing . T ) {
testCases := [ ] struct {
name string
date time . Time
lon float64
lat float64
height float64
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
wantMoonAltitude float64
} {
{
name : "2023-10-29 tokyo partial" ,
date : time . Date ( 2023 , 10 , 29 , 12 , 0 , 0 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
lon : 139.6917 ,
lat : 35.6895 ,
height : 0 ,
wantType : LunarEclipsePartial ,
wantPenumbralMag : 1.1181 ,
wantUmbralMag : 0.122 ,
wantPenumbralStart : time . Date ( 2023 , 10 , 29 , 03 , 01 , 43 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
wantPartialStart : time . Date ( 2023 , 10 , 29 , 04 , 35 , 18 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
wantMaximum : time . Date ( 2023 , 10 , 29 , 05 , 14 , 06 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
wantPartialEnd : time . Date ( 2023 , 10 , 29 , 05 , 52 , 53 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
wantPenumbralEnd : time . Date ( 2023 , 10 , 29 , 07 , 26 , 19 , 0 , time . FixedZone ( "JST" , 9 * 3600 ) ) ,
wantMoonAltitude : 9.1 ,
} ,
{
name : "2025-03-14 chicago total" ,
date : time . Date ( 2025 , 3 , 14 , 12 , 0 , 0 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
lon : - 87.65 ,
lat : 41.85 ,
height : 0 ,
wantType : LunarEclipseTotal ,
wantPenumbralMag : 2.2595 ,
wantUmbralMag : 1.1784 ,
wantPenumbralStart : time . Date ( 2025 , 3 , 13 , 22 , 57 , 28 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantPartialStart : time . Date ( 2025 , 3 , 14 , 0 , 9 , 40 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantTotalStart : time . Date ( 2025 , 3 , 14 , 1 , 26 , 6 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantMaximum : time . Date ( 2025 , 3 , 14 , 1 , 58 , 41 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantTotalEnd : time . Date ( 2025 , 3 , 14 , 2 , 31 , 26 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantPartialEnd : time . Date ( 2025 , 3 , 14 , 3 , 47 , 56 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantPenumbralEnd : time . Date ( 2025 , 3 , 14 , 5 , 0 , 9 , 0 , time . FixedZone ( "CDT" , - 5 * 3600 ) ) ,
wantMoonAltitude : 48.2 ,
} ,
}
const timeTolerance = 2 * time . Minute
const umbralMagnitudeTolerance = 0.02
const penumbralMagnitudeTolerance = 0.1
const altitudeTolerance = 1.5
for _ , tc := range testCases {
t . Run ( tc . name , func ( t * testing . T ) {
info := ClosestLocalLunarEclipse ( tc . date , tc . lon , tc . lat , tc . height )
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 )
assertFloatClose ( t , "MoonAltitude" , info . MoonAltitude , tc . wantMoonAltitude , altitudeTolerance )
} )
}
}
func TestLocalPenumbralLunarEclipseKeepsNegativeUmbralMagnitude ( t * testing . T ) {
cdt := time . FixedZone ( "CDT" , - 5 * 3600 )
info := ClosestLocalLunarEclipse ( time . Date ( 2024 , 3 , 25 , 2 , 0 , 0 , 0 , cdt ) , - 87.65 , 41.85 , 0 )
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 assertSameLocalLunarEclipse ( t * testing . T , name string , got , want LocalLunarEclipseInfo , 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 )
}