专题显示区改为一组圆(后端与查询层)
把专题的「一个圆」换成「一组圆」(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 门控):两圆专题的地点集合必须恰好等于两个单圆专题的 并集 —— 定义性不变量,变异验证过(只认第一个圆 ⇒ 两个用例同时红)。
This commit is contained in:
@@ -34,6 +34,41 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
_ = 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},
|
||||
@@ -43,6 +78,14 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
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 {
|
||||
@@ -59,19 +102,28 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 多圆并集语义至少要被真正测到一轮(两个圆都非空),否则形同虚设
|
||||
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)
|
||||
}
|
||||
want[i].PlaceCount, want[i].VisitCount = len(points), visits
|
||||
want[i].DayCount, want[i].CityCount = days, cities
|
||||
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
|
||||
@@ -104,4 +156,42 @@ func TestTopicStatsBatchMatchesDetailPage(t *testing.T) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user