把专题的「一个圆」换成「一组圆」(areas jsonb 数组):落在任一圆内即算在 区域内(SQL 里的 OR)。这一批只动后端与查询层,前台渲染与后台地图编辑器 分别跟进;后台表单暂时沿用原来的三个坐标输入框,保存时合成一个圆写进 areas,所以现有交互不受影响。 - topics.areas JSONB + 存量回填:老的单圆搬进去,之后 areas 是唯一权威。 废弃的 center_lat/center_lon/radius_km 保留(写入时镜像第一个圆), 一是回滚旧二进制时还能退化显示成第一个圆,二是回填的输入。 - Topic.AreaList() 归一:Areas 为空时退回老的三列,未回填的老行与直接构造 Topic 的调用方都继续工作。 - topicAreaCond 改成 N 个圆的 OR;每个圆占 3 个占位符,编号纪律照旧 (写错就是「专题内容全变 0」那种难查的事故)。 - fitParams 改用所有圆的并集外接矩形:多圆之后「圆心+半径」表达不了, 统一走 fitBBox;单圆时外接矩形就是这个圆的外接正方形,与原路径等价。 - 坐标系:编辑器提交的圆心若是 WGS-84 底图上点的,存前换算成 GCJ-02 (地点过滤要拿它跟 places 的 GCJ-02 坐标比距离)。 测试: - 表单解析(两个入口同一套校验)、wgs84 换算、半径为 0/空 = 不限区域、 坏 JSON/越界必须报错、上限 20 个。 - 占位符纪律:本段引用的 $n 必须恰好是 [prefix+1, len(args)] 连续区间, 最大值必须等于参数个数。 - 多圆并集语义(DB 门控):两圆专题的地点集合必须恰好等于两个单圆专题的 并集 —— 定义性不变量,变异验证过(只认第一个圆 ⇒ 两个用例同时红)。
198 lines
7.7 KiB
Go
198 lines
7.7 KiB
Go
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
|
||
}
|