Files
tamabox/internal/stats/overview_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

331 lines
11 KiB
Go

// 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"
"errors"
"fmt"
"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 点前后。")
}
// TestInsightMoMNamesBothMonths 环比句子必须点明是哪两个月、各多少条。
//
// 旧文案「上月提问量环比 -75.0%」要读者先把「上月」翻译成月份、再把「环比」
// 翻译成「比上上月」——两个相对词叠在一起,实测被读成「上月同期 vs 本月同期」。
// 这里锁住:两个月份名、两个条数、方向词与百分比同号。
func TestInsightMoMNamesBothMonths(t *testing.T) {
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
prev := monthStart.AddDate(0, -1, 0)
before := monthStart.AddDate(0, -2, 0)
cases := []struct {
name string
cur, prev int64
want string
}{
{"下降", 25, 100, "少了 75.0%"},
{"上升", 200, 100, "多了 100.0%"},
{"持平", 100, 100, "持平"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
raw := &db.SiteAnalytics{
TotalQuestions: 300,
Monthly: []db.CountPoint{
{Key: before.Format("2006-01"), N: c.prev},
{Key: prev.Format("2006-01"), N: c.cur},
},
}
joined := strings.Join(NewPage("zh-CN", buildOverview(raw, now)).Insights, "\n")
// 句子里得能读出「哪个月」「多少条」,否则又回到靠脑补的老样子。
require.Contains(t, joined, fmt.Sprintf("%d 月收到", int(prev.Month())))
require.Contains(t, joined, fmt.Sprintf("%d 月的 %s 条", int(before.Month()), numText(c.prev)))
require.Contains(t, joined, c.want)
})
}
}
// TestMonthText 月份名要按语言分别处理:英文不能用「9 月」,CJK 不用 "September"。
func TestMonthText(t *testing.T) {
require.Equal(t, "9 月", monthText("zh-CN", "2026-09"))
require.Equal(t, "9 月", monthText("zh-TW", "2026-09"))
require.Equal(t, "9 月", monthText("ja", "2026-09"))
require.Equal(t, "September", monthText("en", "2026-09"))
// 解析失败时原样返回(宁可露出原始 key,也别在句子里留空格)。
require.Equal(t, "bad-key", monthText("zh-CN", "bad-key"))
}
// 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)
}
// failingAnalytics 模拟数据库挂掉:每次调用都报错,并记录被调用次数。
type failingAnalytics struct {
siteCalls int
}
func (f *failingAnalytics) SiteAnalytics(context.Context, time.Time) (*db.SiteAnalytics, error) {
f.siteCalls++
return nil, errors.New("db is down")
}
func (f *failingAnalytics) TodayCounts(context.Context, time.Time) (int64, int64, error) {
return 0, 0, errors.New("db is down")
}
// TestOverviewFailureCooldown 聚合失败后要冷却,不能每个请求都重排一次全表聚合。
//
// 没有冷却时,数据库挂起(连接不断、查询不返回)会让排队请求各自耗满 30 秒超时,
// 第 N 个请求要等 N×30 秒 —— 页面对所有人一起超时。
func TestOverviewFailureCooldown(t *testing.T) {
prev := db.Analytics
t.Cleanup(func() {
db.Analytics = prev
InvalidateOverview()
})
f := &failingAnalytics{}
db.Analytics = f
InvalidateOverview()
_, err := OverviewData(context.Background())
require.Error(t, err, "没有任何快照时应该把错误交给调用方")
// 冷却期内的请求直接失败,不再打库。
for i := 0; i < 5; i++ {
_, err := OverviewData(context.Background())
require.Error(t, err)
}
require.Equal(t, 1, f.siteCalls, "冷却期内不该重复聚合")
}
// TestOverviewCooldownClearedOnSuccess 冷却只针对失败,成功后照常刷新。
func TestOverviewCooldownClearedOnSuccess(t *testing.T) {
prev := db.Analytics
t.Cleanup(func() {
db.Analytics = prev
InvalidateOverview()
})
bad := &failingAnalytics{}
db.Analytics = bad
InvalidateOverview()
_, err := OverviewData(context.Background())
require.Error(t, err)
// 数据库恢复
good := &fakeAnalytics{todayQ: 1, todayA: 1}
db.Analytics = good
// 手动把冷却时间挪到过去,模拟「过了 30 秒」
ovMu.Lock()
ovFailAt = time.Now().Add(-2 * ovFailCooldown)
ovMu.Unlock()
v, err := OverviewData(context.Background())
require.NoError(t, err)
require.EqualValues(t, 10, v.TotalQuestions)
require.Equal(t, 1, good.siteCalls)
// 成功后冷却状态要清掉,否则下次失败判断会用到旧时间
ovMu.Lock()
failAt, failErr := ovFailAt, ovFailErr
ovMu.Unlock()
require.True(t, failAt.IsZero(), "成功后必须清掉失败时间")
require.NoError(t, failErr)
}
// TestTodayFailureStillThrottled 数据库挂掉时「今日」两项也不能每个请求都打两条 COUNT。
func TestTodayFailureStillThrottled(t *testing.T) {
prev := db.Analytics
t.Cleanup(func() {
db.Analytics = prev
InvalidateOverview()
})
db.Analytics = &failingAnalytics{}
InvalidateOverview()
now := time.Now()
q, a := todayNumbers(context.Background(), now, 7, 3)
require.EqualValues(t, 7, q, "失败时回退到调用方给的值")
require.EqualValues(t, 3, a)
// 紧接一次:命中失败缓存,不该再打库(打了也没法区分,直接看返回值仍是回退值)
q, a = todayNumbers(context.Background(), now.Add(time.Second), 0, 0)
require.EqualValues(t, 7, q, "冷却期内沿用上一次的结果,不再打库")
require.EqualValues(t, 3, a)
}