package templates import ( "bytes" "fmt" gotemplate "html/template" "strings" "testing" "time" "github.com/flamego/template" "github.com/tamakyi/TamaBox/internal/conf" "github.com/tamakyi/TamaBox/internal/db" templatepkg "github.com/tamakyi/TamaBox/internal/template" ) // renderWithData 用给定数据渲染模板(复刻生产环境 Templater + FuncMap)。 func renderWithData(t *testing.T, name string, data map[string]interface{}) error { t.Helper() _, err := renderToString(t, name, data) return err } // renderToString 渲染模板并返回输出,供需要断言渲染结果的用例使用。 func renderToString(t *testing.T, name string, data map[string]interface{}) (string, error) { t.Helper() fs, err := template.EmbedFS(FS, ".", []string{".html"}) if err != nil { t.Fatalf("EmbedFS: %v", err) } tpl := gotemplate.New("Flamego.Template") for _, f := range fs.Files() { tt := tpl.New(f.Name()) for _, funcMap := range templatepkg.FuncMap() { tt.Funcs(funcMap) } dataBytes, err := f.Data() if err != nil { t.Fatalf("get data of %q: %v", f.Name(), err) } if _, err := tt.Parse(string(dataBytes)); err != nil { t.Fatalf("parse %q: %v", f.Name(), err) } } var buf bytes.Buffer if err := tpl.ExecuteTemplate(&buf, name, data); err != nil { return "", err } return buf.String(), nil } // mockLoggedUser 覆盖 templates/user/profile.html 引用到的 LoggedUser 字段。 type mockLoggedUser struct { Domain string Name string Email string Avatar string Background string BackgroundImage string Backgroundimagealpha string Intro string Introcolor string Usernamecolor string Qrcodebackcolor string Qrcodecolor string Qrcodepdpcolor string Dotscale string UseCustomQRColor bool Notify string Language string HarassmentSetting string BlockWords string Tags string ShowInHome bool Acfunlink string Bililink string Weibolink string Xiaohongshulink string Bloglink string ChatMaxMessages int DailyMessageLimit int } // profileBaseData 构造 /user/profile 渲染所需的基础数据。 func profileBaseData() map[string]interface{} { return map[string]interface{}{ "Title": "设置", "LangCode": "zh-CN", "SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}}, "IsLogged": true, "LoggedUser": &mockLoggedUser{ Domain: "tester", Name: "测试箱主", Email: "t@example.com", Usernamecolor: "#000", Introcolor: "#666", Qrcodecolor: "#000", Qrcodepdpcolor: "#000", Qrcodebackcolor: "#fff", Dotscale: "0.35", Backgroundimagealpha: "0.8", Notify: "email", Language: "zh-CN", HarassmentSetting: "none", BlockWords: "广告,spam", Tags: "杂谈", ChatMaxMessages: 20, DailyMessageLimit: 0, }, "CSRFTokenHTML": gotemplate.HTML(``), "IsUserPage": false, "IsPixel": false, "VConsole": false, "HasPagePassword": false, "HasAskPassword": true, "ActiveProfileTab": "account", "ICP": "", // 标签 / 屏蔽词上限,供标签块输入组件做实时提示(生产由 route/user.Profile 注入, // 值来自 conf.Limits) "TagLimits": map[string]int{ "MaxTags": conf.Limits.MaxTags, "MaxTagLen": conf.Limits.MaxTagLen, "MaxBlockWords": conf.Limits.MaxBlockWords, "MaxBlockWordLen": conf.Limits.MaxBlockWordLen, }, // 私聊面板数值上下界(同样来自 conf.Limits) "ChatLimits": map[string]int{ "MaxChatMessages": conf.Limits.ChatMaxMessages, "DailyMessageLimitMax": conf.Limits.DailyMessageLimitMax, }, } } // TestRenderUserProfileTabs 渲染整合后的账号设置页:用户设置 + 提问/私聊/投票 三个功能面板, // 覆盖"开启 / 手动关闭 / 已达定量上限"三种功能状态。 func TestRenderUserProfileTabs(t *testing.T) { type mockControl struct { Enabled bool OpenAt *time.Time CloseAt *time.Time QuotaLimit int64 QuotaCount int64 QuotaResetAt *time.Time } type mockState struct { Enabled bool Open bool Reason string QuotaLimit int64 QuotaCount int64 } type mockFeature struct { Key string LangCode string NameText string StatusText string CSRFTokenHTML interface{} Control *mockControl State *mockState OpenAtStr string CloseAtStr string QuotaResetAtStr string ScheduleDays string ScheduleStart string ScheduleEnd string Mode string } resetAt := time.Now().Add(-24 * time.Hour) csrf := gotemplate.HTML(``) base := profileBaseData() base["QuotaLimitMax"] = db.QuotaLimitMax base["CSRFTokenHTML"] = csrf base["FeatureQuestion"] = &mockFeature{ Key: "question", LangCode: "zh-CN", NameText: "提问", StatusText: "进行中", CSRFTokenHTML: csrf, Control: &mockControl{Enabled: true}, State: &mockState{Enabled: true, Open: true}, Mode: "always", } // 手动关闭 base["FeatureChat"] = &mockFeature{ Key: "chat", LangCode: "zh-CN", NameText: "私聊", StatusText: "已关闭", CSRFTokenHTML: csrf, Control: &mockControl{Enabled: false}, State: &mockState{Enabled: false, Reason: "disabled"}, Mode: "always", } // 定时 + 定量:已达上限(历史数据存在循环定时,模式推断为 recurring) base["FeatureVote"] = &mockFeature{ Key: "vote", LangCode: "zh-CN", NameText: "投票", StatusText: "已达上限", CSRFTokenHTML: csrf, Control: &mockControl{Enabled: true, QuotaLimit: 100, QuotaCount: 100, QuotaResetAt: &resetAt}, State: &mockState{Enabled: true, Reason: "quota_reached", QuotaLimit: 100, QuotaCount: 100}, OpenAtStr: "2026-09-01T09:00", CloseAtStr: "2026-09-30T18:00", QuotaResetAtStr: resetAt.Format("2006-01-02 15:04"), ScheduleDays: "1,3,5", ScheduleStart: "09:00", ScheduleEnd: "18:00", Mode: "recurring", } // 逐个面板渲染:模板能正常执行,且只有目标面板可见、其余面板带 hidden all := []string{"account", "question", "chat", "vote"} for _, tab := range all { base["ActiveProfileTab"] = tab out, err := renderToString(t, "user/profile", base) if err != nil { t.Fatalf("render user/profile (tab=%s) failed: %v", tab, err) } if tab == "account" { // 标签块组件的上限常量已注入(缺注入时 with 会静默跳过,页面失去计数/锁定能力) wantMax := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxTags, conf.Limits.MaxTagLen) if !strings.Contains(out, wantMax) { t.Fatalf("account: 标签输入缺 data-tb-max 注入,want %q", wantMax) } wantBlock := fmt.Sprintf(`data-tb-max="%d" data-tb-max-len="%d"`, conf.Limits.MaxBlockWords, conf.Limits.MaxBlockWordLen) if !strings.Contains(out, wantBlock) { t.Fatalf("account: 屏蔽词输入缺 data-tb-max 注入,want %q", wantBlock) } } if tab == "chat" { // 私聊面板的 number 输入框 min/max 与 conf.Limits 同源 wantChat := fmt.Sprintf(`name="chat_max_messages" class="uk-input uk-width-small" type="number" min="1" max="%d"`, conf.Limits.ChatMaxMessages) if !strings.Contains(out, wantChat) { t.Fatalf("chat: 会话条数输入缺 conf 上限,want %q", wantChat) } wantDaily := fmt.Sprintf(`name="chat_daily_limit" class="uk-input uk-width-small" type="number" min="0" max="%d"`, conf.Limits.DailyMessageLimitMax) if !strings.Contains(out, wantDaily) { t.Fatalf("chat: 每日条数输入缺 conf 上限,want %q", wantDaily) } } for _, other := range all { want := fmt.Sprintf(`data-profile-panel="%s"`, other) visible := strings.Contains(out, want+">") hidden := strings.Contains(out, want+" hidden>") if other == tab && !visible { t.Fatalf("tab=%s: 面板 %s 未渲染为可见", tab, other) } if other != tab && !hidden { t.Fatalf("tab=%s: 面板 %s 未隐藏", tab, other) } } } } // TestRenderUserQuestionList 模拟 /user/questions 的数据渲染,复现 500 错误。 func TestRenderUserQuestionList(t *testing.T) { type mockItem struct { Type string CreatedAt time.Time Content string Link string IsAnswered bool IsRead bool IsPrivate bool IsBlocked bool QuestionID uint IsPinned bool PinLink string } type mockConv struct { Token string CreatedAt time.Time } type mockConvDetail struct { Conversation *mockConv OwnerDomain string OwnerName string FirstMessage string IsBlocked bool } base := 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, "CSRFTokenHTML": gotemplate.HTML(``), "RecaptchaSiteKey": "", "CaptchaType": "recaptcha", "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{}{ mockItem{Type: "question_public", CreatedAt: time.Now(), Content: "你好吗?", Link: "/_/test/1", IsAnswered: true, IsRead: true, QuestionID: 1, IsPinned: true, PinLink: "/_/test/1/pin"}, mockItem{Type: "question_private", CreatedAt: time.Now(), Content: "悄悄话", Link: "/_/test/2", IsAnswered: false, IsPrivate: true, QuestionID: 2, PinLink: "/_/test/2/pin"}, }, "ReceivedChats": []interface{}{ mockItem{Type: "chat", CreatedAt: time.Now(), Content: "你好", Link: "/_/test/chat/abc", IsBlocked: false}, }, "ConversationDetails": []interface{}{ mockConvDetail{Conversation: &mockConv{Token: "abc", CreatedAt: time.Now()}, OwnerDomain: "other", OwnerName: "别人", FirstMessage: "hi", IsBlocked: false}, }, } if err := renderWithData(t, "user/question-list", base); err != nil { t.Fatalf("render user/question-list failed: %v", err) } // 带搜索关键词的场景 base["Keyword"] = "hello" if err := renderWithData(t, "user/question-list", base); err != nil { t.Fatalf("render user/question-list (keyword) failed: %v", err) } }