187 lines
6.6 KiB
Go
187 lines
6.6 KiB
Go
|
|
package geojson_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/eclipse"
|
||
|
|
"b612.me/astro/geojson"
|
||
|
|
"b612.me/astro/moon"
|
||
|
|
)
|
||
|
|
|
||
|
|
// 本文件钉住 §1.5 的三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、
|
||
|
|
// 地平闭合必须能区分精确擦地点闭合与采样端点近似闭合。
|
||
|
|
|
||
|
|
func review15CopyContours(contours [][]moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
|
||
|
|
copied := make([][]moon.OccultationPathPoint, len(contours))
|
||
|
|
for index, contour := range contours {
|
||
|
|
copied[index] = append([]moon.OccultationPathPoint(nil), contour...)
|
||
|
|
}
|
||
|
|
return copied
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalPlanetOccultationValidatesTotalVisibilityContours(t *testing.T) {
|
||
|
|
path := mars20250729TestFixture(t, time.Minute, true).path
|
||
|
|
if !path.HasTotalBand || len(path.TotalBandContours) == 0 || len(path.TotalBandContours[0]) < 2 {
|
||
|
|
t.Fatal("fixture has no usable total band contour")
|
||
|
|
}
|
||
|
|
// 复制的样本本身合法,只有注入的可见性轮廓是待测对象。
|
||
|
|
valid := append([]moon.OccultationPathPoint(nil),
|
||
|
|
path.TotalBandContours[0][0], path.TotalBandContours[0][1])
|
||
|
|
|
||
|
|
outOfOrder := path
|
||
|
|
outOfOrder.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[0]}}
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(outOfOrder); err == nil {
|
||
|
|
t.Fatal("out-of-order total visibility contours must be rejected")
|
||
|
|
}
|
||
|
|
|
||
|
|
outside := path
|
||
|
|
outside.TotalVisibilityContours = [][]moon.OccultationPathPoint{{
|
||
|
|
{Time: path.TotalStart.Time.Add(-time.Minute)}, valid[1],
|
||
|
|
}}
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(outside); err == nil {
|
||
|
|
t.Fatal("total visibility contours outside the total interval must be rejected")
|
||
|
|
}
|
||
|
|
|
||
|
|
orphan := path
|
||
|
|
orphan.HasTotalBand = false
|
||
|
|
orphan.TotalComplete = false
|
||
|
|
orphan.TotalStart = moon.OccultationPathPoint{}
|
||
|
|
orphan.TotalEnd = moon.OccultationPathPoint{}
|
||
|
|
orphan.NorthernTotalLimit = nil
|
||
|
|
orphan.SouthernTotalLimit = nil
|
||
|
|
orphan.TotalFootprints = nil
|
||
|
|
orphan.TotalBandFootprints = nil
|
||
|
|
orphan.TotalBandContours = nil
|
||
|
|
orphan.TotalRiseSetCurves = nil
|
||
|
|
orphan.GreatestTotalWidthKM = 0
|
||
|
|
orphan.TotalVisibilityContours = [][]moon.OccultationPathPoint{{valid[0], valid[1]}}
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(orphan); err == nil {
|
||
|
|
t.Fatal("total visibility contours without HasTotalBand must be rejected")
|
||
|
|
}
|
||
|
|
orphan.TotalVisibilityContours = nil
|
||
|
|
if _, err := geojson.MarshalPlanetOccultation(orphan); err != nil {
|
||
|
|
t.Fatalf("cleared total-band fields must marshal: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func review15DegenerateBoundary(stamp time.Time) [][]eclipse.SolarEclipsePathPoint {
|
||
|
|
return [][]eclipse.SolarEclipsePathPoint{{
|
||
|
|
{Time: stamp, Longitude: 0, Latitude: 0},
|
||
|
|
{Time: stamp, Longitude: 0.01, Latitude: 0},
|
||
|
|
{Time: stamp, Longitude: 0.01, Latitude: 1e-11},
|
||
|
|
{Time: stamp, Longitude: 0, Latitude: 1e-11},
|
||
|
|
}}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantSkipsDegenerateRegion(t *testing.T) {
|
||
|
|
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
|
||
|
|
instant := eclipse.SolarEclipseShadowInstant{
|
||
|
|
Time: stamp, Closed: true, Boundaries: review15DegenerateBoundary(stamp),
|
||
|
|
}
|
||
|
|
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err)
|
||
|
|
}
|
||
|
|
var collection struct {
|
||
|
|
Features []json.RawMessage `json:"features"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &collection); err != nil {
|
||
|
|
t.Fatalf("decode GeoJSON: %v", err)
|
||
|
|
}
|
||
|
|
if len(collection.Features) != 0 {
|
||
|
|
t.Fatalf("features=%d, want the degenerate region omitted", len(collection.Features))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func review15HorizonCutInstant(t *testing.T) eclipse.SolarEclipseShadowInstant {
|
||
|
|
t.Helper()
|
||
|
|
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||
|
|
info, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{
|
||
|
|
Step: 2 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 2 * time.Minute,
|
||
|
|
DisableRiseSet: true,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected the 2009-07-22 eclipse")
|
||
|
|
}
|
||
|
|
for _, footprint := range info.CentralShadowFootprints {
|
||
|
|
if footprint.Closed || len(footprint.HorizonEnds) != 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
return eclipse.SolarEclipseShadowInstant{
|
||
|
|
Time: footprint.Time, Kind: eclipse.SolarEclipseShadowUmbra,
|
||
|
|
Boundaries: footprint.Boundaries, HorizonEnds: footprint.HorizonEnds,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Fatal("fixture has no horizon-cut central-shadow footprint")
|
||
|
|
return eclipse.SolarEclipseShadowInstant{}
|
||
|
|
}
|
||
|
|
|
||
|
|
func review15ClosureExact(t *testing.T, instant eclipse.SolarEclipseShadowInstant) interface{} {
|
||
|
|
t.Helper()
|
||
|
|
raw, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||
|
|
}
|
||
|
|
region := featureWithRole(t, decodeCollection(t, raw), "central-shadow-footprint")
|
||
|
|
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("horizon-cut region has no closure property")
|
||
|
|
}
|
||
|
|
if closure["kind"] != "horizon" {
|
||
|
|
t.Fatalf("closure kind=%v, want horizon", closure["kind"])
|
||
|
|
}
|
||
|
|
return closure["exact"]
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantMarksSampledClosure(t *testing.T) {
|
||
|
|
instant := review15HorizonCutInstant(t)
|
||
|
|
if exact := review15ClosureExact(t, instant); exact != true {
|
||
|
|
t.Fatalf("closure exact=%v, want true with two grazing points", exact)
|
||
|
|
}
|
||
|
|
sampled := instant
|
||
|
|
sampled.HorizonEnds = nil
|
||
|
|
if exact := review15ClosureExact(t, sampled); exact != false {
|
||
|
|
t.Fatalf("closure exact=%v, want false without grazing points", exact)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSolarEclipseCentralShadowClosureExactnessMatchesGrazingPoints(t *testing.T) {
|
||
|
|
fixtures := centralShadowRegionFixtures()
|
||
|
|
exactRegions := 0
|
||
|
|
cutRegions := 0
|
||
|
|
for _, fixture := range fixtures {
|
||
|
|
collection, info := centralShadowMarshalFixture(t, fixture)
|
||
|
|
grazing := make(map[string]bool, len(info.CentralShadowFootprints))
|
||
|
|
for _, footprint := range info.CentralShadowFootprints {
|
||
|
|
grazing[footprint.Time.UTC().Format(time.RFC3339Nano)] = len(footprint.HorizonEnds) == 2
|
||
|
|
}
|
||
|
|
for _, region := range featuresWithRole(collection, "central-shadow-footprint") {
|
||
|
|
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
cutRegions++
|
||
|
|
stamp, _ := region.Properties["time"].(string)
|
||
|
|
want, present := grazing[stamp]
|
||
|
|
if !present {
|
||
|
|
t.Fatalf("%s: region %s has no matching footprint", fixture.name, stamp)
|
||
|
|
}
|
||
|
|
exact, ok := closure["exact"].(bool)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("%s: closure of %s has no exact flag", fixture.name, stamp)
|
||
|
|
}
|
||
|
|
if exact != want {
|
||
|
|
t.Fatalf("%s: closure exact=%v, want %v for %s", fixture.name, exact, want, stamp)
|
||
|
|
}
|
||
|
|
if exact {
|
||
|
|
exactRegions++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if cutRegions == 0 || exactRegions == 0 {
|
||
|
|
t.Fatalf("horizon-cut regions=%d with grazing points=%d; the marker is untested", cutRegions, exactRegions)
|
||
|
|
}
|
||
|
|
}
|