// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package insights import ( "net/http/httptest" "testing" "time" ) func TestPct(t *testing.T) { cases := []struct { n, max int64 want float64 }{ {0, 0, 0}, // 除零:不能算出 NaN 塞进 CSS {5, 0, 0}, // 同上 {10, 10, 100}, // 满格 {1, 3, 33.3}, // 四舍五入到一位小数 {2, 3, 66.7}, {1, 1000, 0.1}, } for _, c := range cases { if got := pct(c.n, c.max); got != c.want { t.Errorf("pct(%d, %d) = %v, want %v", c.n, c.max, got, c.want) } } } func TestFormatInt(t *testing.T) { cases := map[int64]string{ 0: "0", 7: "7", 999: "999", 1000: "1,000", 12345: "12,345", 1234567: "1,234,567", -1234: "-1,234", } for in, want := range cases { if got := formatInt(in); got != want { t.Errorf("formatInt(%d) = %q, want %q", in, got, want) } } } // 窗口切分是「今日 / 近 7 天 / 近 30 天」与「本周 vs 上周」的唯一实现, // 算错不会报错、只会安静地显示错误的数字,所以单独测。 func TestAggregateWindows(t *testing.T) { loc := time.Local today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc) weekFrom := today.AddDate(0, 0, -(WeekDays - 1)) // 10-23 prevFrom := today.AddDate(0, 0, -(2*WeekDays - 1)) // 10-16 prevTo := today.AddDate(0, 0, -WeekDays) // 10-22 p := &Page{} for i := 0; i < TrendDays; i++ { d := today.AddDate(0, 0, -(TrendDays - 1) + i) p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")}) } set := func(offsetDays int, pv, uv, q, a int64) { key := today.AddDate(0, 0, offsetDays).Format("2006-01-02") for i := range p.Days { if p.Days[i].Key == key { p.Days[i].PV, p.Days[i].UV = pv, uv p.Days[i].Questions, p.Days[i].Answers = q, a return } } t.Fatalf("日期 %s 不在骨架里", key) } set(0, 5, 3, 1, 0) // 今天:本周 + 今日 set(-1, 2, 2, 0, 1) // 昨天:本周 set(-6, 4, 1, 2, 0) // 10-23:本周起点 set(-7, 9, 6, 0, 0) // 10-22:上周末日 set(-13, 6, 5, 0, 0) // 10-16:上周起点 p.aggregateWindows(today, weekFrom, prevFrom, prevTo) // 本周 = 5+2+4;上周 = 9+6 if p.Week.PV != 11 { t.Errorf("Week.PV = %d, want 11", p.Week.PV) } if p.Week.PrevPV != 15 { t.Errorf("Week.PrevPV = %d, want 15", p.Week.PrevPV) } if !p.Week.HasDelta { t.Fatal("上周有数据时 HasDelta 应为 true") } if got, want := p.Week.Delta, float64(11-15)/15*100; got != want { t.Errorf("Week.Delta = %v, want %v", got, want) } if p.Today.PV != 5 || p.Today.UV != 3 { t.Errorf("Today = %+v, want PV 5 UV 3", p.Today) } if p.Month.PV != 26 { t.Errorf("Month.PV = %d, want 26(全部 5 天之和)", p.Month.PV) } if p.BestDay == nil || p.BestDay.PV != 9 { t.Errorf("BestDay = %+v, want 10-22 的 9 次", p.BestDay) } } // 上周一次访问都没有时不能给出「涨了 100%」这类结论。 func TestAggregateWindowsNoPrev(t *testing.T) { loc := time.Local today := time.Date(2026, 10, 29, 0, 0, 0, 0, loc) p := &Page{} for i := 0; i < TrendDays; i++ { d := today.AddDate(0, 0, -(TrendDays - 1) + i) p.Days = append(p.Days, Day{Key: d.Format("2006-01-02")}) } p.Days[len(p.Days)-1].PV = 3 p.aggregateWindows(today, today.AddDate(0, 0, -6), today.AddDate(0, 0, -13), today.AddDate(0, 0, -7)) if p.Week.HasDelta { t.Error("上周 PV 为 0 时 HasDelta 应为 false") } } func TestSameDay(t *testing.T) { a := time.Date(2026, 10, 29, 0, 0, 0, 0, time.Local) b := time.Date(2026, 10, 29, 23, 59, 59, 0, time.Local) c := time.Date(2026, 10, 30, 0, 0, 1, 0, time.Local) if !sameDay(a, b) { t.Error("同一天的任意时刻应判为同一天") } if sameDay(a, c) { t.Error("跨天应判为不同天") } } // 访客标识必须「同日可去重、跨日不可关联」:这是面板隐私承诺的技术基础。 func TestVisitorID(t *testing.T) { r := NewRecorder(nil, "test-secret") // 不启动写入,仅用哈希方法 defer r.Close() day1 := time.Date(2026, 10, 29, 8, 0, 0, 0, time.Local) day1Later := time.Date(2026, 10, 29, 22, 0, 0, 0, time.Local) day2 := time.Date(2026, 10, 30, 8, 0, 0, 0, time.Local) a := r.visitorID("1.2.3.4", day1) if a != r.visitorID("1.2.3.4", day1Later) { t.Error("同一天同一 IP 必须得到相同标识,否则 UV 去重失效") } if a == r.visitorID("1.2.3.4", day2) { t.Error("跨天必须得到不同标识,否则可长期追踪同一访客") } if a == r.visitorID("5.6.7.8", day1) { t.Error("不同 IP 同一天必须不同") } if len(a) != 32 { t.Errorf("标识长度 = %d, want 32", len(a)) } // 换密钥后整体不可关联(运维轮换 xsrf_key 时历史哈希不会被重新对上) other := NewRecorder(nil, "another-secret") defer other.Close() if a == other.visitorID("1.2.3.4", day1) { t.Error("不同密钥应给出不同标识") } } func TestIsCrawler(t *testing.T) { cases := map[string]bool{ "": true, // 空 UA 基本是脚本 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36": false, "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)": true, "curl/8.4.0": true, "python-requests/2.31.0": true, "Mozilla/5.0 (compatible; bingbot/2.0)": true, "facebookexternalhit/1.1": true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 Version/17.0 Mobile Safari/604.1": false, } for ua, want := range cases { if got := isCrawler(ua); got != want { t.Errorf("isCrawler(%q) = %v, want %v", ua, got, want) } } } func TestIsPrefetch(t *testing.T) { cases := []struct { name string header string value string want bool }{ {"普通请求", "", "", false}, {"Chrome 预取", "Sec-Purpose", "prefetch", true}, {"Chrome 预渲染", "Sec-Purpose", "prefetch;prerender", true}, {"Safari 预览", "X-Purpose", "preview", true}, {"Firefox 预取", "X-Moz", "prefetch", true}, } for _, c := range cases { req := httptest.NewRequest("GET", "/_/someone", nil) if c.header != "" { req.Header.Set(c.header, c.value) } if got := isPrefetch(req); got != c.want { t.Errorf("%s: isPrefetch = %v, want %v", c.name, got, c.want) } } }