Files
tamabox/templates/vote_templates_test.go
T
2026-09-08 10:10:44 +08:00

466 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 (
gotemplate "html/template"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/flamego/template"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
)
// voteBaseData 构造投票模板渲染的基础数据(仿 render_test.go 的 base)。
func voteBaseData() map[string]interface{} {
return 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": "/vote/abc123",
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CSRFTokenHTML": gotemplate.HTML(`<input type="hidden" name="_csrf" value="x">`),
"RecaptchaSiteKey": "",
"CaptchaType": "recaptcha",
"PageUser": map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": ""},
"ICP": "",
// 参与端自定义补充选项的字数上限(生产由 route/vote 注入 conf.Limits.CustomAnswerLen)
"MaxCustomTextLen": conf.Limits.CustomAnswerLen,
"CustomFieldMarker": db.CustomFieldMarker,
// 站长侧单个选项文本上限(生产由 route/user 注入 conf.Limits.VoteOptionLen)
"MaxVoteOptionLen": conf.Limits.VoteOptionLen,
// [limits] 全站上限(生产由 Contexter 全局注入 conf.Limits),
// 供 question/list 嵌套的提问框 maxlength 同源
"Limits": conf.Limits,
}
}
func TestVoteFormUsesFixedDeviceLimitNotice(t *testing.T) {
fs, err := template.EmbedFS(FS, ".", []string{".html"})
if err != nil {
t.Fatalf("EmbedFS: %v", err)
}
for _, f := range fs.Files() {
if f.Name() != "user/vote-form" && f.Name() != "user/vote-form.html" {
continue
}
data, err := f.Data()
if err != nil {
t.Fatalf("get template data: %v", err)
}
source := string(data)
if strings.Contains(source, `name="max_votes_per_fingerprint"`) {
t.Fatal("fixed fingerprint limit must not be rendered as a form input")
}
if !strings.Contains(source, `vote.formMaxVotesPerFingerprint`) {
t.Fatal("fixed fingerprint limit notice is missing")
}
return
}
t.Fatal("user/vote-form.html not found")
}
func TestRenderVotePage(t *testing.T) {
opt1 := &db.VoteOption{Content: "选项 A"}
opt2 := &db.VoteOption{Content: "选项 B"}
t.Run("single can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "测试单选", Description: "描述", Type: db.VoteTypeSingle, LinkToken: "abc123", MaxChoices: 1}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (single) failed: %v", err)
}
})
t.Run("multiple can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "多选", Type: db.VoteTypeMultiple, LinkToken: "abc123", MaxChoices: 2}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2, {Content: "选项 C"}}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (multiple) failed: %v", err)
}
})
t.Run("rating can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "评分", Type: db.VoteTypeRating, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (rating) failed: %v", err)
}
})
t.Run("rank can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "排序", Type: db.VoteTypeRank, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (rank) failed: %v", err)
}
})
t.Run("preview draft form", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "草稿表单", Type: db.VoteTypeForm, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20},
{Model: dbutil.Model{ID: 2}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = true
base["IsLogged"] = true
base["PreviewMode"] = true
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (preview) failed: %v", err)
}
})
t.Run("custom text limit injected", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "自定义选项", Type: db.VoteTypeSingle, LinkToken: "abc123", AllowCustom: true}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`, AllowCustom: true},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
out, err := renderToString(t, "vote/page", base)
if err != nil {
t.Fatalf("render vote/page (custom) failed: %v", err)
}
// 缺注入时模板不报错,只会渲染出 <no value> 让整段脚本失效,必须显式断言
if strings.Contains(out, "<no value>") {
t.Fatal("rendered page contains <no value>: MaxCustomTextLen was not injected")
}
// 注意:html/template 在 <script> 上下文会主动插入安全空格,
// 实际输出形如 `var MAX_CUSTOM_LEN = 200 ;`,因此用正则而非字面量匹配。
want := regexp.MustCompile(`var MAX_CUSTOM_LEN\s*=\s*` + strconv.Itoa(conf.Limits.CustomAnswerLen) + `\s*;`)
if !want.MatchString(out) {
t.Fatalf("missing custom text limit constant, want /%s/", want)
}
if !strings.Contains(out, "data-tb-text-counter") {
t.Fatal("custom inputs are missing the data-tb-text-counter hook")
}
})
t.Run("form can vote", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "表单", Type: db.VoteTypeForm, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["FormFields"] = []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, FieldType: db.VoteFormFieldTypeText, Label: "昵称", Required: true, MaxLength: 20},
{Model: dbutil.Model{ID: 2}, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
{Model: dbutil.Model{ID: 3}, FieldType: db.VoteFormFieldTypeCheckbox, Label: "喜欢的", Options: `["A","B","C"]`},
{Model: dbutil.Model{ID: 4}, FieldType: db.VoteFormFieldTypeRating, Label: "评分"},
{Model: dbutil.Model{ID: 5}, FieldType: db.VoteFormFieldTypeRank, Label: "排序", Options: `["首选","次选","末选"]`},
}
base["HasVoted"] = false
base["CanVote"] = true
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (form) failed: %v", err)
}
})
t.Run("already voted with results", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "已投", Type: db.VoteTypeSingle, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["Options"] = []*db.VoteOption{opt1, opt2}
base["HasVoted"] = true
base["CanVote"] = false
base["NotStarted"] = false
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = true
base["IsOwner"] = false
base["Results"] = map[string]interface{}{
"Type": "single",
"Total": int64(2),
"Options": []interface{}{},
}
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (voted) failed: %v", err)
}
})
t.Run("not started", func(t *testing.T) {
base := voteBaseData()
base["Vote"] = &db.Vote{Title: "未开始", Type: db.VoteTypeSingle, LinkToken: "abc123"}
base["Owner"] = map[string]interface{}{"Name": "tester", "Domain": "test", "Avatar": ""}
base["HasVoted"] = false
base["CanVote"] = false
base["NotStarted"] = true
base["Ended"] = false
base["VoteScopeRegisteredOnly"] = false
base["ShowResults"] = false
base["IsOwner"] = false
if err := renderWithData(t, "vote/page", base); err != nil {
t.Fatalf("render vote/page (not started) failed: %v", err)
}
})
}
func TestRenderVoteManagementPages(t *testing.T) {
t.Run("question list votes panel", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
// 提问箱页面渲染所需的字段(与 QuestionList 提供的一致)
base["Stats"] = map[string]int64{"total": 0, "answered": 0, "unanswered": 0, "private": 0, "pinned": 0, "chats": 0}
base["Keyword"] = ""
base["SelectedSort"] = "latest"
base["SelectedStatus"] = "all"
base["SelectedType"] = "all"
base["ReceivedItems"] = []interface{}{}
base["ConversationDetails"] = []interface{}{}
// 投票面板数据:投票管理已合并到「我的提问箱 · 投票」面板
base["VoteStats"] = map[string]int64{"total": 4, "published": 2}
base["MyVotes"] = []interface{}{
map[string]interface{}{
"ID": 1, "Title": "投票 1", "Type": "single", "Status": "draft", "EffectiveStatus": "draft",
"SubmissionCount": int64(0), "OptionCount": int64(2),
"CreatedAt": time.Now(), "LinkToken": "abc",
},
map[string]interface{}{
"ID": 2, "Title": "投票 2", "Type": "multiple", "Status": "published", "EffectiveStatus": "ended",
"SubmissionCount": int64(5), "OptionCount": int64(3),
"CreatedAt": time.Now(), "LinkToken": "def",
},
map[string]interface{}{
"ID": 3, "Title": "投票 3", "Type": "single", "Status": "published", "EffectiveStatus": "published",
"SubmissionCount": int64(2), "OptionCount": int64(2),
"CreatedAt": time.Now(), "LinkToken": "ghi", "RemainingText": "剩余 2 天 3 小时",
},
map[string]interface{}{
"ID": 4, "Title": "投票 4", "Type": "form", "Status": "published", "EffectiveStatus": "published",
"SubmissionCount": int64(1), "OptionCount": int64(0),
"CreatedAt": time.Now(), "LinkToken": "jkl", "RemainingText": "无限期",
},
}
if err := renderWithData(t, "user/question-list", base); err != nil {
t.Fatalf("render user/question-list (votes panel) failed: %v", err)
}
})
t.Run("vote form new", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = false
out, err := renderToString(t, "user/vote-form", base)
if err != nil {
t.Fatalf("render user/vote-form (new) failed: %v", err)
}
// 站长侧选项上限常量已注入并应用到两类选项输入(单选/多选选项 + 字段选项)。
// 缺注入时渲染出的会是 var MAX_OPTION_LEN = <no value>;,两个 maxlength 也失去同源上限。
wantVar := regexp.MustCompile(`var MAX_OPTION_LEN\s*=\s*` + strconv.Itoa(conf.Limits.VoteOptionLen) + `\s*;`)
if !wantVar.MatchString(out) {
t.Fatalf("vote-form: 缺 MAX_OPTION_LEN 注入,want %d", conf.Limits.VoteOptionLen)
}
wantMax := `maxlength="' + MAX_OPTION_LEN + '"`
if n := strings.Count(out, wantMax); n != 2 {
t.Fatalf("vote-form: 选项输入框应有两处引用 MAX_OPTION_LEN,实际 %d 处", n)
}
})
t.Run("vote form edit", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "编辑中", Type: db.VoteTypeMultiple, Visibility: "public",
VoterScope: "anyone", ShowResult: "after_vote", MaxChoices: 3, MaxVotesPerFingerprint: 1, RequireCaptcha: true}
base["InitialOptions"] = []string{"A", "B"}
base["InitialFields"] = []interface{}{}
if err := renderWithData(t, "user/vote-form", base); err != nil {
t.Fatalf("render user/vote-form (edit) failed: %v", err)
}
})
t.Run("vote form edit locked", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["IsEdit"] = true
base["LockedContent"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "进行中", Type: db.VoteTypeSingle, Visibility: "public",
VoterScope: "anyone", ShowResult: "after_vote", MaxChoices: 1, MaxVotesPerFingerprint: 1, RequireCaptcha: true}
base["InitialOptions"] = []string{"A", "B"}
base["InitialFields"] = []interface{}{}
if err := renderWithData(t, "user/vote-form", base); err != nil {
t.Fatalf("render user/vote-form (locked) failed: %v", err)
}
})
t.Run("vote results single", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "结果", Type: db.VoteTypeSingle}
base["Total"] = int64(2)
base["Results"] = map[string]interface{}{
"Type": "single",
"Options": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "A"}, "Count": int64(2), "Percent": 100},
},
"CustomStats": []interface{}{
map[string]interface{}{"Text": "自定义项", "Count": int64(1)},
},
}
if err := renderWithData(t, "user/vote-results", base); err != nil {
t.Fatalf("render user/vote-results (single) failed: %v", err)
}
})
t.Run("vote results form", func(t *testing.T) {
base := voteBaseData()
base["IsUserPage"] = true
base["Vote"] = &db.Vote{Model: dbutil.Model{ID: 1}, Title: "表单结果", Type: db.VoteTypeForm}
base["Total"] = int64(1)
base["FormFields"] = []*db.VoteFormField{
{Label: "昵称", FieldType: db.VoteFormFieldTypeText},
{Label: "满意度", FieldType: db.VoteFormFieldTypeRadio, Options: `["满意","一般"]`},
}
base["Results"] = map[string]interface{}{
"Type": "form", "FormCount": int64(1), "FormFieldStats": []interface{}{
map[string]interface{}{
"Field": map[string]interface{}{"Label": "最喜欢的水果", "FieldType": "radio", "Options": `["苹果","香蕉"]`},
"Total": int64(1),
"OptionStats": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "苹果"}, "Count": int64(1)},
map[string]interface{}{"Option": map[string]interface{}{"Content": "香蕉"}, "Count": int64(0)},
},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "整体评价", "FieldType": "rating"},
"Total": int64(1), "AvgRating": 9.0,
"RatingCounts": []interface{}{},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "按喜好排序", "FieldType": "rank"},
"Total": int64(1), "AvgRank": 1.5,
"OptionStats": []interface{}{
map[string]interface{}{"Option": map[string]interface{}{"Content": "A"}, "Count": int64(1), "AvgRank": 1.0},
map[string]interface{}{"Option": map[string]interface{}{"Content": "B"}, "Count": int64(1), "AvgRank": 2.0},
},
},
map[string]interface{}{
"Field": map[string]interface{}{"Label": "建议", "FieldType": "textarea"},
"Total": int64(1), "TextCount": int64(1),
},
},
}
// 文本类字段逐条提交内容按字段标签归类(内联在题目下方折叠展示)
base["TextReplies"] = map[string]interface{}{
"建议": []interface{}{
map[string]interface{}{"CreatedAt": time.Now(), "Text": "希望支持深色模式"},
},
}
if err := renderWithData(t, "user/vote-results", base); err != nil {
t.Fatalf("render user/vote-results (form) failed: %v", err)
}
})
}
func TestRenderQuestionListWithVotes(t *testing.T) {
base := voteBaseData()
base["PageUser"] = map[string]interface{}{"Domain": "test", "Name": "tester", "Avatar": "",
"Background": "", "Intro": "", "UseCustomQRColor": false, "Qrcodebackcolor": "", "Qrcodecolor": "",
"Dotscale": "", "BackgroundImage": "", "Backgroundimagealpha": "", "Qrcodepdpcolor": "",
"Acfunlink": "", "Bililink": "", "Weibolink": "", "Xiaohongshulink": "", "Bloglink": "",
"Usernamecolor": "#000000", "Introcolor": "#000000"}
base["CanAsk"] = true
base["VoteEnabled"] = true
base["PageVotes"] = []*db.Vote{{Title: "板块投票", LinkToken: "abc123"}}
base["PageQuestions"] = []interface{}{}
base["AnsweredCount"] = int64(0)
base["PageQuestionCursor"] = ""
base["AllowChat"] = false
base["ChatMaxMessages"] = 20
if err := renderWithData(t, "question/list", base); err != nil {
t.Fatalf("render question/list with votes failed: %v", err)
}
// 箱主本人访问自己的提问箱:无进行中投票时也应显示管理入口(空态)
base["IsOwnPage"] = true
base["PageVotes"] = []interface{}{}
if err := renderWithData(t, "question/list", base); err != nil {
t.Fatalf("render question/list (own page, empty) failed: %v", err)
}
}
func TestRenderExploreWithHotVotes(t *testing.T) {
base := voteBaseData()
base["Users"] = []interface{}{}
base["RecommendUsers"] = []interface{}{}
base["HotTags"] = []string{}
base["Keyword"] = ""
base["Sort"] = "latest"
base["Page"] = 1
base["TotalPages"] = 1
base["PrevPage"] = 0
base["NextPage"] = 2
base["HotVotes"] = []*db.Vote{
{Title: "热门投票 1", LinkToken: "abc1", Type: db.VoteTypeSingle},
{Title: "热门投票 2", LinkToken: "abc2", Type: db.VoteTypeForm},
}
base["AllVotes"] = []*db.Vote{
{Title: "全部投票 1", LinkToken: "abc3", Type: db.VoteTypeMultiple, Model: dbutil.Model{CreatedAt: time.Now()}},
}
if err := renderWithData(t, "user/explore", base); err != nil {
t.Fatalf("render user/explore with hot votes failed: %v", err)
}
}