718 lines
27 KiB
Go
718 lines
27 KiB
Go
|
|
package geojson_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"math"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/eclipse"
|
||
|
|
"b612.me/astro/geojson"
|
||
|
|
"b612.me/astro/moon"
|
||
|
|
)
|
||
|
|
|
||
|
|
type decodedCollection struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Features []decodedFeature `json:"features"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type decodedFeature struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Properties map[string]interface{} `json:"properties"`
|
||
|
|
Geometry struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Coordinates json.RawMessage `json:"coordinates"`
|
||
|
|
} `json:"geometry"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseFeatureCollection(t *testing.T) {
|
||
|
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||
|
|
Step: 20 * time.Minute,
|
||
|
|
BoundaryPoints: 36,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar partial footprints")
|
||
|
|
}
|
||
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar central path")
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
assertRoles(t, collection,
|
||
|
|
"partial-footprint", "central-band", "center-line", "north-limit", "south-limit", "greatest")
|
||
|
|
assertCollectionCoordinates(t, collection)
|
||
|
|
assertClosedMultiPolygon(t, featureWithRole(t, collection, "central-band"))
|
||
|
|
assertTimedLineAligned(t, featureWithRole(t, collection, "center-line"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseAllowsSingleLimitCentrality(t *testing.T) {
|
||
|
|
date := time.Date(2003, time.May, 30, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||
|
|
Step: 20 * time.Minute, BoundaryPoints: 24,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar partial footprints")
|
||
|
|
}
|
||
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 10 * time.Minute})
|
||
|
|
if !ok || central.Eclipse.Centrality != eclipse.SolarEclipseCentralOneLimit {
|
||
|
|
t.Fatalf("expected one-limit central eclipse, got ok=%v centrality=%s", ok, central.Eclipse.Centrality)
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipse: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
if len(featuresWithRole(collection, "center-line")) != 1 || len(featuresWithRole(collection, "central-band")) != 0 {
|
||
|
|
t.Fatal("single-limit central eclipse should export center line without a band")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseAllowsLowSampleOpenFootprints(t *testing.T) {
|
||
|
|
for _, fixture := range []struct {
|
||
|
|
date time.Time
|
||
|
|
step time.Duration
|
||
|
|
}{
|
||
|
|
{time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||
|
|
{time.Date(2025, time.March, 29, 0, 0, 0, 0, time.UTC), 5 * time.Minute},
|
||
|
|
} {
|
||
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(fixture.date, eclipse.SolarEclipsePartialFootprintOptions{
|
||
|
|
Step: fixture.step, BoundaryPoints: 12,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("%s: expected solar partial footprints", fixture.date.Format("2006-01-02"))
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalSolarEclipse(partial, nil); err != nil {
|
||
|
|
t.Fatalf("%s low-sample GeoJSON: %v", fixture.date.Format("2006-01-02"), err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseWithTimeMarkers(t *testing.T) {
|
||
|
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||
|
|
Step: 20 * time.Minute,
|
||
|
|
BoundaryPoints: 36,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar partial footprints")
|
||
|
|
}
|
||
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{Step: 5 * time.Minute})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar central path")
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalSolarEclipseWithTimeMarkers(partial, ¢ral, geojson.TimeMarkerOptions{
|
||
|
|
Step: time.Hour,
|
||
|
|
Location: time.FixedZone("CST", 8*60*60),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipseWithTimeMarkers: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
markers := featuresWithRole(collection, "time-marker")
|
||
|
|
if len(markers) == 0 {
|
||
|
|
t.Fatal("solar eclipse has no time markers")
|
||
|
|
}
|
||
|
|
for _, marker := range markers {
|
||
|
|
if marker.Properties["source_role"] != "center-line" {
|
||
|
|
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||
|
|
}
|
||
|
|
label, ok := marker.Properties["label"].(string)
|
||
|
|
if !ok || len(label) != len("15:04") || label[2] != ':' {
|
||
|
|
t.Fatalf("invalid time marker label %q", label)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalLunarEclipseUsesRequestedBoundarySampling(t *testing.T) {
|
||
|
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected lunar eclipse")
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalLunarEclipse(info, 24)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalLunarEclipse: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
assertRoles(t, collection, "visible-at-p1", "visible-at-p4", "p1-horizon", "p4-horizon", "greatest")
|
||
|
|
assertCollectionCoordinates(t, collection)
|
||
|
|
visible := featureWithRole(t, collection, "visible-at-p1")
|
||
|
|
if got := visible.Properties["boundary_points"]; got != float64(24) {
|
||
|
|
t.Fatalf("boundary_points=%v, want 24", got)
|
||
|
|
}
|
||
|
|
assertClosedMultiPolygon(t, visible)
|
||
|
|
horizon := featureWithRole(t, collection, "p1-horizon")
|
||
|
|
var lines [][][]float64
|
||
|
|
if err := json.Unmarshal(horizon.Geometry.Coordinates, &lines); err != nil {
|
||
|
|
t.Fatalf("decode P1 horizon: %v", err)
|
||
|
|
}
|
||
|
|
pointCount := 0
|
||
|
|
for _, line := range lines {
|
||
|
|
pointCount += len(line)
|
||
|
|
}
|
||
|
|
if pointCount < 24 {
|
||
|
|
t.Fatalf("P1 horizon has %d points, want at least 24", pointCount)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalLunarEclipseWithTimeMarkers(t *testing.T) {
|
||
|
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected lunar eclipse")
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalLunarEclipseWithTimeMarkers(info, 24, geojson.TimeMarkerOptions{Step: time.Hour})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalLunarEclipseWithTimeMarkers: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
markers := featuresWithRole(collection, "time-marker")
|
||
|
|
if len(markers) == 0 {
|
||
|
|
t.Fatal("lunar eclipse has no time markers")
|
||
|
|
}
|
||
|
|
for _, marker := range markers {
|
||
|
|
if marker.Properties["source_role"] != "sublunar-track" {
|
||
|
|
t.Fatalf("time marker source_role=%v, want sublunar-track", marker.Properties["source_role"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
firstLabel, _ := markers[0].Properties["label"].(string)
|
||
|
|
lastLabel, _ := markers[len(markers)-1].Properties["label"].(string)
|
||
|
|
if firstLabel != "09:00" || lastLabel != "14:00" {
|
||
|
|
t.Fatalf("lunar marker endpoints = %q..%q, want 09:00..14:00", firstLabel, lastLabel)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalLunarEclipseRejectsInvalidContactOrder(t *testing.T) {
|
||
|
|
info, ok := eclipse.LunarEclipseOnDate(time.Date(2026, time.March, 3, 0, 0, 0, 0, time.UTC))
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected lunar eclipse")
|
||
|
|
}
|
||
|
|
info.Maximum = info.PenumbralStart.Add(-time.Minute)
|
||
|
|
if _, err := geojson.MarshalLunarEclipse(info, 24); err == nil {
|
||
|
|
t.Fatal("reversed lunar eclipse contacts were accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationSplitsAntimeridian(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{160, 175, -175, -160}, []float64{8, 4, 0, -4})
|
||
|
|
north := occultationSamples(start, []float64{158, 174, -174, -158}, []float64{18, 14, 10, 6})
|
||
|
|
south := occultationSamples(start, []float64{162, 176, -176, -162}, []float64{-2, -6, -10, -14})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "HR 4799",
|
||
|
|
Start: north[0],
|
||
|
|
Greatest: center[2],
|
||
|
|
End: north[len(north)-1],
|
||
|
|
Complete: true,
|
||
|
|
CenterLine: center,
|
||
|
|
NorthernLimit: north,
|
||
|
|
SouthernLimit: south,
|
||
|
|
Step: time.Hour,
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalStarOccultation(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultation: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
assertRoles(t, collection, "occultation-band", "center-line", "north-limit", "south-limit", "start", "greatest", "end")
|
||
|
|
assertCollectionCoordinates(t, collection)
|
||
|
|
centerFeature := featureWithRole(t, collection, "center-line")
|
||
|
|
var lines [][][]float64
|
||
|
|
if err := json.Unmarshal(centerFeature.Geometry.Coordinates, &lines); err != nil {
|
||
|
|
t.Fatalf("decode center line: %v", err)
|
||
|
|
}
|
||
|
|
if len(lines) != 2 {
|
||
|
|
t.Fatalf("center line has %d antimeridian segments, want 2", len(lines))
|
||
|
|
}
|
||
|
|
for _, line := range lines {
|
||
|
|
for index := 1; index < len(line); index++ {
|
||
|
|
if math.Abs(line[index][0]-line[index-1][0]) > 180 {
|
||
|
|
t.Fatalf("center line still crosses antimeridian: %#v", line)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assertTimedLineAligned(t, centerFeature)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationHandlesExactAntimeridian(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{-180, 180, 150}, []float64{2, 1, 0})
|
||
|
|
north := occultationSamples(start, []float64{-179, 179, 178}, []float64{12, 11, 10})
|
||
|
|
south := occultationSamples(start, []float64{-179, 179, 178}, []float64{-8, -9, -10})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "exact-antimeridian", Start: north[0], Greatest: center[1], End: north[2],
|
||
|
|
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south,
|
||
|
|
Step: time.Hour,
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalStarOccultation(path); err != nil {
|
||
|
|
t.Fatalf("exact antimeridian path: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationWithTimeMarkers(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{2, 1, 0, -1})
|
||
|
|
north := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{12, 11, 10, 9})
|
||
|
|
south := occultationSamples(start, []float64{20, 30, 40, 50}, []float64{-8, -9, -10, -11})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "HR 4799",
|
||
|
|
Start: north[0],
|
||
|
|
Greatest: center[1],
|
||
|
|
End: north[len(north)-1],
|
||
|
|
Complete: true,
|
||
|
|
CenterLine: center,
|
||
|
|
NorthernLimit: north,
|
||
|
|
SouthernLimit: south,
|
||
|
|
Step: time.Hour,
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Hour})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
markers := featuresWithRole(collection, "time-marker")
|
||
|
|
if len(markers) != 3 {
|
||
|
|
t.Fatalf("got %d time markers, want 3", len(markers))
|
||
|
|
}
|
||
|
|
for _, marker := range markers {
|
||
|
|
if marker.Properties["source_role"] != "center-line" {
|
||
|
|
t.Fatalf("time marker source_role=%v, want center-line", marker.Properties["source_role"])
|
||
|
|
}
|
||
|
|
if marker.Geometry.Type != "Point" {
|
||
|
|
t.Fatalf("time marker geometry=%q, want Point", marker.Geometry.Type)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTimeMarkerInterpolationUsesShortestAntimeridianPath(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{170, -170}, []float64{2, 0})
|
||
|
|
north := occultationSamples(start, []float64{168, -168}, []float64{12, 10})
|
||
|
|
south := occultationSamples(start, []float64{172, -172}, []float64{-8, -10})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "HR 4799",
|
||
|
|
Start: north[0],
|
||
|
|
Greatest: center[0],
|
||
|
|
End: north[len(north)-1],
|
||
|
|
Complete: true,
|
||
|
|
CenterLine: center,
|
||
|
|
NorthernLimit: north,
|
||
|
|
SouthernLimit: south,
|
||
|
|
Step: time.Hour,
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: 15 * time.Minute})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalStarOccultationWithTimeMarkers: %v", err)
|
||
|
|
}
|
||
|
|
markers := featuresWithRole(decodeCollection(t, data), "time-marker")
|
||
|
|
if len(markers) != 3 {
|
||
|
|
t.Fatalf("got %d time markers, want 3", len(markers))
|
||
|
|
}
|
||
|
|
for _, marker := range markers {
|
||
|
|
var coordinate []float64
|
||
|
|
if err := json.Unmarshal(marker.Geometry.Coordinates, &coordinate); err != nil {
|
||
|
|
t.Fatalf("decode time marker: %v", err)
|
||
|
|
}
|
||
|
|
if math.Abs(coordinate[0]) < 170 {
|
||
|
|
t.Fatalf("time marker crossed through longitude %.6f instead of the antimeridian", coordinate[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalPlanetOccultationIncludesPartialAndTotalFootprints(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||
|
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||
|
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||
|
|
totalNorth := occultationSamples(start, []float64{22, 30, 38}, []float64{8, 7, 6})
|
||
|
|
totalSouth := occultationSamples(start, []float64{22, 30, 38}, []float64{-4, -5, -6})
|
||
|
|
for index := range totalNorth {
|
||
|
|
at := start.Add(time.Duration(index+1) * 30 * time.Minute)
|
||
|
|
totalNorth[index].Time = at
|
||
|
|
totalSouth[index].Time = at
|
||
|
|
}
|
||
|
|
path := moon.PlanetOccultationPath{
|
||
|
|
Planet: moon.OccultationSaturn,
|
||
|
|
TargetID: "Saturn",
|
||
|
|
Start: north[0],
|
||
|
|
Greatest: center[1],
|
||
|
|
End: north[len(north)-1],
|
||
|
|
Complete: true,
|
||
|
|
CenterLine: center,
|
||
|
|
NorthernLimit: north,
|
||
|
|
SouthernLimit: south,
|
||
|
|
PartialFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 18, -10, 42, 14)},
|
||
|
|
HasTotalBand: true,
|
||
|
|
TotalStart: totalNorth[0],
|
||
|
|
TotalEnd: totalNorth[len(totalNorth)-1],
|
||
|
|
TotalComplete: true,
|
||
|
|
NorthernTotalLimit: totalNorth,
|
||
|
|
SouthernTotalLimit: totalSouth,
|
||
|
|
TotalFootprints: []moon.PlanetOccultationFootprint{sampleFootprint(start.Add(time.Hour), 23, -5, 37, 9)},
|
||
|
|
GreatestTotalWidthKM: 2500,
|
||
|
|
Step: time.Hour,
|
||
|
|
TargetSpacingKM: 50,
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalPlanetOccultation(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalPlanetOccultation: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
assertRoles(t, collection,
|
||
|
|
"partial-footprint", "total-footprint", "center-line", "north-limit", "south-limit",
|
||
|
|
"north-total-limit", "south-total-limit", "start", "total-start", "greatest", "total-end", "end")
|
||
|
|
assertCollectionCoordinates(t, collection)
|
||
|
|
if got := featureWithRole(t, collection, "greatest").Properties["planet"]; got != "saturn" {
|
||
|
|
t.Fatalf("planet=%v, want saturn", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalPlanetOccultationAllowsMissingCenterLine(t *testing.T) {
|
||
|
|
start := time.Date(2024, time.September, 5, 0, 0, 0, 0, time.UTC)
|
||
|
|
paths, err := moon.FindPlanetOccultationPaths(
|
||
|
|
start, start.AddDate(0, 0, 1), moon.OccultationVenus, moon.OccultationPathOptions{},
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FindPlanetOccultationPaths: %v", err)
|
||
|
|
}
|
||
|
|
if len(paths) != 1 || len(paths[0].CenterLine) != 0 {
|
||
|
|
t.Fatalf("unexpected Venus path count/center line: paths=%d center=%d", len(paths), len(paths[0].CenterLine))
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := geojson.MarshalPlanetOccultationWithTimeMarkers(
|
||
|
|
paths[0], geojson.TimeMarkerOptions{Step: 30 * time.Minute},
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalPlanetOccultationWithTimeMarkers: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
if len(featuresWithRole(collection, "center-line")) != 0 || len(featuresWithRole(collection, "time-marker")) != 0 {
|
||
|
|
t.Fatal("edge-only planetary path contains center-line features")
|
||
|
|
}
|
||
|
|
assertRoles(t, collection, "north-limit", "south-limit", "start", "greatest", "end")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseRejectsMisalignedLimits(t *testing.T) {
|
||
|
|
date := time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar partial footprints")
|
||
|
|
}
|
||
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, eclipse.SolarEclipsePathOptions{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected solar central path")
|
||
|
|
}
|
||
|
|
central.SouthernLimit = central.SouthernLimit[:len(central.SouthernLimit)-1]
|
||
|
|
if _, err := geojson.MarshalSolarEclipse(partial, ¢ral); err == nil {
|
||
|
|
t.Fatal("misaligned solar limits were accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalStarOccultationRejectsInvalidPathData(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
valid := sampleStarOccultationPath(start)
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
mutate func(*moon.StarOccultationPath)
|
||
|
|
}{
|
||
|
|
{name: "misaligned limits", mutate: func(path *moon.StarOccultationPath) {
|
||
|
|
path.SouthernLimit = path.SouthernLimit[:len(path.SouthernLimit)-1]
|
||
|
|
}},
|
||
|
|
{name: "mismatched limit times", mutate: func(path *moon.StarOccultationPath) {
|
||
|
|
path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second)
|
||
|
|
}},
|
||
|
|
{name: "non-monotonic line", mutate: func(path *moon.StarOccultationPath) {
|
||
|
|
path.CenterLine[1].Time = path.CenterLine[0].Time
|
||
|
|
}},
|
||
|
|
{name: "zero event time", mutate: func(path *moon.StarOccultationPath) {
|
||
|
|
path.Start.Time = time.Time{}
|
||
|
|
}},
|
||
|
|
}
|
||
|
|
for _, test := range tests {
|
||
|
|
t.Run(test.name, func(t *testing.T) {
|
||
|
|
path := valid
|
||
|
|
path.CenterLine = append([]moon.OccultationPathPoint(nil), valid.CenterLine...)
|
||
|
|
path.NorthernLimit = append([]moon.OccultationPathPoint(nil), valid.NorthernLimit...)
|
||
|
|
path.SouthernLimit = append([]moon.OccultationPathPoint(nil), valid.SouthernLimit...)
|
||
|
|
test.mutate(&path)
|
||
|
|
if _, err := geojson.MarshalStarOccultation(path); err == nil {
|
||
|
|
t.Fatal("invalid stellar occultation path was accepted")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalPlanetOccultationRejectsInvalidFootprintPolygon(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.February, 1, 0, 0, 0, 0, time.UTC)
|
||
|
|
path := samplePlanetOccultationPath(start)
|
||
|
|
path.PartialFootprints = []moon.PlanetOccultationFootprint{sampleFootprint(start, 10, -10, 20, 10)}
|
||
|
|
path.PartialFootprints[0].Polygons = append(path.PartialFootprints[0].Polygons, []moon.OccultationPathPoint{
|
||
|
|
{Longitude: 30, Latitude: 0}, {Longitude: 31, Latitude: 0},
|
||
|
|
})
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(path); err == nil {
|
||
|
|
t.Fatal("invalid footprint polygon was silently dropped")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalFunctionsRejectIncompleteInput(t *testing.T) {
|
||
|
|
if _, err := geojson.MarshalSolarEclipse(eclipse.SolarEclipsePartialFootprintsInfo{}, nil); err == nil {
|
||
|
|
t.Fatal("empty solar eclipse input was accepted")
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalLunarEclipse(eclipse.LunarEclipseInfo{}, 360); err == nil {
|
||
|
|
t.Fatal("empty lunar eclipse input was accepted")
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalStarOccultation(moon.StarOccultationPath{}); err == nil {
|
||
|
|
t.Fatal("empty stellar occultation input was accepted")
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(moon.PlanetOccultationPath{}); err == nil {
|
||
|
|
t.Fatal("empty planetary occultation input was accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTimeMarkerOptionsRejectNegativeStep(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||
|
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||
|
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "HR 4799",
|
||
|
|
Start: north[0],
|
||
|
|
Greatest: center[1],
|
||
|
|
End: north[len(north)-1],
|
||
|
|
Complete: true,
|
||
|
|
CenterLine: center,
|
||
|
|
NorthernLimit: north,
|
||
|
|
SouthernLimit: south,
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: -time.Minute}); err == nil {
|
||
|
|
t.Fatal("negative time-marker step was accepted")
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||
|
|
t.Fatal("sub-minute time-marker step was accepted")
|
||
|
|
}
|
||
|
|
|
||
|
|
excessiveEnd := path.CenterLine[0].Time.Add(24*time.Hour + 2*time.Minute)
|
||
|
|
path.End.Time = excessiveEnd
|
||
|
|
path.CenterLine[len(path.CenterLine)-1].Time = excessiveEnd
|
||
|
|
path.NorthernLimit[len(path.NorthernLimit)-1].Time = excessiveEnd
|
||
|
|
path.SouthernLimit[len(path.SouthernLimit)-1].Time = excessiveEnd
|
||
|
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Minute}); err == nil {
|
||
|
|
t.Fatal("excessive time-marker count was accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTimeMarkerOptionsAreValidatedWithoutCenterLine(t *testing.T) {
|
||
|
|
start := time.Date(2025, time.June, 5, 17, 45, 0, 0, time.UTC)
|
||
|
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||
|
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||
|
|
path := moon.StarOccultationPath{
|
||
|
|
TargetID: "edge-only", Start: north[0], Greatest: north[1], End: north[2], Complete: true,
|
||
|
|
NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalStarOccultationWithTimeMarkers(path, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||
|
|
t.Fatal("edge-only stellar path accepted sub-minute markers")
|
||
|
|
}
|
||
|
|
planet := moon.PlanetOccultationPath{
|
||
|
|
Planet: moon.OccultationVenus, TargetID: "Venus", Start: north[0], Greatest: north[1], End: north[2],
|
||
|
|
Complete: true, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||
|
|
}
|
||
|
|
if _, err := geojson.MarshalPlanetOccultationWithTimeMarkers(planet, geojson.TimeMarkerOptions{Step: time.Nanosecond}); err == nil {
|
||
|
|
t.Fatal("edge-only planetary path accepted sub-minute markers")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeCollection(t *testing.T, data []byte) decodedCollection {
|
||
|
|
t.Helper()
|
||
|
|
var collection decodedCollection
|
||
|
|
if err := json.Unmarshal(data, &collection); err != nil {
|
||
|
|
t.Fatalf("decode GeoJSON: %v", err)
|
||
|
|
}
|
||
|
|
if collection.Type != "FeatureCollection" {
|
||
|
|
t.Fatalf("collection type=%q, want FeatureCollection", collection.Type)
|
||
|
|
}
|
||
|
|
if len(collection.Features) == 0 {
|
||
|
|
t.Fatal("GeoJSON contains no features")
|
||
|
|
}
|
||
|
|
for _, feature := range collection.Features {
|
||
|
|
if feature.Type != "Feature" {
|
||
|
|
t.Fatalf("feature type=%q, want Feature", feature.Type)
|
||
|
|
}
|
||
|
|
if _, ok := feature.Properties["event"]; !ok {
|
||
|
|
t.Fatal("feature has no event property")
|
||
|
|
}
|
||
|
|
if _, ok := feature.Properties["role"]; !ok {
|
||
|
|
t.Fatal("feature has no role property")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return collection
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertRoles(t *testing.T, collection decodedCollection, roles ...string) {
|
||
|
|
t.Helper()
|
||
|
|
for _, role := range roles {
|
||
|
|
featureWithRole(t, collection, role)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func featureWithRole(t *testing.T, collection decodedCollection, role string) decodedFeature {
|
||
|
|
t.Helper()
|
||
|
|
for _, feature := range collection.Features {
|
||
|
|
if feature.Properties["role"] == role {
|
||
|
|
return feature
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Fatalf("GeoJSON is missing role %q", role)
|
||
|
|
return decodedFeature{}
|
||
|
|
}
|
||
|
|
|
||
|
|
func featuresWithRole(collection decodedCollection, role string) []decodedFeature {
|
||
|
|
result := make([]decodedFeature, 0)
|
||
|
|
for _, feature := range collection.Features {
|
||
|
|
if feature.Properties["role"] == role {
|
||
|
|
result = append(result, feature)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertCollectionCoordinates(t *testing.T, collection decodedCollection) {
|
||
|
|
t.Helper()
|
||
|
|
for _, feature := range collection.Features {
|
||
|
|
var coordinates interface{}
|
||
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &coordinates); err != nil {
|
||
|
|
t.Fatalf("decode %v coordinates: %v", feature.Properties["role"], err)
|
||
|
|
}
|
||
|
|
assertCoordinateTree(t, coordinates)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertCoordinateTree(t *testing.T, value interface{}) {
|
||
|
|
t.Helper()
|
||
|
|
items, ok := value.([]interface{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("coordinate node has type %T", value)
|
||
|
|
}
|
||
|
|
if len(items) >= 2 {
|
||
|
|
longitude, lonOK := items[0].(float64)
|
||
|
|
latitude, latOK := items[1].(float64)
|
||
|
|
if lonOK && latOK {
|
||
|
|
if math.IsNaN(longitude) || math.IsInf(longitude, 0) || longitude < -180 || longitude > 180 {
|
||
|
|
t.Fatalf("invalid longitude %.12f", longitude)
|
||
|
|
}
|
||
|
|
if math.IsNaN(latitude) || math.IsInf(latitude, 0) || latitude < -90 || latitude > 90 {
|
||
|
|
t.Fatalf("invalid latitude %.12f", latitude)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, item := range items {
|
||
|
|
assertCoordinateTree(t, item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertClosedMultiPolygon(t *testing.T, feature decodedFeature) {
|
||
|
|
t.Helper()
|
||
|
|
if feature.Geometry.Type != "MultiPolygon" {
|
||
|
|
t.Fatalf("%v geometry=%q, want MultiPolygon", feature.Properties["role"], feature.Geometry.Type)
|
||
|
|
}
|
||
|
|
var polygons [][][][]float64
|
||
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
||
|
|
t.Fatalf("decode %v polygon: %v", feature.Properties["role"], err)
|
||
|
|
}
|
||
|
|
if len(polygons) == 0 {
|
||
|
|
t.Fatalf("%v has no polygons", feature.Properties["role"])
|
||
|
|
}
|
||
|
|
for _, polygon := range polygons {
|
||
|
|
if len(polygon) == 0 || len(polygon[0]) < 4 {
|
||
|
|
t.Fatalf("%v contains an incomplete ring", feature.Properties["role"])
|
||
|
|
}
|
||
|
|
ring := polygon[0]
|
||
|
|
first, last := ring[0], ring[len(ring)-1]
|
||
|
|
if first[0] != last[0] || first[1] != last[1] {
|
||
|
|
t.Fatalf("%v ring is not closed", feature.Properties["role"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertTimedLineAligned(t *testing.T, feature decodedFeature) {
|
||
|
|
t.Helper()
|
||
|
|
var lines [][][]float64
|
||
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &lines); err != nil {
|
||
|
|
t.Fatalf("decode line coordinates: %v", err)
|
||
|
|
}
|
||
|
|
timeSegments, ok := feature.Properties["times"].([]interface{})
|
||
|
|
if !ok || len(timeSegments) != len(lines) {
|
||
|
|
t.Fatalf("times do not align with %d line segments: %#v", len(lines), feature.Properties["times"])
|
||
|
|
}
|
||
|
|
for index, rawSegment := range timeSegments {
|
||
|
|
times, ok := rawSegment.([]interface{})
|
||
|
|
if !ok || len(times) != len(lines[index]) {
|
||
|
|
t.Fatalf("times segment %d does not align with %d coordinates", index, len(lines[index]))
|
||
|
|
}
|
||
|
|
for _, rawTime := range times {
|
||
|
|
value, ok := rawTime.(string)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("time has type %T", rawTime)
|
||
|
|
}
|
||
|
|
if _, err := time.Parse(time.RFC3339Nano, value); err != nil {
|
||
|
|
t.Fatalf("invalid RFC3339 time %q: %v", value, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func occultationSamples(start time.Time, longitudes, latitudes []float64) []moon.OccultationPathPoint {
|
||
|
|
result := make([]moon.OccultationPathPoint, len(longitudes))
|
||
|
|
for index := range result {
|
||
|
|
result[index] = moon.OccultationPathPoint{
|
||
|
|
Time: start.Add(time.Duration(index) * time.Hour),
|
||
|
|
Longitude: longitudes[index],
|
||
|
|
Latitude: latitudes[index],
|
||
|
|
MoonAltitude: 30,
|
||
|
|
WidthKM: 3000,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func sampleStarOccultationPath(start time.Time) moon.StarOccultationPath {
|
||
|
|
center := occultationSamples(start, []float64{20, 30, 40}, []float64{2, 1, 0})
|
||
|
|
north := occultationSamples(start, []float64{20, 30, 40}, []float64{12, 11, 10})
|
||
|
|
south := occultationSamples(start, []float64{20, 30, 40}, []float64{-8, -9, -10})
|
||
|
|
return moon.StarOccultationPath{
|
||
|
|
TargetID: "HR 4799", Start: north[0], Greatest: center[1], End: north[len(north)-1],
|
||
|
|
Complete: true, CenterLine: center, NorthernLimit: north, SouthernLimit: south, Step: time.Hour,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func samplePlanetOccultationPath(start time.Time) moon.PlanetOccultationPath {
|
||
|
|
star := sampleStarOccultationPath(start)
|
||
|
|
return moon.PlanetOccultationPath{
|
||
|
|
Planet: moon.OccultationSaturn, TargetID: "Saturn",
|
||
|
|
Start: star.Start, Greatest: star.Greatest, End: star.End, Complete: true,
|
||
|
|
CenterLine: star.CenterLine, NorthernLimit: star.NorthernLimit, SouthernLimit: star.SouthernLimit,
|
||
|
|
Step: time.Hour,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func sampleFootprint(at time.Time, west, south, east, north float64) moon.PlanetOccultationFootprint {
|
||
|
|
return moon.PlanetOccultationFootprint{
|
||
|
|
Time: at,
|
||
|
|
Polygons: [][]moon.OccultationPathPoint{{
|
||
|
|
{Longitude: west, Latitude: south},
|
||
|
|
{Longitude: east, Latitude: south},
|
||
|
|
{Longitude: east, Latitude: north},
|
||
|
|
{Longitude: west, Latitude: north},
|
||
|
|
}},
|
||
|
|
}
|
||
|
|
}
|