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

247 lines
9.5 KiB
Go
Raw 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 svgchart
import (
"reflect"
"strings"
"testing"
)
// 折行与截断必须与 EstimatedTextWidth/EstimatedTextExtents 同口径:留下的每一行都不超宽,
// 截断后的行数不超过高度预算,且末行以省略号结尾。
func textFits(t *testing.T, lines []string, maxWidth, fontSize float64) {
t.Helper()
for _, line := range lines {
if width := EstimatedTextWidth(line, fontSize); width > maxWidth {
t.Fatalf("line %q width %.1f exceeds %.1f", line, width, maxWidth)
}
}
}
func TestWrapTextKeepsEveryLineInsideWidth(t *testing.T) {
for _, test := range []struct {
name string
value string
maxWidth float64
fontSize float64
}{
{name: "ascii words", value: strings.Repeat("alpha beta gamma ", 20), maxWidth: 220, fontSize: 11},
{name: "chinese runs", value: strings.Repeat("月球掩星示意图说明", 30), maxWidth: 180, fontSize: 11},
{name: "mixed", value: strings.Repeat("掩星 occultation 说明;", 40), maxWidth: 260, fontSize: 9},
{name: "single word", value: strings.Repeat("A", 200), maxWidth: 120, fontSize: 10},
} {
t.Run(test.name, func(t *testing.T) {
lines := WrapText(test.value, test.maxWidth, test.fontSize)
if len(lines) < 2 {
t.Fatalf("lines = %d, want the text to be wrapped", len(lines))
}
textFits(t, lines, test.maxWidth, test.fontSize)
})
}
}
func TestWrapTextKeepsShortTextOnOneLine(t *testing.T) {
lines := WrapText(" 短说明 short note ", 400, 11)
if len(lines) != 1 || lines[0] != "短说明 short note" {
t.Fatalf("lines = %#v, want one trimmed line", lines)
}
if lines := WrapText(" ", 400, 11); lines != nil {
t.Fatalf("blank text lines = %#v, want nil", lines)
}
}
func TestTruncateTextLinesMarksTheCut(t *testing.T) {
lines := []string{"第一行", "第二行", "第三行", "第四行"}
kept := TruncateTextLines(lines, 400, 11, 2)
if len(kept) != 2 || kept[0] != "第一行" {
t.Fatalf("kept = %#v, want the first two lines", kept)
}
if !strings.HasSuffix(kept[1], ellipsisMark) {
t.Fatalf("last kept line = %q, want the ellipsis mark", kept[1])
}
if kept := TruncateTextLines(lines, 400, 11, 9); len(kept) != 4 || kept[3] != "第四行" {
t.Fatalf("kept = %#v, want the untouched input", kept)
}
}
func TestTruncateTextLinesEllipsizesTheLastLineToWidth(t *testing.T) {
lines := []string{strings.Repeat("宽", 20), strings.Repeat("宽", 20)}
kept := TruncateTextLines(lines, 60, 10, 1)
if len(kept) != 1 || !strings.HasSuffix(kept[0], ellipsisMark) {
t.Fatalf("kept = %#v, want one ellipsized line", kept)
}
textFits(t, kept, 60, 10)
}
func TestFooterLinesWithScaleRespectsLineLimit(t *testing.T) {
notes := []string{"说明一"}
scale := "图中时刻为 UTC"
for _, test := range []struct {
name string
maxLines int
scaleNote string
want []string
}{
{name: "no lines", maxLines: 0, scaleNote: scale},
{name: "negative lines", maxLines: -1, scaleNote: scale},
{name: "scale only", maxLines: 1, scaleNote: scale, want: []string{scale}},
{name: "notes and scale", maxLines: 2, scaleNote: scale, want: []string{"说明一", scale}},
{name: "notes without scale", maxLines: 1, want: []string{"说明一"}},
} {
t.Run(test.name, func(t *testing.T) {
got := FooterLinesWithScale(notes, test.scaleNote, 400, 11, test.maxLines)
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("lines = %#v, want %#v", got, test.want)
}
if test.maxLines > 0 && len(got) > test.maxLines {
t.Fatalf("lines = %d, want no more than %d", len(got), test.maxLines)
}
})
}
}
func TestTextLineLimitCountsExtents(t *testing.T) {
// 3 行 11 号字、行距 15 的总高约 42.65,预算再少一点就只放得下 2 行。
if limit := TextLineLimit(11, 15, 42.7); limit != 3 {
t.Fatalf("limit = %d, want 3", limit)
}
if limit := TextLineLimit(11, 15, 42.6); limit != 2 {
t.Fatalf("limit = %d, want 2", limit)
}
if limit := TextLineLimit(11, 15, 1); limit != 1 {
t.Fatalf("limit = %d, want the one-line floor", limit)
}
// 基线口径:首行基线 100,第 2 行要排得下需要底线不低于 100+15+0.3*11。
if limit := BaselineLineLimit(11, 15, 100, 118.4); limit != 2 {
t.Fatalf("limit = %d, want 2", limit)
}
if limit := BaselineLineLimit(11, 15, 100, 118.2); limit != 1 {
t.Fatalf("limit = %d, want 1", limit)
}
}
func TestEllipsizeTextStaysInsideWidth(t *testing.T) {
value := strings.Repeat("长文本 ellipsis ", 30)
clamped := EllipsizeText(value, 150, 11)
if !strings.HasSuffix(clamped, ellipsisMark) {
t.Fatalf("clamped = %q, want the ellipsis mark", clamped)
}
textFits(t, []string{clamped}, 150, 11)
if short := EllipsizeText("短", 150, 11); short != "短" {
t.Fatalf("short text = %q, want it untouched", short)
}
if empty := EllipsizeText(value, 1, 11); empty != "" {
t.Fatalf("clamped = %q, want an empty string when even the mark does not fit", empty)
}
}
// 均衡折行只改断行位置:行数不超过同宽度下的 WrapText,行宽互相接近,且逐行仍在 maxWidth 内。
// textRunes 拼掉断行处被吃掉的空白,用于比较两种折行的字符内容。
func textRunes(lines []string) string {
return strings.Join(strings.Fields(strings.Join(lines, " ")), "")
}
func textSpread(lines []string, fontSize float64) float64 {
minimum, maximum := 0.0, 0.0
for index, line := range lines {
width := EstimatedTextWidth(line, fontSize)
if index == 0 || width < minimum {
minimum = width
}
if index == 0 || width > maximum {
maximum = width
}
}
if minimum <= 0 {
return 0
}
return maximum / minimum
}
func TestWrapTextBalancedKeepsLineCountAndWidth(t *testing.T) {
for _, test := range []struct {
name string
value string
maxWidth float64
fontSize float64
sameCount bool
wantSpread bool
}{
{name: "english prose", maxWidth: 500, fontSize: 10, sameCount: true, wantSpread: true,
value: "Moon fixed at center; east is left and north is up. The blue-gray dashed line is the local lunar path; the red dashed line is the star track."},
{name: "chinese with one space", maxWidth: 420, fontSize: 12, wantSpread: true,
value: "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)"},
{name: "mixed page", maxWidth: 300, fontSize: 9,
value: strings.Repeat("月球掩星 occultation 说明;", 8)},
} {
t.Run(test.name, func(t *testing.T) {
greedy := WrapText(test.value, test.maxWidth, test.fontSize)
balanced := WrapTextBalanced(test.value, test.maxWidth, test.fontSize)
if len(balanced) > len(greedy) {
t.Fatalf("balanced lines = %d, want no more than the greedy %d", len(balanced), len(greedy))
}
if test.sameCount && len(balanced) != len(greedy) {
t.Fatalf("balanced lines = %d, want the greedy %d", len(balanced), len(greedy))
}
if len(balanced) < 2 {
t.Fatalf("balanced lines = %d, want the text to be wrapped", len(balanced))
}
textFits(t, balanced, test.maxWidth, test.fontSize)
if got, want := textRunes(balanced), textRunes(greedy); got != want {
t.Fatalf("balanced text = %q, want the same runes as %q", got, want)
}
spread := textSpread(balanced, test.fontSize)
if test.sameCount && spread > textSpread(greedy, test.fontSize) {
t.Fatalf("balanced spread = %.3f, want no worse than the greedy %.3f (%#v)",
spread, textSpread(greedy, test.fontSize), balanced)
}
if test.wantSpread && spread > 1.15 {
t.Fatalf("balanced spread = %.3f, want <= 1.15 (%#v)", spread, balanced)
}
})
}
}
// 中文里只有一个空格时,WrapText 会为躲开西文词把首行回退到该空格,WrapTextBalanced 允许在全角字符前断开,不再留下短行。
func TestWrapTextBalancedBreaksFullWidthTextWithoutShortTail(t *testing.T) {
value := "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。 图中时刻为 UTC(显示时区 UTC+08:00)"
greedy := WrapText(value, 420, 12)
if len(greedy) < 3 {
t.Fatalf("greedy lines = %#v, want the whitespace backoff to cost extra lines", greedy)
}
if width := EstimatedTextWidth(greedy[0], 12); width > 0.5*420 {
t.Fatalf("greedy first line = %q (%.1f), want the short backoff line", greedy[0], width)
}
balanced := WrapTextBalanced(value, 420, 12)
if len(balanced) >= len(greedy) {
t.Fatalf("balanced lines = %d, want fewer than the greedy %d", len(balanced), len(greedy))
}
textFits(t, balanced, 420, 12)
if len(balanced) != 2 || textSpread(balanced, 12) > 1.15 {
t.Fatalf("balanced = %#v, want two lines of close width", balanced)
}
}
func TestWrapTextBalancedLinesFillsSlots(t *testing.T) {
value := strings.Repeat("月球掩星 occultation 说明;", 2)
filled := WrapTextBalancedLines(value, 400, 9, 2)
if len(filled) != 2 {
t.Fatalf("filled lines = %d, want 2", len(filled))
}
textFits(t, filled, 400, 9)
if spread := textSpread(filled, 9); spread > 1.15 {
t.Fatalf("filled spread = %.3f, want <= 1.15 (%#v)", spread, filled)
}
if natural := WrapTextBalanced(value, 400, 9); len(natural) != 1 {
t.Fatalf("natural lines = %d, want the natural wrap to keep one line", len(natural))
}
if lines := WrapTextBalancedLines(value, 400, 9, 0); len(lines) != 1 {
t.Fatalf("lines = %d, want maxLines 0 to fall back to the natural wrap", len(lines))
}
// 槽位放不下时不强行行数:返回可用宽度下的折行结果,由调用方截断。
long := strings.Repeat("月球掩星 occultation 说明;", 12)
if lines := WrapTextBalancedLines(long, 400, 9, 2); len(lines) < 2 {
t.Fatalf("lines = %d, want the width-limited wrap", len(lines))
}
}