044f2f4381
- 新增半解析、旧模型、自定义与多源堆叠星历实例,提供 Context、Instance、SPK 读取及状态与视位置计算 - 统一太阳、月球和行星实例接口,扩展黄经穿越、留、相位、最近角距、偕日可见、任意高度升落、轨道节点及凌日等天象查询 - 完善日月食、月掩与凌日的月缘剖面、有限盘面接触、站心修正、局地可见性、全球路径和地图输出 - 新增 SVG、GeoJSON、KML 天象输出及凌日、月掩和月缘地形相关缓存与几何求解 - 新增恒星星表导入与查询,支持 CSV、Hipparcos、BSC5、Stellarium 等格式及名称、交叉标识、锥面检索和星等过滤 - 补充 IAU 2006、地球定向、折射消光、大气观测、轨道传播及半解析行星/月球数据支持 - 将默认未来民用时标切换为 TimeScaleLeapHour,更新 2027–2400 农历速查表、中文日期解析和时区边界处理 - 增加事件基线、跨模型回归、几何边界、月缘地形和输出契约测试,完善中英文手册、示例资源、NOTICE 与忽略规则
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"
|
|
)
|
|
|
|
// 本文件钉住三条契约:全掩可见性轮廓必须校验、退化影区整条缺省、
|
|
// 地平闭合必须能区分精确擦地点闭合与采样端点近似闭合。
|
|
|
|
func copyOccultationContours(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 degenerateShadowBoundary(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: degenerateShadowBoundary(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 horizonCutShadowInstant(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 shadowClosureExact(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 := horizonCutShadowInstant(t)
|
|
if exact := shadowClosureExact(t, instant); exact != true {
|
|
t.Fatalf("closure exact=%v, want true with two grazing points", exact)
|
|
}
|
|
sampled := instant
|
|
sampled.HorizonEnds = nil
|
|
if exact := shadowClosureExact(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)
|
|
}
|
|
}
|