真机里两行到访可能「不独立成立」:ZMERGEDTO 指向它被并入的那条、ZPARENT 说明它只是某个 父段的一部分。app 对这两种行既不显示也不计数,本站以前当成普通到访 ⇒ 时间线上出现 「同一时刻同一地点两条」、日停留重复计入(实测数据集 9 有 64 条,26 条到达时间与原件相同)。 这些行**不能删**——导出要靠它们认这次合并发生过,所以判据只立一份 stats.SoloVisitSQL, 在读的一侧收口:visitsWhere / VisitsByPlace / Bookmarked / placeCols 子查询、首次到达城市、 地图极值点、专题(详情 + 列表批量)、三处聚合的 visit_count 与 dwell_minutes, 外加 schema.sql 一段幂等回填把存量库拉正。 天气与位移**不收口**(那几小时、那段路真实发生过);导出侧一个字节不动(rond 只读 places.last_visit_at,保持全量,压根不读 visit_count / dwell_minutes)。编辑页照样列出 这些行,但打上「已并入 #562 / 子段 #374」,免得以为数据丢了。 顺带修两个自测抓出来的问题: - ingest 的地点聚合回填排在 insertEntityRaw 之前 ⇒ 判据读到空留档 ⇒ 每行都算独立到访 (整整多算一个「已并入」的量)。 - 收口后 last_visit_at 保持全量,会凑出「0 次却有最近到访」(数据集 9 有 4 个地点), 展示侧按可见到访数兜住。 验收:304 PASS / 0 FAIL / 0 SKIP(新增 3 个用例:标记后导出仍带该行、存量回填拉正、 判据只出现在读侧)。
332 lines
12 KiB
Go
332 lines
12 KiB
Go
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
|
||
// 判据按**访客口径**在手写 SQL 里独立写一遍(不复用 stats.SoloVisitSQL):已并入 / 子段的
|
||
// 到访在 app 里既不算次数也不占天数——独立写一份,正好在口径写错时把它抓出来。
|
||
// 位移/里程那两行**不**加这个条件:那段路是真的发生过的,收口只管到访本身。
|
||
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
|
||
AND NOT EXISTS (SELECT 1 FROM entity_raw er
|
||
WHERE er.dataset_id=v.dataset_id AND er.entity='ZVISIT' AND er.src_pk=v.src_pk
|
||
AND (er.raw->>'ZMERGEDTO' IS NOT NULL OR er.raw->>'ZPARENT' IS NOT NULL))`, 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)
|
||
}
|
||
}
|
||
|
||
// TestVisitSideFiltersApplyToMovements 钉住「按活动 / 城市 / 标签 / 关键词筛选时,
|
||
// 里程与出行次数也要跟着变」。
|
||
//
|
||
// movements 自己只有两端到访,没有活动、城市这些字段。曾经 movementsWhere 只认
|
||
// dataset / 时间 / 家工作 / 区域,于是筛完之后「到访数变了、里程还是全量」——
|
||
// 区域那档犯过一次(见上一个测试),这几档是同一个洞没堵完。
|
||
func TestVisitSideFiltersApplyToMovements(t *testing.T) {
|
||
db, dsID := setup(t)
|
||
d := stats.DB{DB: db}
|
||
ctx := context.Background()
|
||
|
||
// 挑一个真有到访的活动,且它只覆盖一部分到访(否则筛选前后一样,测不出东西)
|
||
var actID int64
|
||
if err := db.QueryRow(`SELECT activity_id FROM visits
|
||
WHERE dataset_id=$1 AND activity_id IS NOT NULL
|
||
GROUP BY activity_id ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&actID); err != nil {
|
||
t.Skipf("没有带活动的到访:%v", err)
|
||
}
|
||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
one, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, ActivityIDs: []int64{actID}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if one.Visits >= all.Visits {
|
||
t.Skipf("活动 %d 覆盖了全部到访,换个数据集再测", actID)
|
||
}
|
||
|
||
// 手写 SQL:两端**任一端**的到访属于该活动的位移
|
||
var dist float64
|
||
var n int
|
||
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0), count(*) FROM movements m
|
||
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||
WHERE v.dataset_id=m.dataset_id AND v.activity_id=$2
|
||
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
|
||
dsID, actID).Scan(&dist, &n); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if diff := one.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
|
||
t.Errorf("按活动筛选后里程 %.4f,手写 SQL %.4f(筛选没作用到里程上?)", one.DistanceKm, dist)
|
||
}
|
||
if one.Movements != n {
|
||
t.Errorf("按活动筛选后出行次数 %d,手写 SQL %d", one.Movements, n)
|
||
}
|
||
|
||
// 同一条路径也覆盖城市:movementVisitConds 里所有维度共用一个 EXISTS
|
||
var city string
|
||
if err := db.QueryRow(`SELECT p.city FROM visits v JOIN places p ON p.id=v.place_id
|
||
WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''
|
||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
|
||
t.Skipf("没有带城市的到访:%v", err)
|
||
}
|
||
byCity, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, City: city})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var cityDist float64
|
||
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0) FROM movements m
|
||
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||
WHERE v.dataset_id=m.dataset_id
|
||
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2)
|
||
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
|
||
dsID, city).Scan(&cityDist); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if diff := byCity.DistanceKm - cityDist; diff > 0.01 || diff < -0.01 {
|
||
t.Errorf("按城市「%s」筛选后里程 %.4f,手写 SQL %.4f", city, byCity.DistanceKm, cityDist)
|
||
}
|
||
}
|