Files
astro/eclipse/svg/footer_margin_contract_test.go
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

233 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"encoding/xml"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。
// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。
const chartBottomMarginMinimum = 6.0
type chartTextElement struct {
x float64
y float64
fontSize float64
fill string
value string
}
func chartTextElements(t *testing.T, document string) []chartTextElement {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(document))
elements := []chartTextElement{}
stack := []string{}
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("chart is not valid XML: %v", err)
}
switch typed := token.(type) {
case xml.StartElement:
stack = append(stack, typed.Name.Local)
if typed.Name.Local != "text" {
continue
}
element := chartTextElement{}
for _, attribute := range typed.Attr {
switch attribute.Name.Local {
case "x":
element.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
element.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "fill":
element.fill = attribute.Value
}
}
elements = append(elements, element)
case xml.EndElement:
stack = stack[:len(stack)-1]
case xml.CharData:
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 {
elements[len(elements)-1].value += string(typed)
}
}
}
return elements
}
func chartBottomMarginViolations(t *testing.T, document string, height int) []string {
t.Helper()
violations := []string{}
for _, element := range chartTextElements(t, document) {
if strings.TrimSpace(element.value) == "" {
continue
}
_, below := svgchart.EstimatedTextExtents(element.fontSize)
if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum {
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum))
}
}
return violations
}
func chartFooterCases(t *testing.T) []struct {
name string
height int
document string
} {
t.Helper()
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
long := footerFitLongText("footer ")
cases := []struct {
name string
height int
document string
}{}
add := func(name string, height int, document string, ok bool) {
if !ok {
t.Fatalf("%s: chart missing", name)
}
cases = append(cases, struct {
name string
height int
document string
}{name, height, document})
}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
add("站心日食", 692, solar, ok)
solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long})
add("站心日食 窄画布长说明", 520, solarLong, ok)
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
add("站心月食", 640, lunar, ok)
lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long})
add("站心月食 窄画布长说明", 520, lunarLong, ok)
detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long})
add("月食详图 长说明", 1000, detailed, ok)
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
add("月食地图 长说明", 640, mapChart, ok)
solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long})
add("日食地图 长说明", 640, solarMap, ok)
return cases
}
func TestChartTextKeepsBottomMargin(t *testing.T) {
for _, tc := range chartFooterCases(t) {
if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
}
}
}
func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) {
cst := time.FixedZone("CST", 8*3600)
cases := []struct {
name string
height int
document string
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
if !ok {
t.Fatal("站心日食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心日食", 692, solar})
lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
if !ok {
t.Fatal("站心月食:缺图")
}
cases = append(cases, struct {
name string
height int
document string
}{"站心月食", 640, lunar})
for _, tc := range cases {
baselines := []float64{}
for _, element := range chartTextElements(t, tc.document) {
if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") {
continue
}
if strings.TrimSpace(element.value) == "" {
continue
}
baselines = append(baselines, element.y)
}
if len(baselines) < 3 {
t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines))
}
sort.Float64s(baselines)
step := svgchart.FooterLineHeight(12)
for index := 1; index < len(baselines); index++ {
if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 {
t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines)
}
}
if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 {
t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding)
}
}
}
// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。
func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) {
long := footerFitLongText("scale ")
cst := time.FixedZone("CST", 8*3600)
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
cases := []struct {
name string
document string
ok bool
}{}
solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0,
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心日食", solar, ok})
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"站心月食", lunar, ok})
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
cases = append(cases, struct {
name string
document string
ok bool
}{"月食地图", mapChart, ok})
for _, tc := range cases {
if !tc.ok {
t.Fatalf("%s:缺图", tc.name)
}
if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") {
t.Errorf("%s:超长说明把时标声明挤掉了", tc.name)
}
}
}