Files
astro/geojson/solar_shadow_instant_test.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

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)
}
}