feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算 - 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出 - 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口 - 扩展日食中心线、南北界及偏食足迹采样,支持极区投影 - 修正站心时角、月出月落、月球视半径、折射和恒星自行计算 - 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
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},
|
||||
}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user