first release
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
package stats_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"rond-with-you/internal/stats"
|
||||
)
|
||||
|
||||
// 这些是只读的对照测试:把统计结果和「手写 SQL 数一遍」的结果比对,
|
||||
// 专门钉住「区域限制漏在某个查询里」这类问题(轨迹、里程、出行方式都犯过)。
|
||||
func setup(t *testing.T) (*sql.DB, int64) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 ROND_DSN")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
var id int64
|
||||
if err := db.QueryRow(`SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&id); err != nil {
|
||||
t.Skipf("没有生效数据集:%v", err)
|
||||
}
|
||||
return db, id
|
||||
}
|
||||
|
||||
func TestOverviewMatchesManualSQL(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
f := stats.Filter{DatasetID: dsID}
|
||||
|
||||
got, err := d.Overview(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var places, cities, days, visits, movements int
|
||||
var dist float64
|
||||
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city),
|
||||
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(*)
|
||||
FROM visits v LEFT JOIN places p ON p.id = v.place_id WHERE v.dataset_id=$1`, dsID).
|
||||
Scan(&places, &cities, &days, &visits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COALESCE(sum(distance_km),0), count(*) FROM movements WHERE dataset_id=$1`, dsID).
|
||||
Scan(&dist, &movements); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Places != places || got.Cities != cities || got.Days != days || got.Visits != visits {
|
||||
t.Errorf("总览与手工 SQL 不一致:%+v vs places=%d cities=%d days=%d visits=%d",
|
||||
got, places, cities, days, visits)
|
||||
}
|
||||
if diff := got.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
|
||||
t.Errorf("里程 %.4f,手工 SQL %.4f", got.DistanceKm, dist)
|
||||
}
|
||||
if got.Movements != movements {
|
||||
t.Errorf("行程数 %d,手工 SQL %d", got.Movements, movements)
|
||||
}
|
||||
}
|
||||
|
||||
// 区域限制必须同时作用于 visits 与 movements:只改其中一边,
|
||||
// 访客会看到「到访次数藏了、里程还留着」这种自相矛盾的数字。
|
||||
func TestRegionFilterAppliesToVisitsAndMovements(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
// 挑一个有记录的省份做白名单,避免用空区域(那样等于没限制)
|
||||
var region string
|
||||
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
|
||||
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> ''
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(®ion); err != nil {
|
||||
t.Skipf("找不到可用省份:%v", err)
|
||||
}
|
||||
|
||||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
only, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if only.Visits >= all.Visits {
|
||||
t.Errorf("白名单 %s 后到访数没减少:%d → %d", region, all.Visits, only.Visits)
|
||||
}
|
||||
if only.DistanceKm >= all.DistanceKm {
|
||||
t.Errorf("白名单 %s 后里程没减少:%.1f → %.1f(movements 的区域过滤可能漏了)",
|
||||
region, all.DistanceKm, only.DistanceKm)
|
||||
}
|
||||
|
||||
// 黑名单:反过来,排除这个省后剩下的应等于「全部 - 只有它」
|
||||
var other string
|
||||
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
|
||||
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> '' AND p.province <> $2
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID, region).Scan(&other); err == nil {
|
||||
notRegion, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}, RegionsExclude: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notRegion.Visits == all.Visits {
|
||||
t.Errorf("黑名单 %s 后到访数没减少:仍为 %d", region, all.Visits)
|
||||
}
|
||||
}
|
||||
|
||||
// 出行方式统计同样要跟着缩水
|
||||
tAll, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tOnly, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := func(ts []stats.TransportStat) float64 {
|
||||
var v float64
|
||||
for _, x := range ts {
|
||||
v += x.Distance
|
||||
}
|
||||
return v
|
||||
}
|
||||
if sum(tOnly) >= sum(tAll) {
|
||||
t.Errorf("白名单 %s 后出行方式里程没减少:%.1f → %.1f", region, sum(tAll), sum(tOnly))
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤条件不该把时间范围弄丢。
|
||||
func TestOverviewTimeRangeStillApplies(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all.FirstAt == nil || all.LastAt == nil {
|
||||
t.Skip("没有时间范围可测")
|
||||
}
|
||||
mid := all.FirstAt.Add(all.LastAt.Sub(*all.FirstAt) / 2)
|
||||
half, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, From: &mid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if half.Visits >= all.Visits {
|
||||
t.Errorf("限定后半段后到访数没减少:%d → %d", all.Visits, half.Visits)
|
||||
}
|
||||
if half.LastAt == nil || all.LastAt == nil || half.LastAt.Before(mid) {
|
||||
t.Errorf("后半段的最后时间 %v 早于起点 %v", half.LastAt, mid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWeatherStatsRespectsFilter 天气统计必须跟着筛选走。
|
||||
//
|
||||
// 以前 WeatherStats 声明了 Filter 却只用了 DatasetID,HourlyTemp 干脆只收 datasetID:
|
||||
// 统计页筛完之后「气温」还是全量汇总,跟同一页的到访数对不上。
|
||||
func TestWeatherStatsRespectsFilter(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
var minAt, maxAt time.Time
|
||||
if err := db.QueryRow(`SELECT min(at), max(at) FROM weather WHERE dataset_id=$1`, dsID).
|
||||
Scan(&minAt, &maxAt); err != nil {
|
||||
t.Skipf("没有天气数据可测:%v", err)
|
||||
}
|
||||
// 取「最后一个月」当区间:它必然只覆盖一部分天气
|
||||
from := time.Date(maxAt.Year(), maxAt.Month(), 1, 0, 0, 0, 0, maxAt.Location())
|
||||
if !from.After(minAt) {
|
||||
t.Skip("天气数据只落在一个月内,构造不出有效区间")
|
||||
}
|
||||
|
||||
sum := func(ws []stats.WeatherStat) int {
|
||||
n := 0
|
||||
for _, w := range ws {
|
||||
n += w.Count
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
all, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, From: &from})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 与手写 SQL 对账
|
||||
var wantTotal, wantWindow int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&wantTotal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather w WHERE w.dataset_id=$1 AND w.at >= $2`,
|
||||
dsID, from).Scan(&wantWindow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 分组数是 12 上限,只有没截断时求和才等于总数
|
||||
if len(got) < 12 && sum(got) != wantWindow {
|
||||
t.Errorf("按时间筛选后天气行数 %d,手写 SQL %d", sum(got), wantWindow)
|
||||
}
|
||||
if wantWindow < wantTotal && sum(got) >= sum(all) {
|
||||
t.Errorf("筛了时间但天气行数没减少(%d → %d),筛选没生效", sum(all), sum(got))
|
||||
}
|
||||
|
||||
// 逐月气温同样要跟着区间走
|
||||
temps, err := d.HourlyTemp(ctx, stats.Filter{DatasetID: dsID, From: &from})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(temps) != 1 {
|
||||
t.Errorf("区间只覆盖 %s 一个月,逐月气温应当只剩 1 组,实际 %d 组:%+v",
|
||||
from.Format("2006-01"), len(temps), temps)
|
||||
}
|
||||
|
||||
// 城市筛选:只有归属于该城市到访的天气算数
|
||||
var city string
|
||||
if err := db.QueryRow(`SELECT p.city FROM weather w
|
||||
JOIN visits v ON v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
|
||||
JOIN places p ON p.id=v.place_id
|
||||
WHERE w.dataset_id=$1 AND p.city IS NOT NULL
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
|
||||
t.Skipf("没有带城市的天气:%v", err)
|
||||
}
|
||||
byCity, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, City: city})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantCity int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather w
|
||||
WHERE w.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||||
WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
|
||||
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2))`,
|
||||
dsID, city).Scan(&wantCity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(byCity) < 12 && sum(byCity) != wantCity {
|
||||
t.Errorf("按城市「%s」筛选后天气行数 %d,手写 SQL %d", city, sum(byCity), wantCity)
|
||||
}
|
||||
if sum(byCity) >= wantTotal {
|
||||
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user