293 lines
12 KiB
Go
293 lines
12 KiB
Go
|
|
package geojson_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/eclipse"
|
||
|
|
"b612.me/astro/geojson"
|
||
|
|
)
|
||
|
|
|
||
|
|
// 契约:单时刻本影导出只含该时刻的 region 与被切断时的物理边界;同一时刻的几何必须与
|
||
|
|
// 整包采样的对应 feature 完全一致,前端的"插值→精确替换"才不会跳变。
|
||
|
|
// Contract: the single-instant export contains only that instant's region (and the
|
||
|
|
// physical boundary when the horizon cuts it), and its geometry must match the packaged
|
||
|
|
// sample at the same time exactly.
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantMatchesPackagedFeatures(t *testing.T) {
|
||
|
|
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, 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")
|
||
|
|
}
|
||
|
|
packaged := decodeCollection(t, mustMarshalSolarEclipse(t, partial))
|
||
|
|
regions := featuresWithRole(packaged, "central-shadow-footprint")
|
||
|
|
boundaries := map[string]decodedFeature{}
|
||
|
|
for _, feature := range featuresWithRole(packaged, "central-shadow-boundary") {
|
||
|
|
stamp, _ := feature.Properties["time"].(string)
|
||
|
|
boundaries[stamp] = feature
|
||
|
|
}
|
||
|
|
if len(regions) == 0 {
|
||
|
|
t.Fatal("packaged export has no central-shadow-footprint")
|
||
|
|
}
|
||
|
|
comparedOpen := 0
|
||
|
|
for _, index := range []int{0, len(regions) / 2, len(regions) - 1} {
|
||
|
|
feature := regions[index]
|
||
|
|
stamp, _ := feature.Properties["time"].(string)
|
||
|
|
value, err := time.Parse(time.RFC3339Nano, stamp)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("parse packaged time %q: %v", stamp, err)
|
||
|
|
}
|
||
|
|
instant, ok := eclipse.SolarEclipseShadowAt(value)
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("%s: single-instant call reported no umbra but the package has a feature", stamp)
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: MarshalSolarEclipseShadowInstant: %v", stamp, err)
|
||
|
|
}
|
||
|
|
single := decodeCollection(t, data)
|
||
|
|
if len(single.Features) == 0 {
|
||
|
|
t.Fatalf("%s: single-instant export has no features", stamp)
|
||
|
|
}
|
||
|
|
instantRegion := featureWithRole(t, single, "central-shadow-footprint")
|
||
|
|
// 时间戳经 time.Time 往返会有约 1e-9 日的舍入,几何容差取 1e-7 度(约 1 厘米)。
|
||
|
|
assertSameCoordinates(t, stamp+" region", feature.Geometry.Coordinates, instantRegion.Geometry.Coordinates)
|
||
|
|
// 整包 feature 与单时刻导出的插值签名必须由同一口径给出,前端才能用同一个键比较。
|
||
|
|
if feature.Properties["interp_signature"] != instantRegion.Properties["interp_signature"] {
|
||
|
|
t.Fatalf("%s: packaged interp_signature=%v, instant=%v",
|
||
|
|
stamp, feature.Properties["interp_signature"], instantRegion.Properties["interp_signature"])
|
||
|
|
}
|
||
|
|
if feature.Properties["interp_signature"] != instant.Topology.Signature() {
|
||
|
|
t.Fatalf("%s: packaged interp_signature=%v, topology=%q",
|
||
|
|
stamp, feature.Properties["interp_signature"], instant.Topology.Signature())
|
||
|
|
}
|
||
|
|
closed, _ := feature.Properties["source_boundary_closed"].(bool)
|
||
|
|
if closed {
|
||
|
|
if len(single.Features) != 1 {
|
||
|
|
t.Fatalf("%s: self-closed instant exported %d features, want only the region", stamp, len(single.Features))
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
comparedOpen++
|
||
|
|
packagedBoundary, present := boundaries[stamp]
|
||
|
|
if !present {
|
||
|
|
t.Fatalf("%s: packaged export has no boundary feature to compare", stamp)
|
||
|
|
}
|
||
|
|
instantBoundary := featureWithRole(t, single, "central-shadow-boundary")
|
||
|
|
assertSameCoordinates(t, stamp+" boundary", packagedBoundary.Geometry.Coordinates, instantBoundary.Geometry.Coordinates)
|
||
|
|
for _, key := range []string{"closure", "interp_signature", "delta_t_seconds", "geometry_role"} {
|
||
|
|
if _, present := instantRegion.Properties[key]; !present {
|
||
|
|
t.Fatalf("%s: instant region is missing property %q", stamp, key)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if comparedOpen == 0 {
|
||
|
|
t.Fatal("no horizon-cut instant was compared against the packaged boundary")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantEmpty(t *testing.T) {
|
||
|
|
instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 12, 0, 0, 0, time.UTC))
|
||
|
|
if ok || !instant.Empty() {
|
||
|
|
t.Fatalf("expected an empty instant, got ok=%v empty=%v", ok, instant.Empty())
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("empty instant returned an error: %v", err)
|
||
|
|
}
|
||
|
|
var decoded struct {
|
||
|
|
Type string `json:"type"`
|
||
|
|
Features []interface{} `json:"features"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||
|
|
t.Fatalf("decode empty export: %v", err)
|
||
|
|
}
|
||
|
|
if decoded.Type != "FeatureCollection" || len(decoded.Features) != 0 {
|
||
|
|
t.Fatalf("empty export shape: type=%q features=%d", decoded.Type, len(decoded.Features))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantProperties(t *testing.T) {
|
||
|
|
value := time.Date(2009, time.July, 22, 4, 19, 23, 378387689, time.UTC)
|
||
|
|
instant, ok := eclipse.SolarEclipseShadowAt(value)
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected an umbral footprint at the test instant")
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
if len(collection.Features) != 2 {
|
||
|
|
t.Fatalf("features=%d, want region + boundary", len(collection.Features))
|
||
|
|
}
|
||
|
|
region := featureWithRole(t, collection, "central-shadow-footprint")
|
||
|
|
if region.Geometry.Type != "MultiPolygon" || region.Properties["geometry_role"] != "horizon-closed-region" {
|
||
|
|
t.Fatalf("region shape: type=%v role=%v", region.Geometry.Type, region.Properties["geometry_role"])
|
||
|
|
}
|
||
|
|
if region.Properties["source_boundary_closed"] != false {
|
||
|
|
t.Fatalf("region source_boundary_closed=%v, want false", region.Properties["source_boundary_closed"])
|
||
|
|
}
|
||
|
|
closure, ok := region.Properties["closure"].(map[string]interface{})
|
||
|
|
if !ok || closure["kind"] != "horizon" {
|
||
|
|
t.Fatalf("closure=%v, want kind=horizon", region.Properties["closure"])
|
||
|
|
}
|
||
|
|
if signature, _ := region.Properties["interp_signature"].(string); signature != instant.Topology.Signature() {
|
||
|
|
t.Fatalf("interp_signature=%v, want %q", region.Properties["interp_signature"], instant.Topology.Signature())
|
||
|
|
}
|
||
|
|
boundary := featureWithRole(t, collection, "central-shadow-boundary")
|
||
|
|
if boundary.Geometry.Type != "MultiLineString" || boundary.Properties["geometry_role"] != "open-boundary" {
|
||
|
|
t.Fatalf("boundary shape: type=%v role=%v", boundary.Geometry.Type, boundary.Properties["geometry_role"])
|
||
|
|
}
|
||
|
|
if boundary.Properties["interp_signature"] != region.Properties["interp_signature"] {
|
||
|
|
t.Fatal("region and boundary must share the interpolation signature")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// assertSameCoordinates 比较两份几何坐标:结构必须一致,数值容差 1e-7 度。
|
||
|
|
// assertSameCoordinates compares two coordinate trees with a 1e-7 degree tolerance.
|
||
|
|
func assertSameCoordinates(t *testing.T, label string, want, got json.RawMessage) {
|
||
|
|
t.Helper()
|
||
|
|
var wantTree, gotTree interface{}
|
||
|
|
if err := json.Unmarshal(want, &wantTree); err != nil {
|
||
|
|
t.Fatalf("%s: decode packaged coordinates: %v", label, err)
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(got, &gotTree); err != nil {
|
||
|
|
t.Fatalf("%s: decode instant coordinates: %v", label, err)
|
||
|
|
}
|
||
|
|
var compare func(path string, a, b interface{})
|
||
|
|
compare = func(path string, a, b interface{}) {
|
||
|
|
aList, aOK := a.([]interface{})
|
||
|
|
bList, bOK := b.([]interface{})
|
||
|
|
if aOK != bOK {
|
||
|
|
t.Fatalf("%s%s: structure differs", label, path)
|
||
|
|
}
|
||
|
|
if !aOK {
|
||
|
|
aNumber, aIsNumber := a.(float64)
|
||
|
|
bNumber, bIsNumber := b.(float64)
|
||
|
|
if !aIsNumber || !bIsNumber {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if difference := aNumber - bNumber; difference > 1e-7 || difference < -1e-7 {
|
||
|
|
t.Fatalf("%s%s: %.12f vs %.12f", label, path, aNumber, bNumber)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(aList) != len(bList) {
|
||
|
|
t.Fatalf("%s%s: length %d vs %d", label, path, len(aList), len(bList))
|
||
|
|
}
|
||
|
|
for index := range aList {
|
||
|
|
compare(fmt.Sprintf("%s[%d]", path, index), aList[index], bList[index])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
compare("", wantTree, gotTree)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantSelfClosedShape(t *testing.T) {
|
||
|
|
instant, ok := eclipse.SolarEclipseShadowAt(time.Date(2009, time.July, 22, 0, 55, 0, 0, time.UTC))
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected a self-closed footprint")
|
||
|
|
}
|
||
|
|
if !instant.Closed {
|
||
|
|
t.Fatalf("expected closed at the test instant, signature=%s", instant.Topology.Signature())
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
if len(collection.Features) != 1 {
|
||
|
|
t.Fatalf("self-closed instant exported %d features, want only the region", len(collection.Features))
|
||
|
|
}
|
||
|
|
region := collection.Features[0]
|
||
|
|
if region.Properties["source_boundary_closed"] != true {
|
||
|
|
t.Fatalf("source_boundary_closed=%v, want true", region.Properties["source_boundary_closed"])
|
||
|
|
}
|
||
|
|
// 与整包导出同构:自身闭合的区域没有 geometry_role,也没有 closure。
|
||
|
|
for _, key := range []string{"geometry_role", "closure"} {
|
||
|
|
if _, present := region.Properties[key]; present {
|
||
|
|
t.Fatalf("self-closed region must not carry %q (packaged export does not)", key)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSolarEclipseShadowInstantPenumbraRoles(t *testing.T) {
|
||
|
|
solver := eclipse.NewSolarEclipseShadowSolver(eclipse.SolarEclipseShadowSolverOptions{
|
||
|
|
Kind: eclipse.SolarEclipseShadowPenumbra,
|
||
|
|
})
|
||
|
|
instant, ok := solver.ShadowAt(time.Date(2009, time.July, 22, 0, 52, 0, 0, time.UTC))
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("expected a penumbra footprint")
|
||
|
|
}
|
||
|
|
data, err := geojson.MarshalSolarEclipseShadowInstant(instant)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("MarshalSolarEclipseShadowInstant: %v", err)
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, data)
|
||
|
|
region := featureWithRole(t, collection, "partial-footprint")
|
||
|
|
if region.Geometry.Type != "MultiPolygon" {
|
||
|
|
t.Fatalf("penumbra region geometry=%q, want MultiPolygon", region.Geometry.Type)
|
||
|
|
}
|
||
|
|
if signature, _ := region.Properties["interp_signature"].(string); len(signature) < 8 || signature[:8] != "penumbra" {
|
||
|
|
t.Fatalf("penumbra signature=%v, want a penumbra prefix", region.Properties["interp_signature"])
|
||
|
|
}
|
||
|
|
if _, present := region.Properties["delta_t_seconds"]; !present {
|
||
|
|
t.Fatal("penumbra region is missing delta_t_seconds")
|
||
|
|
}
|
||
|
|
if instant.Closed {
|
||
|
|
if len(collection.Features) != 1 {
|
||
|
|
t.Fatalf("self-closed penumbra exported %d features, want only the region", len(collection.Features))
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
boundary := featureWithRole(t, collection, "partial-footprint-boundary")
|
||
|
|
if boundary.Geometry.Type != "MultiLineString" {
|
||
|
|
t.Fatalf("penumbra boundary geometry=%q, want MultiLineString", boundary.Geometry.Type)
|
||
|
|
}
|
||
|
|
if closure, _ := region.Properties["closure"].(map[string]interface{}); closure["kind"] != "horizon" {
|
||
|
|
t.Fatalf("penumbra closure=%v, want kind=horizon", region.Properties["closure"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMarshalSampledPartialFootprintCarriesClosureAndHorizonEndpoints(t *testing.T) {
|
||
|
|
date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC)
|
||
|
|
partial, 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")
|
||
|
|
}
|
||
|
|
collection := decodeCollection(t, mustMarshalSolarEclipse(t, partial))
|
||
|
|
openCount, closureCount := 0, 0
|
||
|
|
for _, feature := range featuresWithRole(collection, "partial-footprint") {
|
||
|
|
if _, present := feature.Properties["interp_signature"]; !present {
|
||
|
|
t.Fatal("packaged partial footprint is missing interp_signature")
|
||
|
|
}
|
||
|
|
closed, _ := feature.Properties["source_boundary_closed"].(bool)
|
||
|
|
if closed {
|
||
|
|
if _, present := feature.Properties["closure"]; present {
|
||
|
|
t.Fatal("self-closed partial footprint must not carry a closure")
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
openCount++
|
||
|
|
if _, present := feature.Properties["closure"]; present {
|
||
|
|
closureCount++
|
||
|
|
}
|
||
|
|
if feature.Properties["geometry_role"] != "horizon-closed-region" {
|
||
|
|
t.Fatalf("open partial footprint geometry_role=%v, want horizon-closed-region",
|
||
|
|
feature.Properties["geometry_role"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if openCount == 0 || closureCount != openCount {
|
||
|
|
t.Fatalf("open=%d withClosure=%d, want every open footprint annotated", openCount, closureCount)
|
||
|
|
}
|
||
|
|
}
|