package stats_test import ( "context" "database/sql" "fmt" "os" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" "rond-with-you/internal/stats" ) // verdictFixture 是一套**自造**的数据集,坐标故意取在远端(89.9 / 100.x), // 与任何真实备份、任何测试夹具都不会重合。 // // 为什么不复用 setup(t) 的活跃数据集:`place_key` 是**坐标键**,而所有夹具导入的都是 // 同一份备份、坐标完全相同。往活跃数据集上写 place_votes 会顺着坐标键漏进并行跑的 // 其它包(internal/web 有用例要读某个地点的 verdict),把别人的断言弄脏。 // 这里只建自己的数据集,并且**不碰 is_active**——改它会让并行跑的统计用例选到空库。 type verdictFixture struct { db *sql.DB ds int64 uid int64 p1, p2, p3 int64 // p1/p2 打「推荐」,p3 不打 } func newVerdictFixture(t *testing.T) *verdictFixture { 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() }) ctx := context.Background() tag := fmt.Sprint(time.Now().UnixNano()) var uid int64 if err := db.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name) VALUES ($1,'x','verdict-scope') RETURNING id`, "verdict-scope-"+tag).Scan(&uid); err != nil { t.Fatalf("建临时账号失败: %v", err) } var ds int64 sha := fmt.Sprintf("%064x", time.Now().UnixNano()) if err := db.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status) VALUES ($1,'verdict-scope',$2,'done') RETURNING id`, uid, sha).Scan(&ds); err != nil { t.Fatalf("建临时数据集失败: %v", err) } t.Cleanup(func() { db.ExecContext(ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid) // 数据集级联带走 places / visits / movements / weather / activities db.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, ds) db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid) }) // 到访的到达时间固定写死,避免「今天」漂移导致天数/月份断言不稳 at := func(iso string) time.Time { v, err := time.Parse(time.RFC3339, iso) if err != nil { t.Fatal(err) } return v } mkPlace := func(pk int, lat, lon float64, city, prov string) int64 { t.Helper() var id int64 if err := db.QueryRowContext(ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, city, province) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, ds, pk, fmt.Sprintf("P%d", pk), lat, lon, city, prov).Scan(&id); err != nil { t.Fatalf("建地点失败: %v", err) } return id } f := &verdictFixture{db: db, ds: ds, uid: uid} f.p1 = mkPlace(1, 89.900001, 100.100001, "CityA", "ProvA") f.p2 = mkPlace(2, 89.900002, 100.100002, "CityB", "ProvA") f.p3 = mkPlace(3, 89.900003, 100.100003, "CityC", "ProvB") var actID int64 if err := db.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name) VALUES ($1,1,'家') RETURNING id`, ds).Scan(&actID); err != nil { t.Fatalf("建活动失败: %v", err) } mkVisit := func(pk int, placeID int64, iso string, dur int) { t.Helper() if _, err := db.ExecContext(ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, activity_id, arrival, duration_min) VALUES ($1,$2,$3,$4,$5,$6)`, ds, pk, placeID, actID, at(iso), dur); err != nil { t.Fatalf("建到访失败: %v", err) } } mkVisit(101, f.p1, "2026-01-05T10:00:00+08:00", 60) mkVisit(102, f.p1, "2026-01-06T10:00:00+08:00", 30) mkVisit(103, f.p2, "2026-02-10T10:00:00+08:00", 20) mkVisit(104, f.p3, "2026-02-11T10:00:00+08:00", 10) mkMove := func(pk, from, to int, placeID int64, km float64, iso string) { t.Helper() if _, err := db.ExecContext(ctx, `INSERT INTO movements (dataset_id, src_pk, from_visit_src, to_visit_src, from_place_id, to_place_id, started_at, ended_at, distance_km) VALUES ($1,$2,$3,$4,$5,$5,$6,$6,$7)`, ds, pk, from, to, placeID, at(iso), km); err != nil { t.Fatalf("建位移失败: %v", err) } } // m1 跨 p1→p2(两端都在 S 内)· m2 跨 p3→p1(任一端在 S 内就该算)· m3 两端都在 p3(该被排除) mkMove(201, 101, 103, f.p1, 5.0, "2026-01-07T10:00:00+08:00") mkMove(202, 104, 101, f.p1, 7.0, "2026-02-12T10:00:00+08:00") mkMove(203, 104, 104, f.p3, 11.0, "2026-02-13T10:00:00+08:00") mkWeather := func(pk, visitSrc int, iso, cond string) { t.Helper() if _, err := db.ExecContext(ctx, `INSERT INTO weather (dataset_id, src_pk, visit_src, at, condition) VALUES ($1,$2,$3,$4,$5)`, ds, pk, visitSrc, at(iso), cond); err != nil { t.Fatalf("建天气失败: %v", err) } } mkWeather(301, 101, "2026-01-05T10:00:00+08:00", "Clear") mkWeather(302, 104, "2026-02-11T10:00:00+08:00", "Cloudy") // 只给 p1 / p2 打「推荐」。键用 Go 侧的 %.6f —— 顺便验证它与 SQL 的 // to_char(...,'FM999990.000000') 逐位一致(不一致这里就查不到,断言会红)。 mark := func(placeID int64, lat, lon float64, verdict int) { t.Helper() key := fmt.Sprintf("%.6f|%.6f", lat, lon) if _, err := db.ExecContext(ctx, `INSERT INTO place_votes (user_id, place_key, dataset_id, src_pk, verdict) VALUES ($1,$2,$3,1,$4)`, uid, key, ds, verdict); err != nil { t.Fatalf("写 verdict(键 %s)失败: %v", key, err) } } mark(f.p1, 89.900001, 100.100001, 1) mark(f.p2, 89.900002, 100.100002, 1) return f } func (f *verdictFixture) filter(v int) stats.Filter { return stats.Filter{DatasetID: f.ds, Verdict: v} } func sumBuckets(bs []stats.Bucket) int { n := 0 for _, b := range bs { n += b.Count } return n } // D7 回归:verdict 必须作用到**每一个**统计口径上。 // // 用户已定口径「全域一致」——筛选条上看得见的这枚条件,页面上所有数字都得跟着变, // 否则就是「点没了、里程还在」这种自相矛盾的视图(P1-3 是同一类问题的另一个面)。 // // 判据用**不可能存在的结论值 99**:没有任何地点会被它匹配,于是**每一个**统计都必须退回 // 空集。任何一处漏了下推(地图点、城市/省份、类型计数、概览、里程/出行、天气、时间分布) // 都会在这一条上暴露成一个非零数——比逐个统计写断言更彻底。 func TestVerdictScopesEveryStatistic(t *testing.T) { f := newVerdictFixture(t) ctx := context.Background() d := stats.DB{DB: f.db} // 先确认夹具本身有数据(否则「为空」是废话) all, err := d.Overview(ctx, f.filter(0)) if err != nil { t.Fatalf("Overview(无筛选) 失败: %v", err) } if all.Visits != 4 || all.Places != 3 || all.Movements != 3 { t.Fatalf("夹具数据不对:visits=%d places=%d movements=%d,期望 4/3/3", all.Visits, all.Places, all.Movements) } // ① 不可能存在的结论 ⇒ 全域都该是空的 none := f.filter(99) ov, err := d.Overview(ctx, none) if err != nil { t.Fatal(err) } if ov != (stats.Overview{}) { t.Errorf("Verdict=99 时 Overview 应当全零,实际 %+v", ov) } places, total, err := d.Places(ctx, none, "visits", 100, 0) if err != nil { t.Fatal(err) } if len(places) != 0 || total != 0 { t.Errorf("Verdict=99 时地点列表应当为空,实际 %d 条(total=%d)", len(places), total) } mapped, err := d.MapPlaces(ctx, none, 100) if err != nil { t.Fatal(err) } if len(mapped) != 0 { t.Errorf("Verdict=99 时地图点应当为空,实际 %d 个", len(mapped)) } if rows, n, err := d.Visits(ctx, none, 100, 0, "desc"); err != nil || len(rows) != 0 || n != 0 { t.Errorf("Verdict=99 时到访列表应当为空,实际 %d 条(total=%d, err=%v)", len(rows), n, err) } if as, err := d.ActivityStats(ctx, none); err != nil || len(as) != 0 { t.Errorf("Verdict=99 时类型分布应当为空,实际 %d 组(err=%v)", len(as), err) } if ac, err := d.ActivityCounts(ctx, none); err != nil || len(ac) != 0 { t.Errorf("Verdict=99 时类型计数应当为空,实际 %d 项(err=%v)", len(ac), err) } if cs, err := d.CityStats(ctx, none, 10); err != nil || len(cs) != 0 { t.Errorf("Verdict=99 时城市分布应当为空,实际 %d 组(err=%v)", len(cs), err) } if ps, err := d.ProvinceStats(ctx, none); err != nil || len(ps) != 0 { t.Errorf("Verdict=99 时省份分布应当为空,实际 %d 组(err=%v)", len(ps), err) } if ws, err := d.WeatherStats(ctx, none); err != nil || len(ws) != 0 { t.Errorf("Verdict=99 时天气汇总应当为空,实际 %d 组(err=%v)", len(ws), err) } if ms, err := d.Monthly(ctx, none); err != nil || sumBuckets(ms) != 0 { t.Errorf("Verdict=99 时月度分布应当为空,实际 %d 次(err=%v)", sumBuckets(ms), err) } if cal, err := d.Calendar(ctx, none); err != nil || len(cal) != 0 { t.Errorf("Verdict=99 时日历应当为空,实际 %d 天(err=%v)", len(cal), err) } if bs, err := d.Hourly(ctx, none); err != nil || sumBuckets(bs) != 0 { t.Errorf("Verdict=99 时小时分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err) } if bs, err := d.Weekday(ctx, none); err != nil || sumBuckets(bs) != 0 { t.Errorf("Verdict=99 时星期分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err) } if ts, err := d.TransportStats(ctx, none); err != nil || len(ts) != 0 { t.Errorf("Verdict=99 时出行方式应当为空,实际 %d 组(err=%v)", len(ts), err) } } // D7 正向:只给 p1/p2 打「推荐」时,每个口径都必须**恰好**收窄到它们名下的数据。 // 上一条证明「不会漏下推」,这一条证明「下推之后选对了」。 func TestVerdictNarrowsEveryStatisticToMarkedPlaces(t *testing.T) { f := newVerdictFixture(t) ctx := context.Background() d := stats.DB{DB: f.db} on := f.filter(1) // S = {p1, p2}:3 条到访(60+30+20 分钟)、2 个地点、2 个城市、1 个省、3 个不同日期 ov, err := d.Overview(ctx, on) if err != nil { t.Fatal(err) } if ov.Visits != 3 || ov.Places != 2 || ov.Cities != 2 || ov.Provinces != 1 || ov.Days != 3 { t.Errorf("Overview 收窄不对:visits=%d places=%d cities=%d provinces=%d days=%d,期望 3/2/2/1/3", ov.Visits, ov.Places, ov.Cities, ov.Provinces, ov.Days) } if ov.DwellMin != 110 { t.Errorf("停留 %d 分钟,期望 110", ov.DwellMin) } // 位移按「任一端在 S 内」:m1(p1→p2) 与 m2(p3→p1) 算,两端都在 p3 的 m3 不算 if ov.Movements != 2 || ov.DistanceKm < 11.99 || ov.DistanceKm > 12.01 { t.Errorf("位移收窄不对:movements=%d distance=%.2f,期望 2 / 12.00", ov.Movements, ov.DistanceKm) } places, total, err := d.Places(ctx, on, "visits", 100, 0) if err != nil { t.Fatal(err) } if total != 2 || len(places) != 2 { t.Errorf("地点列表应当只剩 2 个,实际 %d 条(total=%d)", len(places), total) } got := map[int64]bool{} for _, p := range places { got[p.ID] = true } if !got[f.p1] || !got[f.p2] || got[f.p3] { t.Errorf("地点列表选错:%v(应当只有 p1/p2)", got) } mapped, err := d.MapPlaces(ctx, on, 100) if err != nil { t.Fatal(err) } if len(mapped) != 2 { t.Errorf("地图点应当只剩 2 个,实际 %d 个(这一处以前完全没认 verdict)", len(mapped)) } // 每个「按到访计数」的口径都必须与 Overview.Visits 对得上 if ac, err := d.ActivityCounts(ctx, on); err != nil { t.Fatal(err) } else { n := 0 for _, c := range ac { n += c } if n != ov.Visits { t.Errorf("类型计数合计 %d,与到访数 %d 不符", n, ov.Visits) } } if as, err := d.ActivityStats(ctx, on); err != nil { t.Fatal(err) } else { n := 0 for _, a := range as { n += a.Count } if n != ov.Visits { t.Errorf("类型分布合计 %d,与到访数 %d 不符", n, ov.Visits) } } if cs, err := d.CityStats(ctx, on, 10); err != nil { t.Fatal(err) } else { n := 0 for _, c := range cs { n += c.Count } if n != ov.Visits || len(cs) != 2 { t.Errorf("城市分布应当 2 组共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(cs), n) } } if ps, err := d.ProvinceStats(ctx, on); err != nil { t.Fatal(err) } else { n := 0 for _, p := range ps { n += p.Count } if n != ov.Visits || len(ps) != 1 || ps[0].Province != "ProvA" { t.Errorf("省份分布应当只有 ProvA 共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(ps), n) } } if ms, err := d.Monthly(ctx, on); err != nil || sumBuckets(ms) != ov.Visits { t.Errorf("月度分布合计 %d,与到访数 %d 不符(err=%v)", sumBuckets(ms), ov.Visits, err) } if bs, err := d.Hourly(ctx, on); err != nil || sumBuckets(bs) != ov.Visits { t.Errorf("小时分布合计不对(err=%v)", err) } if bs, err := d.Weekday(ctx, on); err != nil || sumBuckets(bs) != ov.Visits { t.Errorf("星期分布合计不对(err=%v)", err) } // 天气挂在到访上:只有 p1 那条该留下 if ws, err := d.WeatherStats(ctx, on); err != nil { t.Fatal(err) } else { n := 0 for _, w := range ws { n += w.Count } if n != 1 { t.Errorf("天气应当只剩 1 条(挂在 p1 上那条),实际 %d 条", n) } } // 到访列表也要跟着收窄 if rows, n, err := d.Visits(ctx, on, 100, 0, "desc"); err != nil || len(rows) != 3 || n != 3 { t.Errorf("到访列表应当只剩 3 条,实际 %d 条(total=%d, err=%v)", len(rows), n, err) } }