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

191 lines
8.3 KiB
Go

package svg
import (
"math"
"strings"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/svgmap"
)
// 复用 partial 结果跳过中心带重建时,导出的字节必须与完整重算中心带的旧路径逐字相同。
func TestSolarEclipseMapSkipCentralBandMatchesFullPath(t *testing.T) {
for _, fixture := range []struct {
name string
date time.Time
}{
{name: "2024-04-08 total", date: time.Date(2024, time.April, 8, 0, 0, 0, 0, time.UTC)},
{name: "2010-01-15 annular", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)},
} {
t.Run(fixture.name, func(t *testing.T) {
options := SolarEclipseMapSVGOptions{
Width: 900, Height: 620, Location: time.UTC,
PartialStep: 10 * time.Minute, CentralStep: 10 * time.Minute,
}
normalized := normalizeSolarEclipseMapSVGOptions(fixture.date, options)
got, ok := SolarEclipseMapSVGNASABulletinSplitK(fixture.date, options)
if !ok {
t.Fatal("missing map")
}
global, ok := eclipsecore.SolarEclipseOnDateNASABulletinSplitK(fixture.date)
if !ok {
t.Fatal("missing eclipse")
}
partial, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(fixture.date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: normalized.PartialStep,
BoundaryPoints: normalized.BoundaryPoints,
CentralShadowStep: normalized.CentralShadowStep,
// 必须与渲染器同一组等时线电平,否则比较的是两次不同的请求。
GreatestTimeValues: solarEclipseGreatestTimeLevels(global, normalized),
MagnitudeValues: normalized.MagnitudeValues,
})
if !ok {
t.Fatal("missing partial footprints")
}
// 旧路径:中心路径不跳过中心带,渲染器不读取它的包络。
central, hasCentral := eclipsecore.SolarEclipseCentralPathNASABulletinSplitK(fixture.date, eclipsecore.SolarEclipsePathOptions{
Step: normalized.CentralStep,
TargetSpacingKM: normalized.TargetSpacingKM,
})
local, hasLocal := eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK(
partial.Eclipse.GreatestEclipse,
partial.Eclipse.GreatestLongitude,
partial.Eclipse.GreatestLatitude,
0,
)
projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, normalized.Projection)
geocentric, hasGeocentric := eclipsecore.SolarEclipseGeocentricPanelAt(fixture.date)
want := renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal,
geocentric, hasGeocentric, normalized, projection,
svgmap.GeoPoint{Longitude: partial.Eclipse.GreatestLongitude, Latitude: partial.Eclipse.GreatestLatitude})
if got != want {
t.Fatalf("reused central band changed the rendered document (%d vs %d bytes)", len(got), len(want))
}
})
}
}
// 自动投影只按半球守卫选择极区视图:focus 高但跨越赤道的事件必须留在等距圆柱视图。
func TestSolarEclipseMapAutoProjectionKeepsBothHemispheres(t *testing.T) {
date := time.Date(2026, time.August, 12, 0, 0, 0, 0, time.UTC)
options := SolarEclipseMapSVGOptions{Location: time.UTC, PartialStep: 10 * time.Minute}
auto, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing 2026-08-12 map")
}
options.Projection = EclipseMapProjectionEquirectangular
explicit, ok := SolarEclipseMapSVG(date, options)
if !ok {
t.Fatal("missing explicit equirectangular map")
}
if auto != explicit {
t.Fatal("auto projection promoted a two-hemisphere event to a polar view")
}
// 单半球事件仍必须自动选极区视图。
antarctic := time.Date(2021, time.December, 4, 0, 0, 0, 0, time.UTC)
partial, ok := eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK(antarctic, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 180,
})
if !ok {
t.Fatal("missing 2021-12-04 partial footprints")
}
central, hasCentral := eclipsecore.SolarEclipseCentralPathNASABulletinSplitK(antarctic, eclipsecore.SolarEclipsePathOptions{Step: 2 * time.Minute})
if got := resolveSolarEclipseMapProjection(partial, central, hasCentral, EclipseMapProjectionAuto); got != svgmap.ProjectionSouthPolar {
t.Fatalf("2021-12-04 auto projection = %v, want the south polar view", got)
}
}
// 包络守卫必须校验真正绘制的三条线:中心线按 2 km,限界按食带宽的四分之一。
func TestSolarEclipseCentralEnvelopeGuardChecksDrawnLines(t *testing.T) {
date := time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC)
partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 180,
})
if !ok {
t.Fatal("missing partial footprints")
}
central, ok := eclipsecore.SolarEclipseCentralPath(date, eclipsecore.SolarEclipsePathOptions{
Step: 2 * time.Minute, SkipCentralBand: true,
})
if !ok {
t.Fatal("missing central path")
}
if !solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, central) {
t.Fatal("authoritative envelope was rejected for its own eclipse")
}
strayed := central
strayed.SouthernLimit = solarEclipseShiftedLatitudes(central.SouthernLimit, -9)
if solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, strayed) {
t.Fatal("guard accepted a southern limit about 1000 km outside the envelope")
}
// 中心线单独南移约 2200 km:限界仍在容差内,只有中心线检查能拒绝它。
strayedAxis := central
strayedAxis.CenterLine = solarEclipseShiftedLatitudes(central.CenterLine, -20)
if solarEclipseCentralEnvelopeCoversPath(partial.CentralBandSegments, strayedAxis) {
t.Fatal("guard accepted a center line far outside the envelope")
}
// 回退到弦带时必须标注来源,消费方可区分权威包络与粗弦带。
var builder strings.Builder
frame := svgmap.Frame{Width: 3600, Height: 1800, Projection: svgmap.ProjectionEquirectangular}
writeSolarEclipseCentralPath(&builder, strayed, partial.CentralBandFootprints, partial.CentralBandSegments, frame, partial.Eclipse.Type)
if !strings.Contains(builder.String(), `<g class="central-eclipse-band" data-source="paired-limit-chords"`) {
t.Fatal("chord-band fallback is not marked with its source")
}
if strings.Contains(builder.String(), "besselian-critical-envelope") {
t.Fatal("straying limits still filled with the authoritative envelope")
}
}
// 采样扫掠回退同样要标注来源。
func TestSolarEclipseSampledSweepFallbackIsMarked(t *testing.T) {
date := time.Date(2043, time.April, 9, 0, 0, 0, 0, time.UTC)
partial, ok := eclipsecore.SolarEclipsePartialFootprints(date, eclipsecore.SolarEclipsePartialFootprintOptions{
Step: 10 * time.Minute, BoundaryPoints: 96, CentralShadowStep: 10 * time.Minute,
})
if !ok || len(partial.CentralShadowFootprints) == 0 {
t.Fatal("missing 2043 central-shadow footprints")
}
var builder strings.Builder
frame := svgmap.Frame{Width: 3600, Height: 1800, Projection: svgmap.ProjectionEquirectangular}
writeSolarEclipseCentralShadowSweep(&builder, partial.CentralShadowFootprints, frame, partial.Eclipse.Type)
if !strings.Contains(builder.String(), `<g class="solar-central-shadow-sweep" data-source="sampled-open-sweep"`) {
t.Fatal("sampled central-shadow sweep is not marked with its source")
}
}
// 升落阶段与方向进入 class 属性前必须转义。
func TestSolarEclipseRiseSetCurveClassNamesAreEscaped(t *testing.T) {
curves := []eclipsecore.SolarEclipseRiseSetCurve{{
Phase: eclipsecore.RiseSetPhase(`start" onload="alert(1)`),
Direction: eclipsecore.RiseSetDirection(`rise`),
Segments: [][]eclipsecore.SolarEclipsePathPoint{{
{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1},
}},
}}
var builder strings.Builder
frame := svgmap.Frame{Width: 360, Height: 180, Projection: svgmap.ProjectionEquirectangular}
writeSolarEclipseRiseSetCurves(&builder, curves, frame)
if strings.Contains(builder.String(), `start" onload=`) {
t.Fatal("rise/set phase was written into the class attribute unescaped")
}
if err := validateEclipseMapXML(builder.String()); err != nil {
t.Fatalf("escaped rise/set curve is not valid XML: %v", err)
}
}
func solarEclipseShiftedLatitudes(
points []eclipsecore.SolarEclipsePathPoint,
delta float64,
) []eclipsecore.SolarEclipsePathPoint {
shifted := make([]eclipsecore.SolarEclipsePathPoint, len(points))
copy(shifted, points)
for index := range shifted {
shifted[index].Latitude = math.Max(-90, math.Min(90, shifted[index].Latitude+delta))
}
return shifted
}