Files
tamabox/route/vote/vote_stats_test.go

270 lines
11 KiB
Go
Raw Permalink 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 vote
import (
stdctx "context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/dbutil"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// mockVoteSubmissions 仅实现 aggregateFormFieldStats 依赖的 ListByVoteID。
type mockVoteSubmissions struct {
submissions []*db.VoteSubmission
}
func (m *mockVoteSubmissions) ListByVoteID(ctx stdctx.Context, voteID uint, cursor *dbutil.Cursor) ([]*db.VoteSubmission, error) {
return m.submissions, nil
}
func (m *mockVoteSubmissions) ListByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint][]*db.VoteSubmission, error) {
out := make(map[uint][]*db.VoteSubmission)
for _, s := range m.submissions {
out[s.VoteID] = append(out[s.VoteID], s)
}
return out, nil
}
func (m *mockVoteSubmissions) Create(ctx stdctx.Context, opts db.CreateVoteSubmissionOptions) (*db.VoteSubmission, error) {
return nil, nil
}
func (m *mockVoteSubmissions) GetByID(ctx stdctx.Context, id uint) (*db.VoteSubmission, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByVoteID(ctx stdctx.Context, voteID uint) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) CountByVoteIDs(ctx stdctx.Context, voteIDs []uint) (map[uint]int64, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByOptionID(ctx stdctx.Context, voteID, optionID uint) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) CountGroupByOptionID(ctx stdctx.Context, voteID uint) (map[uint]int64, error) {
return nil, nil
}
func (m *mockVoteSubmissions) CountByIP(ctx stdctx.Context, voteID uint, ip string, since time.Time) (int64, error) {
return 0, nil
}
func (m *mockVoteSubmissions) HasUserVoted(ctx stdctx.Context, voteID, userID uint) (bool, error) {
return false, nil
}
func (m *mockVoteSubmissions) HasTokenVoted(ctx stdctx.Context, voteID uint, token string) (bool, error) {
return false, nil
}
func (m *mockVoteSubmissions) DeleteByVoteID(ctx stdctx.Context, voteID uint) error { return nil }
func formData(t *testing.T, v map[string]interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal form data: %v", err)
}
return b
}
func TestParseMultipleOptionIDsRejectsDuplicates(t *testing.T) {
valid := map[uint]bool{1: true, 2: true, 3: true}
chosen, err := parseMultipleOptionIDs([]string{"1", "2"}, 2, valid, i18n.Default)
if err != nil {
t.Fatalf("valid multiple options rejected: %v", err)
}
if len(chosen) != 2 || chosen[0] != 1 || chosen[1] != 2 {
t.Fatalf("unexpected parsed options: %#v", chosen)
}
cases := []struct {
name string
ids []string
want string
}{
{"duplicate", []string{"1", "1"}, "选项不能重复选择"},
{"too-many", []string{"1", "2", "3"}, "最多只能选择 2 个选项"},
{"invalid", []string{"1", "99"}, "存在无效的选项"},
{"overflow", []string{"18446744073709551616"}, "存在无效的选项"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseMultipleOptionIDs(tc.ids, 2, valid, i18n.Default)
if err == nil || err.Error() != tc.want {
t.Fatalf("parseMultipleOptionIDs(%v) error = %v, want %q", tc.ids, err, tc.want)
}
})
}
}
func TestVoterTokenSignatureValidation(t *testing.T) {
oldKey := conf.Server.XSRFKey
conf.Server.XSRFKey = "test-voter-token-secret"
defer func() { conf.Server.XSRFKey = oldKey }()
token := newVoterToken()
if !validVoterToken(token) {
t.Fatalf("new voter token should validate: %q", token)
}
if len(token) != 108 {
t.Fatalf("new voter token length = %d, want 108", len(token))
}
if validVoterToken(token + "x") {
t.Fatal("tampered voter token should be rejected")
}
parts := strings.Split(token, ".")
if len(parts) != 2 || validVoterToken(parts[0]+"."+strings.Repeat("A", len(parts[1]))) {
t.Fatal("token with forged signature should be rejected")
}
}
func TestAggregateFormFieldStats(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`},
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRating, Label: "评分"},
{Model: dbutil.Model{ID: 4}, VoteID: 9, FieldType: db.VoteFormFieldTypeRank, Label: "排序", Options: `["X","Y","Z"]`},
{Model: dbutil.Model{ID: 5}, VoteID: 9, FieldType: db.VoteFormFieldTypeNumber, Label: "数量"},
{Model: dbutil.Model{ID: 6}, VoteID: 9, FieldType: db.VoteFormFieldTypeTextarea, Label: "建议"},
}
old := db.VoteSubmissions
defer func() { db.VoteSubmissions = old }()
subs := []*db.VoteSubmission{
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A", "B"}, "评分": 9, "排序": map[string]float64{"X": 1, "Y": 2, "Z": 3}, "数量": 3, "建议": "很好",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A"}, "评分": 7, "排序": map[string]float64{"X": 2, "Y": 1, "Z": 3}, "数量": 5, "建议": "",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "一般", "评分": 8, "排序": map[string]float64{"X": 3, "Y": 2, "Z": 1}, "数量": 10,
})},
}
db.VoteSubmissions = &mockVoteSubmissions{submissions: subs}
stats := aggregateFormFieldStatsWithSubs(fields, subs)
if len(stats) != 6 {
t.Fatalf("expected 6 field stats, got %d", len(stats))
}
// radio:满意 2 / 一般 1
radio := stats[0]
if radio.Total != 3 || len(radio.OptionStats) != 2 {
t.Fatalf("radio stats wrong: total=%d opts=%d", radio.Total, len(radio.OptionStats))
}
if radio.OptionStats[0].Count != 2 || radio.OptionStats[1].Count != 1 {
t.Fatalf("radio counts wrong: %+v", radio.OptionStats)
}
// checkbox:A 2 / B 1,回答数 2,总勾选数 3(A、B、A)
cb := stats[1]
if cb.Total != 2 || cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
t.Fatalf("checkbox stats wrong: total=%d %+v", cb.Total, cb.OptionStats)
}
if cb.TotalSelections != 3 {
t.Fatalf("checkbox TotalSelections wrong: got %d, want 3", cb.TotalSelections)
}
// rating:均值 (9+7+8)/3=8.0,总 3
rating := stats[2]
if rating.Total != 3 || rating.AvgRating < 7.99 || rating.AvgRating > 8.01 {
t.Fatalf("rating stats wrong: total=%d avg=%f", rating.Total, rating.AvgRating)
}
if len(rating.RatingCounts) != 10 {
t.Fatalf("rating counts must have 10 buckets, got %d", len(rating.RatingCounts))
}
// rank:X 平均 2,Y 平均 5/3≈1.67,Z 平均 7/3≈2.33,整体平均 2.0
rank := stats[3]
if rank.Total != 3 || rank.AvgRank < 1.99 || rank.AvgRank > 2.01 {
t.Fatalf("rank avg wrong: total=%d avg=%f", rank.Total, rank.AvgRank)
}
if rank.OptionStats[0].AvgRank < 1.99 || rank.OptionStats[0].AvgRank > 2.01 {
t.Fatalf("rank option X avg wrong: %f", rank.OptionStats[0].AvgRank)
}
if rank.OptionStats[1].AvgRank < 1.66 || rank.OptionStats[1].AvgRank > 1.68 {
t.Fatalf("rank option Y avg wrong: %f", rank.OptionStats[1].AvgRank)
}
// number:均值 (3+5+10)/3=6
num := stats[4]
if num.Total != 3 || num.AvgNumber < 5.99 || num.AvgNumber > 6.01 {
t.Fatalf("number stats wrong: total=%d avg=%f", num.Total, num.AvgNumber)
}
// textarea:填写 1;文本字段的"回答数"(Total) 应与"已填写数"(TextCount) 一致
text := stats[5]
if text.TextCount != 1 {
t.Fatalf("textarea count wrong: %d", text.TextCount)
}
if text.Total != text.TextCount {
t.Fatalf("textarea total should equal filled count, got total=%d filled=%d", text.Total, text.TextCount)
}
}
func TestAggregateFormFieldStatsEmpty(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`},
}
old := db.VoteSubmissions
defer func() { db.VoteSubmissions = old }()
db.VoteSubmissions = &mockVoteSubmissions{submissions: []*db.VoteSubmission{}}
stats := aggregateFormFieldStatsWithSubs(fields, nil)
if len(stats) != 1 || stats[0].Total != 0 || stats[0].OptionStats[0].Count != 0 {
t.Fatalf("empty stats wrong: %+v", stats)
}
}
// TestAggregateFormFieldStatsCustom 验证"允许补充自定义选项"字段:非预置值归入 CustomStats(按文本去重计数)。
func TestAggregateFormFieldStatsCustom(t *testing.T) {
fields := []*db.VoteFormField{
{Model: dbutil.Model{ID: 1}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "满意度", Options: `["满意","一般"]`, AllowCustom: true},
{Model: dbutil.Model{ID: 2}, VoteID: 9, FieldType: db.VoteFormFieldTypeCheckbox, Label: "多选", Options: `["A","B"]`, AllowCustom: true},
// 未开启 AllowCustom 的字段:非预置值仍按普通值计数(不归入 CustomStats)
{Model: dbutil.Model{ID: 3}, VoteID: 9, FieldType: db.VoteFormFieldTypeRadio, Label: "无自定义", Options: `["X"]`},
}
subs := []*db.VoteSubmission{
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "满意", "多选": []string{"A", "自定义甲"}, "无自定义": "X",
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "自定义乙", "多选": []string{"自定义甲", "B"},
})},
{VoteID: 9, FormData: formData(t, map[string]interface{}{
"满意度": "自定义乙", "多选": []string{"A"},
})},
}
stats := aggregateFormFieldStatsWithSubs(fields, subs)
// radio(AllowCustom):满意 1 / 一般 0 / 自定义乙 2 / 自定义甲 0 → CustomStats 仅"自定义乙"计 2
radio := stats[0]
if radio.Total != 3 {
t.Fatalf("radio total wrong: %d", radio.Total)
}
if radio.OptionStats[0].Count != 1 || radio.OptionStats[1].Count != 0 {
t.Fatalf("radio preset counts wrong: %+v", radio.OptionStats)
}
if len(radio.CustomStats) != 1 || radio.CustomStats[0].Text != "自定义乙" || radio.CustomStats[0].Count != 2 {
t.Fatalf("radio custom stats wrong: %+v", radio.CustomStats)
}
// checkbox(AllowCustom):A 2 / B 1 / 自定义甲 2(两条提交各选一次)→ CustomStats 计 2
cb := stats[1]
if cb.Total != 3 || cb.TotalSelections != 5 {
t.Fatalf("checkbox total/selections wrong: total=%d sel=%d", cb.Total, cb.TotalSelections)
}
if cb.OptionStats[0].Count != 2 || cb.OptionStats[1].Count != 1 {
t.Fatalf("checkbox preset counts wrong: %+v", cb.OptionStats)
}
if len(cb.CustomStats) != 1 || cb.CustomStats[0].Text != "自定义甲" || cb.CustomStats[0].Count != 2 {
t.Fatalf("checkbox custom stats wrong: %+v", cb.CustomStats)
}
// 未开启 AllowCustom 的字段:非预置值不应出现(提交校验已拦截),即使出现也只按普通值计数
noCustom := stats[2]
if noCustom.Total != 1 || len(noCustom.CustomStats) != 0 {
t.Fatalf("no-custom field should not produce CustomStats: %+v", noCustom)
}
}