// Copyright 2022 E99p1ant. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package stats import ( "context" "strings" "testing" "time" "github.com/stretchr/testify/require" "github.com/tamakyi/TamaBox/internal/db" ) // TestBuildDailyFillsGaps 没有数据的日期必须占位,否则折线会把两天连成一条直线, // 让人以为「那天也有提问」。 func TestBuildDailyFillsGaps(t *testing.T) { now := time.Date(2026, 10, 23, 15, 30, 0, 0, time.Local) got := buildDaily([]db.CountPoint{{Key: "2026-10-20", N: 5}}, now, 5) require.Len(t, got, 5) require.Equal(t, "2026-10-19", got[0].Date) require.Equal(t, "2026-10-23", got[4].Date) require.EqualValues(t, 0, got[0].N) require.EqualValues(t, 5, got[1].N, "10-20 是窗口里的第 2 天") } // TestBuildMonthlyNoMonthOverflow 月底不能溢出:3-31 直接 AddDate(0,-11,0) 会滚到 3 月初, // 12 根柱子就变成 11 根。实现里先归到当月 1 号,这里守住这个行为。 func TestBuildMonthlyNoMonthOverflow(t *testing.T) { now := time.Date(2026, 3, 31, 0, 0, 0, 0, time.Local) got := buildMonthly(nil, now, 12) require.Len(t, got, 12) require.Equal(t, "2025-04", got[0].Month) require.Equal(t, "2026-03", got[11].Month) require.True(t, got[11].Current, "最后一根柱子是当月") require.False(t, got[0].Current) } // TestBusiestSlot 同值时取更早的下标,保证同一份数据每次算出来的结论一致。 func TestBusiestSlot(t *testing.T) { var heatmap [7][24]int64 heatmap[2][9] = 10 heatmap[5][20] = 10 wd, hr := busiestSlot(heatmap) require.Equal(t, 2, wd) require.Equal(t, 9, hr) } // TestOverviewEmptyNoDivideByZero 全零数据不能除零,也不能编出不存在的结论。 func TestOverviewEmptyNoDivideByZero(t *testing.T) { v := buildOverview(&db.SiteAnalytics{}, time.Now()) require.Zero(t, v.AnswerRate) require.Zero(t, v.FastReplyRate) require.Zero(t, v.GrowthMoM) require.False(t, v.HasPrevMonth) require.Len(t, v.Daily, dailyWindowDays) require.Len(t, v.Monthly, monthlyWindow) page := NewPage("zh-CN", v) require.Len(t, page.Insights, 1, "空库只说一句「数据还太少」") require.Equal(t, "提问数据还太少,暂时看不出什么。", page.Insights[0]) } // TestOverviewDerived 派生指标:回答率、环比、最忙时段、24 小时内回复占比。 func TestOverviewDerived(t *testing.T) { now := time.Now() monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) prevMonth := monthStart.AddDate(0, -1, 0).Format("2006-01") monthBefore := monthStart.AddDate(0, -2, 0).Format("2006-01") raw := &db.SiteAnalytics{ TotalQuestions: 200, Answered: 50, Private: 20, Anonymous: 120, Daily: []db.CountPoint{{Key: now.Format("2006-01-02"), N: 7}}, // 环比看的是「上月」与「上上月」:本月还没过完,当年 10 月为 0 会把结论带偏。 Monthly: []db.CountPoint{ {Key: monthBefore, N: 100}, {Key: prevMonth, N: 50}, }, LengthBins: [6]int64{1, 2, 3, 4, 5, 6}, ReplyBins: [6]int64{10, 10, 10, 10, 5, 5}, } raw.Heatmap[1][8] = 40 v := buildOverview(raw, now) require.InDelta(t, 25.0, v.AnswerRate, 0.001) require.InDelta(t, 60.0, v.FastReplyRate, 0.001, "(10+10+10)/50 在 24 小时内") require.Equal(t, 180, int(v.Public)) require.Equal(t, 80, int(v.LoggedIn)) require.Equal(t, 1, v.BusiestWD) require.Equal(t, 8, v.BusiestHr) require.EqualValues(t, 40, v.HeatmapMax) require.True(t, v.HasPrevMonth) require.InDelta(t, -50.0, v.GrowthMoM, 0.001, "9 月 50 相对 8 月 100 是 -50%") page := NewPage("zh-CN", v) require.NotEmpty(t, page.Insights) require.Len(t, page.WeekdayLabels, 7) require.Len(t, page.LengthLabels, 6) require.Len(t, page.ReplyLabels, 6) // 句子里必须是完整星期名:热力图列头的 stats.wd.* 是单字("一"/"三"), // 复用到句子里会读成「提问最集中在三 8 点前后」。 require.Contains(t, strings.Join(page.Insights, "\n"), "提问最集中在周一 8 点前后。") } // fakeAnalytics 最小实现,用来卡住缓存的边界: // 整页聚合一天只该算一次,但「今日」两项必须跟着每次请求走。 type fakeAnalytics struct { todayQ, todayA int64 siteCalls int } func (f *fakeAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) { f.siteCalls++ return &db.SiteAnalytics{TotalQuestions: 10, Answered: 4}, nil } func (f *fakeAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) { return f.todayQ, f.todayA, nil } // TestTodayNumbersNotFrozen 回归:「今日」两项曾被塞进每日缓存, // 导致页面顶着「实时」的说明一整天显示常量。 func TestTodayNumbersNotFrozen(t *testing.T) { prev := db.Analytics t.Cleanup(func() { db.Analytics = prev InvalidateOverview() }) f := &fakeAnalytics{todayQ: 3, todayA: 1} db.Analytics = f InvalidateOverview() first, err := OverviewData(context.Background()) require.NoError(t, err) require.EqualValues(t, 3, first.TodayQuestions) // 今天又来了几条,且短缓存已过期 f.todayQ, f.todayA = 8, 2 todayMu.Lock() todayAt = todayAt.Add(-2 * todayTTL) todayMu.Unlock() second, err := OverviewData(context.Background()) require.NoError(t, err) require.EqualValues(t, 8, second.TodayQuestions, "今日数不能被每日缓存冻结") require.EqualValues(t, 2, second.TodayAnswers) require.Equal(t, 1, f.siteCalls, "整页聚合当天只应算一次") // 返回的是副本,不能把已经交出去的 pages 改掉 require.EqualValues(t, 3, first.TodayQuestions) } // TestTodayCacheExpiresAcrossMidnight 跨日必须重算:昨天 23:59 算出的是昨天的数。 func TestTodayCacheExpiresAcrossMidnight(t *testing.T) { prev := db.Analytics t.Cleanup(func() { db.Analytics = prev InvalidateOverview() }) f := &fakeAnalytics{todayQ: 7, todayA: 5} db.Analytics = f justAfterMidnight := time.Date(2026, 10, 24, 0, 0, 30, 0, time.Local) // 昨天午夜算出来的缓存(日期不同,即使还在 60 秒内也不该复用) setTodayCache(99, 99, justAfterMidnight.Add(-24*time.Hour)) q, a := todayNumbers(context.Background(), justAfterMidnight, 0, 0) require.EqualValues(t, 7, q, "跨日后必须重算,不能沿用昨天的数字") require.EqualValues(t, 5, a) }