Files
tamakyi 1bb35dbbc9 专题显示区改为一组圆(后端与查询层)
把专题的「一个圆」换成「一组圆」(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 门控):两圆专题的地点集合必须恰好等于两个单圆专题的
  并集 —— 定义性不变量,变异验证过(只认第一个圆 ⇒ 两个用例同时红)。
2026-10-12 16:29:46 +08:00

198 lines
7.7 KiB
Go
Raw Permalink 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 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
}