package web import ( "context" "database/sql" "testing" ) // 列表页(fillTopicStatsBatch:一条 UNION ALL)与详情页(topicContent:逐专题查询) // 对同一专题必须给出同样的统计 —— 列表上的数字点进去要对得上。 // 顺带钉住批量版的占位符编号:错一个就是 pgx「expected N arguments, got M」。 func TestTopicStatsBatchMatchesDetailPage(t *testing.T) { f := newLoginFixture(t) ctx := context.Background() var ds int64 if err := f.srv.db.QueryRowContext(ctx, `SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&ds); err != nil { t.Skipf("没有生效数据集:%v", err) } var minAt, maxAt sql.NullTime if err := f.srv.db.QueryRowContext(ctx, `SELECT min(arrival), max(arrival) FROM visits WHERE dataset_id=$1`, ds).Scan(&minAt, &maxAt); err != nil { t.Fatal(err) } if !minAt.Valid || !maxAt.Valid { t.Skip("生效数据集里没有到访记录") } var lat, lon float64 if err := f.srv.db.QueryRowContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places WHERE dataset_id=$1 ORDER BY visit_count DESC LIMIT 1`, ds).Scan(&lat, &lon); err != nil { t.Fatal(err) } var city string _ = f.srv.db.QueryRowContext(ctx, `SELECT city FROM places WHERE dataset_id=$1 AND COALESCE(city,'')<>'' GROUP BY city ORDER BY count(*) DESC LIMIT 1`, ds).Scan(&city) // 挑两个**离得最远**的地点做圆心,好让两个圆尽量不相交 —— 相交的话 // 「并集 = 两个单圆之和」这个判据就退化成「两个圆其实差不多」,抓不住 OR 只算一半的错。 type pt struct { lat, lon float64 } var cand []pt rows, err := f.srv.db.QueryContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND COALESCE(lat,0)<>0 AND COALESCE(lon,0)<>0 ORDER BY visit_count DESC LIMIT 200`, ds) if err != nil { t.Fatal(err) } for rows.Next() { var p pt if err := rows.Scan(&p.lat, &p.lon); err != nil { t.Fatal(err) } cand = append(cand, p) } if err := rows.Err(); err != nil { t.Fatal(err) } rows.Close() if len(cand) < 2 { t.Skip("生效数据集里不足两个带坐标的地点,无法构造多圆用例") } a0, a1, far := cand[0], cand[1], 0.0 for _, p := range cand[1:] { d := (p.lat-a0.lat)*(p.lat-a0.lat) + (p.lon-a0.lon)*(p.lon-a0.lon) if d > far { far, a1 = d, p } } mid := minAt.Time.Add(maxAt.Time.Sub(minAt.Time) / 2) base := []Topic{ {ID: 1, Title: "全时段", StartAt: minAt.Time, EndAt: maxAt.Time}, {ID: 2, Title: "前半段", StartAt: minAt.Time, EndAt: mid}, {ID: 3, Title: "后半段", StartAt: mid, EndAt: maxAt.Time}, {ID: 4, Title: "按区域圈定", StartAt: minAt.Time, EndAt: maxAt.Time, CenterLat: lat, CenterLon: lon, RadiusKm: 30}, {ID: 5, Title: "区域圈不到任何地点", StartAt: minAt.Time, EndAt: maxAt.Time, CenterLat: 1, CenterLon: 1, RadiusKm: 1}, // 6/7/8 是多圆用例:8 必须恰好等于 6 与 7 的并集(下面有专门断言) {ID: 6, Title: "圆A", StartAt: minAt.Time, EndAt: maxAt.Time, Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5}}}, {ID: 7, Title: "圆B", StartAt: minAt.Time, EndAt: maxAt.Time, Areas: []TopicArea{{Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}}, {ID: 8, Title: "A+B(并集)", StartAt: minAt.Time, EndAt: maxAt.Time, Areas: []TopicArea{{Lat: a0.lat, Lon: a0.lon, RadiusKm: 5}, {Lat: a1.lat, Lon: a1.lon, RadiusKm: 5}}}, } type round struct { name string regions []string exclude bool wantNonZero bool } rounds := []round{{name: "无区域过滤", wantNonZero: true}} if city != "" { rounds = append(rounds, round{name: "白名单:" + city, regions: []string{city}, wantNonZero: true}, round{name: "黑名单:" + city, regions: []string{city}, exclude: true}, ) } // 多圆并集语义至少要被真正测到一轮(两个圆都非空),否则形同虚设 unionRounds := 0 for _, rd := range rounds { // 详情页路径:逐专题查询 want := append([]Topic(nil), base...) placeSets := map[int64]map[int64]bool{} for i := range want { points, visits, days, cities, err := f.srv.topicContent(ctx, &want[i], ds, rd.regions, rd.exclude) if err != nil { t.Fatalf("[%s] topicContent #%d: %v", rd.name, want[i].ID, err) } ids := map[int64]bool{} for _, p := range points { ids[p.ID] = true want[i].DwellMin += p.Dwell } placeSets[want[i].ID] = ids want[i].PlaceCount, want[i].VisitCount = len(points), visits want[i].DayCount, want[i].CityCount = days, cities } if checkAreaUnionSemantics(t, rd.name, placeSets) { unionRounds++ } // 列表页路径:一条 UNION ALL got := append([]Topic(nil), base...) if err := f.srv.fillTopicStatsBatch(ctx, got, ds, rd.regions, rd.exclude); err != nil { t.Fatalf("[%s] fillTopicStatsBatch: %v", rd.name, err) } nonZero := 0 for i := range got { if got[i].PlaceCount != want[i].PlaceCount || got[i].VisitCount != want[i].VisitCount || got[i].DayCount != want[i].DayCount || got[i].CityCount != want[i].CityCount || got[i].DwellMin != want[i].DwellMin { t.Errorf("[%s] 专题 #%d 批量与详情页不一致:\n 批量 地点/到访/天数/城市/停留 = %d/%d/%d/%d/%d\n 详情页同项 = %d/%d/%d/%d/%d", rd.name, got[i].ID, got[i].PlaceCount, got[i].VisitCount, got[i].DayCount, got[i].CityCount, got[i].DwellMin, want[i].PlaceCount, want[i].VisitCount, want[i].DayCount, want[i].CityCount, want[i].DwellMin) } if got[i].PlaceCount > 0 { nonZero++ } // 半径 1km 圈在 (1,1) 是海面,必须一个地点都圈不到(证明区域条件真的生效) if got[i].ID == 5 && (got[i].PlaceCount != 0 || got[i].VisitCount != 0 || got[i].DwellMin != 0) { t.Errorf("[%s] 半径 1km 圈在 (1,1) 的专题不应有内容,实得 %d 地点 %d 到访", rd.name, got[i].PlaceCount, got[i].VisitCount) } } if rd.wantNonZero && nonZero == 0 { t.Fatalf("[%s] 所有专题统计都是 0,用例形同虚设", rd.name) } t.Logf("[%s] %d 个专题逐个比对通过(有内容 %d 个)", rd.name, len(got), nonZero) } if unionRounds == 0 { t.Error("多圆并集语义一轮都没真正测到(两个圆里总有一个是空的)—— 用例形同虚设") } } // checkAreaUnionSemantics 校验多圆语义:主题 8(两个圆)的地点集合必须**恰好**等于 // 主题 6 与 7(各一个圆)的并集。这是定义性不变量,不是相对计数: // - 只认第一个圆 ⇒ 退化成 6 的子集,7 那些地点全部缺失; // - OR 写成 AND ⇒ 退化成 6 与 7 的交集(两圆不相交时直接空)。 // // 返回值报告这一轮是否「真的测到了」:两个圆都非空才有区分力。 // 带区域白/黑名单时其中一个圆可能整个被过滤掉(圆心在外地),那是正常数据行为, // 所以不在这里硬失败,改由调用方要求「至少有一轮测到」。 func checkAreaUnionSemantics(t *testing.T, round string, sets map[int64]map[int64]bool) bool { t.Helper() a, b, both := sets[6], sets[7], sets[8] if a == nil || b == nil || both == nil { t.Fatalf("[%s] 多圆用例没跑起来(sets 里缺 6/7/8)", round) } union := map[int64]bool{} for id := range a { union[id] = true } for id := range b { union[id] = true } for id := range union { if !both[id] { t.Errorf("[%s] 并集里的地点 %d 没出现在两圆专题里(少算了某个圆?)", round, id) } } for id := range both { if !union[id] { t.Errorf("[%s] 两圆专题多出地点 %d(不属于任何一个圆)", round, id) } } t.Logf("[%s] 多圆并集:A=%d B=%d 并集=%d 两圆专题=%d", round, len(a), len(b), len(union), len(both)) return len(a) > 0 && len(b) > 0 }