Files
rond-with-you/internal/stats/stats_test.go
T
tamakyi 4597047989 口径:已并入 / 子段的到访不再计入时间线与统计(导出侧一个字节不动)
真机里两行到访可能「不独立成立」: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 个用例:标记后导出仍带该行、存量回填拉正、
判据只出现在读侧)。
2026-09-29 21:15:25 +08:00

332 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&region); 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)
}
}