Files
tamabox/templates/zz_preview_test.go
T
tamakyi a3cf99a8da fix(stats): 环比句点明月份与条数,不再写「上月…环比 -75%」
「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份,
再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为
是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。

  改前:上月提问量环比 -75.0%。
  改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。

口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数
写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手
核对百分比(5620/4890 确实是 +14.9%)。

实现:
- `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September"
  ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。
- 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板,
  四种语言里「多了 / 少了」的位置和词形都不一样。
- `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。

测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」,
覆盖上升/下降/持平;`TestMonthText` 锁语言差异。

顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的
monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来,
截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
2026-10-25 09:27:57 +08:00

485 lines
19 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 templates
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/stats"
)
// TestGenPreview 仅用于本地肉眼核对样式改动,产出 outputs/preview-*.html。
// 不在 CI 里跑:需要 TEST_PREVIEW=1 才执行。
func TestGenPreview(t *testing.T) {
if os.Getenv("TEST_PREVIEW") != "1" {
t.Skip("set TEST_PREVIEW=1 to generate preview files")
}
outDir := filepath.Join("..", "outputs")
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.Fatal(err)
}
base := map[string]interface{}{
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "ja", "Name": "日本語"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"Title": "TamaBox",
"SiteStats": &stats.View{
RegisteredUsers: 1284, TotalQuestions: 56321, AnsweredQuestions: 31208,
LastQuestionAt: "2026-10-19 10:12:44",
LastAnswerAt: "2026-10-19 09:58:02",
UpdatedAt: "2026-10-19 10:00:00",
},
}
for _, theme := range []string{"light", "dark"} {
home, err := renderToString(t, "home", base)
if err != nil {
t.Fatalf("render home: %v", err)
}
writePreview(t, outDir, "preview-home-"+theme+".html", home, theme, "../static")
}
// 提问箱主页:带背景图(渐变占位)+ 浅色文字,验证遮罩方向
boxData := publicBaseData()
boxData["VConsole"] = false
bgURI := "data:image/svg+xml;utf8," +
strings.NewReplacer("#", "%23").Replace(
`<svg xmlns='http://www.w3.org/2000/svg' width='900' height='500'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#3b6ea5'/><stop offset='1' stop-color='#d98b6a'/></linearGradient></defs><rect width='900' height='500' fill='url(#g)'/></svg>`)
// 注意:模板读的是 .PageUser.Background,必须写进 PageUser 里,否则 {{ if .PageUser.Background }} 为假、遮罩类不会渲染
boxData["PageUser"].(map[string]interface{})["Background"] = bgURI
boxData["PageUser"].(map[string]interface{})["Usernamecolor"] = "#ffffff"
boxData["PageUser"].(map[string]interface{})["Introcolor"] = "#ffffff"
// 无头 Chrome 里 uk-img 的懒加载不一定触发,这里直接把背景图写进 CSS 便于核对遮罩
coverCSS := `<style>.tb-profile-cover{background-image:url("` + bgURI +
`");background-size:cover;background-position:center;}</style>`
for _, theme := range []string{"light", "dark"} {
box, err := renderToString(t, "question/list", boxData)
if err != nil {
t.Fatalf("render list: %v", err)
}
box = strings.Replace(box, "</head>", coverCSS+"</head>", 1)
writePreview(t, outDir, "preview-box-"+theme+".html", box, theme, "../static")
}
// 主题预览:与箱主页同一份数据,只是打开 sakura 主题(验证令牌覆盖与深色块)
themeBox := publicBaseData()
themeBox["VConsole"] = false
themeBox["ThemeSlug"] = "sakura"
themeBox["PageUser"].(map[string]interface{})["Background"] = bgURI
themeBox["PageUser"].(map[string]interface{})["Usernamecolor"] = "#3a2b30"
themeBox["PageUser"].(map[string]interface{})["Introcolor"] = "#6b545c"
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "question/list", themeBox)
if err != nil {
t.Fatalf("render themed list: %v", err)
}
page = inlineThemeCSS(t, page, "sakura")
writePreview(t, outDir, "preview-theme-sakura-"+theme+".html", page, theme, "../static")
}
// 探索页:核对令牌替换后的卡片/列表配色
explore := voteBaseData()
explore["LangCode"] = "zh-CN"
explore["IsUserPage"] = false
explore["Keyword"] = ""
explore["Sort"] = "latest"
explore["Page"] = 1
explore["TotalPages"] = 1
explore["PrevPage"] = 0
explore["NextPage"] = 2
explore["HotTags"] = []string{"杂谈", "学习", "音乐"}
explore["HotVotes"] = nil
explore["AllVotes"] = nil
explore["RecommendUsers"] = []interface{}{}
explore["Users"] = []map[string]interface{}{
{"Name": "小狼", "Domain": "tamakyi", "Avatar": "", "Intro": "随便问,友善一点,看到都会回。", "Tags": "杂谈,日常"},
{"Name": "Kitsune", "Domain": "kitsune", "Avatar": "", "Intro": "摄影 / 音乐 / 一点点代码", "Tags": "摄影"},
{"Name": "残响", "Domain": "echo", "Avatar": "", "Intro": "长期潜水,偶尔冒泡。", "Tags": "杂谈"},
{"Name": "Mika", "Domain": "mika", "Avatar": "", "Intro": "想认识更多有趣的人。", "Tags": "学习,音乐"},
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/explore", explore)
if err != nil {
t.Fatalf("render explore: %v", err)
}
writePreview(t, outDir, "preview-explore-"+theme+".html", page, theme, "../static")
}
// 设置页「内容防护」面板:核对是否与其它面板同样是白底卡片
profile := profileBaseData()
// 主题选择器(account 面板):用固定假数据,避免测试依赖 conf 的 [theme] 段
profile["Themes"] = []map[string]interface{}{
{"Slug": "default", "Swatch": ""},
{"Slug": "sakura", "Swatch": "linear-gradient(135deg, #fff6f8 0 55%, #e5678f 55% 100%)"},
}
profile["CurrentTheme"] = "sakura"
profile["ActiveProfileTab"] = "security"
profile["FeatureQuestion"] = nil
profile["FeatureChat"] = nil
profile["FeatureVote"] = nil
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/profile", profile)
if err != nil {
t.Fatalf("render profile: %v", err)
}
writePreview(t, outDir, "preview-profile-"+theme+".html", page, theme, "../static")
}
// 设置页「账号」面板:主题选择器的实际排版(卡片在面板末尾)
profileAccount := profileBaseData()
profileAccount["Themes"] = profile["Themes"]
profileAccount["CurrentTheme"] = "sakura"
profileAccount["ActiveProfileTab"] = "account"
profileAccount["FeatureQuestion"] = nil
profileAccount["FeatureChat"] = nil
profileAccount["FeatureVote"] = nil
for _, theme := range []string{"light"} {
page, err := renderToString(t, "user/profile", profileAccount)
if err != nil {
t.Fatalf("render account profile: %v", err)
}
writePreview(t, outDir, "preview-profile-account-"+theme+".html", page, theme, "../static")
}
// 设置页在箱主主题下的样子:/user 后台现在也会跟随本人主题,
// 需要确认「粉色页面 + 主题选择器」不会互相吃掉。
profileSakura := profileBaseData()
profileSakura["Themes"] = profile["Themes"]
profileSakura["CurrentTheme"] = "sakura"
profileSakura["ActiveProfileTab"] = "account"
profileSakura["ThemeSlug"] = "sakura"
profileSakura["FeatureQuestion"] = nil
profileSakura["FeatureChat"] = nil
profileSakura["FeatureVote"] = nil
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "user/profile", profileSakura)
if err != nil {
t.Fatalf("render themed profile: %v", err)
}
page = inlineThemeCSS(t, page, "sakura")
writePreview(t, outDir, "preview-profile-sakura-"+theme+".html", page, theme, "../static")
}
// ---- 以下是「主题生成器」的预览画板素材 ----
// dev/theme-builder 不再自己画一个仿制页面,而是直接拿这里的真实渲染结果当 iframe 画板;
// 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html
// 的 SCOPES 列表,否则生成器里选不到。
writeQuestionItemPreviews(t, outDir)
writeQuestionOwnerPreviews(t, outDir)
writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir)
writeStatsPreviews(t, outDir)
}
// writeStatsPreviews 全站数据页(/stats)。
// 四种组合都出:亮/暗确认令牌适配,中/英确认英文长句不会把卡片挤变形。
func writeStatsPreviews(t *testing.T, outDir string) {
t.Helper()
now := time.Now()
daily := make([]stats.DailyPoint, 0, 30)
var peak stats.DailyPoint
for i := 0; i < 30; i++ {
d := now.AddDate(0, 0, -(29 - i))
n := int64(38 + (i*11)%47 + (i%5)*13)
p := stats.DailyPoint{Date: d.Format("2006-01-02"), Label: d.Format("01-02"), N: n}
daily = append(daily, p)
if p.N >= peak.N {
peak = p
}
}
monthly := make([]stats.MonthlyPoint, 0, 12)
for i := 0; i < 12; i++ {
m := now.AddDate(0, -(11 - i), 0)
monthly = append(monthly, stats.MonthlyPoint{
Month: m.Format("2006-01"), Label: m.Format("01"),
N: int64(2100 + i*310 + (i%3)*420), Current: i == 11,
})
}
// 热力图:造出「深夜 + 周末」两个高峰,便于肉眼确认档位分得开。
var heatmap [7][24]int64
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
v := int64(30 + (h%12)*16)
if h >= 21 || h <= 1 {
v += 320
}
if h >= 12 && h <= 14 {
v += 90
}
if d >= 5 {
v += 120
}
heatmap[d][h] = v
}
}
ov := &stats.Overview{
RegisteredUsers: 1284, TotalQuestions: 56321, Answered: 31208,
Public: 41002, Private: 15319, Read: 40122, Unread: 16199,
Anonymous: 22110, LoggedIn: 34211,
ActiveBoxes: 412, AnsweringBoxes: 268, TotalMessages: 8842,
LastQuestionAt: "2026-10-22 23:41:07", LastAnswerAt: "2026-10-22 23:12:33",
AnswerRate: 55.4, FastReplyRate: 38.6, Total30: 2148, AvgDailyLast30: 71.6,
// 环比从预览数据算出来,不写死:句子里会同时印出两个月的条数,
// 写死的百分比和它对不上,截图就成了假证据。
GrowthMoM: (float64(monthly[10].N) - float64(monthly[9].N)) / float64(monthly[9].N) * 100, HasPrevMonth: true,
TodayQuestions: 37, TodayAnswers: 21,
Daily: daily, Monthly: monthly, PeakDay: peak, DailyMax: peak.N, MonthlyMax: 6180,
BusiestWD: 6, BusiestHr: 23,
Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180,
LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821},
ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908},
UpdatedAt: now.Format("2006-01-02") + " 00:07:12",
}
for _, lang := range []string{"zh-CN", "en"} {
data := map[string]interface{}{
"Title": "全站数据",
"LangCode": lang,
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/stats",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"ICP": "",
"Overview": stats.NewPage(lang, ov),
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "stats", data)
if err != nil {
t.Fatalf("render stats: %v", err)
}
writePreview(t, outDir, "preview-stats-"+lang+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。
// 这一页正文长、卡片高,装饰层最容易挡住内容,所以必须进预览画板。
func writeQuestionItemPreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": "沿着河堤一直往西,到海边的旧仓库就掉头。",
"CreatedAt": time.Now(), "UpdatedAt": time.Now(), "IsPrivate": false, "IsPinned": false,
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["FollowUpRemaining"] = 0
data["FollowUpLimit"] = 3
data["IsOwnPage"] = true
data["IsQuestioner"] = false
data["IsBlocked"] = false
data["AllowChat"] = true
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item: %v", err)
}
writePreview(t, outDir, "preview-question-"+theme+".html", page, theme, "../static")
}
}
// writeQuestionOwnerPreviews 单题页的箱主视角:回答行有「回答并下一条」、页脚有「下一条未读」,
// 未回答 / 已回答两种状态都要能肉眼核对排版。
func writeQuestionOwnerPreviews(t *testing.T, outDir string) {
t.Helper()
for _, state := range []struct {
name string
answer string
}{
{"unanswered", ""},
{"answered", "沿着河堤一直往西,到海边的旧仓库就掉头。"},
} {
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["CanDelete"] = true
data["IsOwnPage"] = true
data["NextUnreadID"] = uint(2)
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": state.answer,
"CreatedAt": time.Now(), "UpdatedAt": time.Now(),
"IsPrivate": false, "IsPinned": false, "IsRead": state.answer != "",
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["IsQuestioner"] = false
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item owner: %v", err)
}
writePreview(t, outDir, "preview-question-owner-"+state.name+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeAdminListPreviews 箱主后台的问题列表(/user/questions)。
func writeAdminListPreviews(t *testing.T, outDir string) {
t.Helper()
type adminItem struct {
Type, Content, Link, PinLink string
CreatedAt time.Time
IsAnswered, IsRead, IsPrivate bool
IsBlocked, IsPinned bool
QuestionID uint
ReportCount int64
}
type adminConv struct {
Token string
CreatedAt time.Time
}
type adminConvDetail struct {
Conversation *adminConv
OwnerDomain string
OwnerName string
FirstMessage string
IsBlocked bool
}
for _, theme := range []string{"light", "dark"} {
data := map[string]interface{}{
"Title": "我的提问",
"LangCode": "zh-CN",
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}},
"IsLogged": true,
"LoggedUser": map[string]interface{}{"Domain": "test", "Name": "tester"},
"LoggedUserName": "tester",
"CurrentURI": "/user/questions",
"IsUserPage": true,
"IsPixel": false,
"VConsole": false,
"CaptchaType": "math",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
"Stats": map[string]int64{
"total": 3, "answered": 2, "unanswered": 1, "read": 2, "unread": 1, "private": 1, "pinned": 1,
},
"ChatStats": map[string]int64{"total": 2, "received": 1, "initiated": 1, "closed": 0},
"Keyword": "",
"SelectedSort": "latest",
"SelectedStatus": "all",
"SelectedRead": "all",
"SelectedType": "all",
"ReceivedItems": []interface{}{
adminItem{Type: "question_public", CreatedAt: time.Now(), Content: "你平时一个人骑车会去哪里?",
Link: "/_/test/1", IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"},
adminItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话:最近还好吗",
Link: "/_/test/2", IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"},
adminItem{Type: "question_public", CreatedAt: time.Date(2025, 9, 5, 4, 45, 22, 0, time.Local), Content: "光环是什么材质的?",
Link: "/_/test/3", IsAnswered: true, IsRead: false, QuestionID: 3, PinLink: "/_/test/3/pin"},
},
"ReceivedChats": []interface{}{
adminItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc"},
},
"ConversationDetails": []interface{}{
adminConvDetail{Conversation: &adminConv{Token: "abc", CreatedAt: time.Now()},
OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi"},
},
}
page, err := renderToString(t, "user/question-list", data)
if err != nil {
t.Fatalf("render user/question-list: %v", err)
}
writePreview(t, outDir, "preview-admin-"+theme+".html", page, theme, "../static")
}
}
// writeVotePreviews 投票页(/vote/{token}):语言、主题都跟发布者,是唯一会「跨箱主」的页面。
func writeVotePreviews(t *testing.T, outDir string) {
t.Helper()
for _, theme := range []string{"light", "dark"} {
data := voteBaseData()
data["Vote"] = &db.Vote{
Title: "下次直播想看我做什么?", Description: "随便聊聊,投票结果仅供参考。",
Type: db.VoteTypeSingle, LinkToken: "abc123", MaxChoices: 1,
}
data["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
data["Options"] = []*db.VoteOption{
{Content: "一起骑车去海边"}, {Content: "读信 + 回答提问"}, {Content: "整理旧照片"},
}
data["HasVoted"] = false
data["CanVote"] = true
data["NotStarted"] = false
data["Ended"] = false
data["VoteScopeRegisteredOnly"] = false
data["ShowResults"] = false
data["IsOwner"] = false
page, err := renderToString(t, "vote/page", data)
if err != nil {
t.Fatalf("render vote/page: %v", err)
}
writePreview(t, outDir, "preview-vote-"+theme+".html", page, theme, "../static")
}
}
// inlineThemeCSS 把主题 <link> 换成内联 style:预览页走 file://,相对路径取不到。
// 预览数据里没有 StaticVersion,v 参数为空串,所以按正则匹配而不是固定串替换。
func inlineThemeCSS(t *testing.T, html, slug string) string {
t.Helper()
css, err := os.ReadFile(filepath.Join("..", "static", "css", "themes", slug+".css"))
if err != nil {
t.Fatalf("read theme css: %v", err)
}
link := regexp.MustCompile(`<link rel="stylesheet" href="/static/css/themes/` + slug + `\.css\?v=[^"]*"/>`)
if !link.MatchString(html) {
t.Fatalf("主题 %s 的 <link> 未出现在页面里(ThemeSlug 没传到模板)", slug)
}
return link.ReplaceAllString(html, "<style>"+string(css)+"</style>")
}
// writePreview 把 CDN 换成可直连的镜像、钉死主题,仅用于本地预览。
func writePreview(t *testing.T, dir, name, html, theme, staticPrefix string) {
t.Helper()
html = strings.ReplaceAll(html, "https://cdnjs.snrat.com/", "https://cdnjs.cloudflare.com/")
if theme == "dark" {
// <html> 上可能还有 data-theme-style,只在这个开标签内判断/插入;
// 不能全文搜索 —— 内联的主题 css 里有 html[data-theme="dark"] 选择器
if i := strings.Index(html, "<html"); i >= 0 {
if j := strings.Index(html[i:], ">"); j > 0 {
tag := html[i : i+j]
if !strings.Contains(tag, "data-theme=") {
html = html[:i+j] + ` data-theme="dark"` + html[i+j:]
}
}
}
}
html = strings.ReplaceAll(html, `href="/favicon.ico"`, `href="`+staticPrefix+`/favicon.ico"`)
// base.css 也要改成本地相对路径:预览页走 file://,"/static/base.css" 是绝对路径取不到,
// 于是 :root 上的设计令牌全部缺失 —— var(--tb-*) 变成无效值,卡片没边框、图表全透明。
html = strings.ReplaceAll(html, `href="/static/`, `href="`+staticPrefix+`/`)
html = strings.Replace(html, `<title>`, `<title>预览 `+time.Now().Format("15:04")+` · `, 1)
if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil {
t.Fatal(err)
}
}