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

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