Files
astro/eclipse/svg/footer_margin_contract_test.go
T

233 lines
7.8 KiB
Go
Raw Normal View History

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