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

203 lines
6.8 KiB
Go

package svg
import (
"encoding/xml"
"io"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/internal/svgchart"
)
// 调用方给图题、面板标题与页脚传超长文本时,产物必须留在画布内并在截断处给出省略号;
// 判定口径与 internal/svgchart 的 EstimatedTextWidth/EstimatedTextExtents 一致。
type footerFitText struct {
value string
x, y float64
fontSize float64
anchor string
}
func footerFitLongText(prefix string) string {
piece := prefix + "long custom text 自定义长文本,用于验证折行与截断;"
value := ""
for len([]rune(value)) < 640 {
value += piece
}
return value
}
func footerFitTexts(t *testing.T, document string) []footerFitText {
t.Helper()
decoder := xml.NewDecoder(strings.NewReader(document))
texts := []footerFitText{}
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 element := token.(type) {
case xml.StartElement:
stack = append(stack, element.Name.Local)
if element.Name.Local != "text" {
continue
}
text := footerFitText{fontSize: 12}
for _, attribute := range element.Attr {
switch attribute.Name.Local {
case "x":
text.x, _ = strconv.ParseFloat(attribute.Value, 64)
case "y":
text.y, _ = strconv.ParseFloat(attribute.Value, 64)
case "font-size":
text.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
case "text-anchor":
text.anchor = attribute.Value
}
}
texts = append(texts, text)
case xml.EndElement:
stack = stack[:len(stack)-1]
case xml.CharData:
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(texts) > 0 {
texts[len(texts)-1].value += string(element)
}
}
}
return texts
}
func footerFitViolations(t *testing.T, document string, width, height int) []string {
t.Helper()
violations := []string{}
for _, text := range footerFitTexts(t, document) {
textWidth := svgchart.EstimatedTextWidth(text.value, text.fontSize)
left := text.x
switch text.anchor {
case "middle":
left = text.x - textWidth/2
case "end":
left = text.x - textWidth
}
above, below := svgchart.EstimatedTextExtents(text.fontSize)
if text.x < 0 || text.x > float64(width) || text.y < 0 || text.y > float64(height) {
violations = append(violations, text.value)
continue
}
if left < 0 || left+textWidth > float64(width) || text.y-above < 0 || text.y+below > float64(height) {
violations = append(violations, text.value)
}
}
return violations
}
func TestSolarEclipseMapSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("solar ")
if runes := len([]rune(long)); runes < 300 || runes > 800 {
t.Fatalf("long text runes = %d, want 300..800", runes)
}
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
for _, size := range [][2]int{{800, 560}, {960, 640}, {1000, 1414}} {
diagram, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{
Width: size[0], Height: size[1], Title: long, MapTitle: long, FooterNote: long,
})
if !ok {
t.Fatalf("%dx%d: missing chart", size[0], size[1])
}
if violations := footerFitViolations(t, diagram, size[0], size[1]); len(violations) > 0 {
t.Fatalf("%dx%d: %d texts outside the canvas, first = %q", size[0], size[1], len(violations), violations[0])
}
if !strings.Contains(diagram, "…") {
t.Fatalf("%dx%d: truncated custom text is not marked", size[0], size[1])
}
}
}
func TestLunarEclipseDetailedSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("lunar ")
date := time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC)
// 1000x1000 是该版式能装下地图与数据块的最小高度,1000x1414 是整页开本。
for _, size := range [][2]int{{1000, 1000}, {1000, 1414}} {
diagram, ok := LunarEclipseDetailedSVG(date, LunarEclipseDetailedSVGOptions{
Width: size[0], Height: size[1], Title: long, FooterNote: long,
})
if !ok {
t.Fatalf("%dx%d: missing chart", size[0], size[1])
}
if violations := footerFitViolations(t, diagram, size[0], size[1]); len(violations) > 0 {
t.Fatalf("%dx%d: %d texts outside the canvas, first = %q", size[0], size[1], len(violations), violations[0])
}
if !strings.Contains(diagram, "…") {
t.Fatalf("%dx%d: truncated custom text is not marked", size[0], size[1])
}
}
}
func TestLunarEclipseMapAndSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("moon ")
date := time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC)
mapDiagram, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
Width: 960, Height: 640, Title: long, FooterNote: long,
})
if !ok {
t.Fatal("missing lunar-eclipse map")
}
if violations := footerFitViolations(t, mapDiagram, 960, 640); len(violations) > 0 {
t.Fatalf("map: %d texts outside the canvas, first = %q", len(violations), violations[0])
}
if !strings.Contains(mapDiagram, "…") {
t.Fatal("map: truncated custom text is not marked")
}
diagram, ok := LunarEclipseSVG(date, LunarEclipseSVGOptions{Title: long, FooterNote: long})
if !ok {
t.Fatal("missing lunar-eclipse diagram")
}
if violations := footerFitViolations(t, diagram, 960, 620); len(violations) > 0 {
t.Fatalf("diagram: %d texts outside the canvas, first = %q", len(violations), violations[0])
}
if !strings.Contains(diagram, "…") {
t.Fatal("diagram: truncated custom text is not marked")
}
}
func TestLocalSolarEclipseSVGCustomLongTextStaysInsideCanvas(t *testing.T) {
long := footerFitLongText("local ")
date := time.Date(2024, 4, 8, 18, 0, 0, 0, time.UTC)
diagram, ok := LocalSolarEclipseSVG(date, -90, 35, 0, LocalSolarEclipseSVGOptions{
Title: long, SummaryText: long, GreatestText: long, MetaText: long,
DirectionText: long, FooterNote: long, Location: time.UTC,
})
if !ok {
t.Fatal("missing local solar-eclipse chart")
}
if violations := footerFitViolations(t, diagram, 920, 720); len(violations) > 0 {
t.Fatalf("%d texts outside the canvas, first = %q", len(violations), violations[0])
}
if !strings.Contains(diagram, "…") {
t.Fatal("truncated custom text is not marked")
}
}
// 自动生成的默认文案不得进入截断分支:这些产物在改动前后逐字节一致,出现省略号即说明分流写错。
func TestEclipseSVGDefaultTextIsNotTruncated(t *testing.T) {
date := time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC)
for _, language := range []string{"zh", "en"} {
solar, ok := SolarEclipseMapSVG(date, SolarEclipseMapSVGOptions{Language: language})
if !ok || strings.Contains(solar, "…") {
t.Fatalf("solar map %s: ok=%v, default text must stay unmarked", language, ok)
}
lunar, ok := LunarEclipseDetailedSVG(time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
LunarEclipseDetailedSVGOptions{Language: language})
if !ok || strings.Contains(lunar, "…") {
t.Fatalf("lunar detailed %s: ok=%v, default text must stay unmarked", language, ok)
}
}
}