Files
rond-with-you/internal/web/topic_areas_test.go
T
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

392 lines
14 KiB
Go
Raw 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 (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
"rond-with-you/internal/geo"
)
func closeTo(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
// 显示区(一组圆)的数据层不变量。这些用例都是纯函数级的,不需要数据库。
// formPost 造一个已经 ParseForm 过的 POST 请求,供 topicAreasFromForm 读字段。
func formPost(t *testing.T, values url.Values) *http.Request {
t.Helper()
r := httptest.NewRequest("POST", "/admin/topics/save", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
return r
}
// ---------- 表单解析:两个入口必须走同一套校验 ----------
func TestTopicAreasFromForm(t *testing.T) {
type want struct {
areas int
lat float64
lon float64
km float64
}
cases := []struct {
name string
values url.Values
want want
wantErr string
}{
{
name: "老表单三字段 → 合成一个圆",
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"30"}},
want: want{areas: 1, lat: 25.6, lon: 100.2, km: 30},
},
{
name: "老表单全空 → 不限区域(不是错误)",
values: url.Values{"center_lat": {""}, "center_lon": {""}, "radius_km": {""}},
want: want{areas: 0},
},
{
name: "老表单半径 0 → 不限区域",
values: url.Values{"center_lat": {"25.6"}, "center_lon": {"100.2"}, "radius_km": {"0"}},
want: want{areas: 0},
},
{
name: "老表单有半径但圆心是 0,0 → 报错(不能静默变成「圈在几内亚湾」)",
values: url.Values{"center_lat": {"0"}, "center_lon": {"0"}, "radius_km": {"30"}},
wantErr: "点选圆心",
},
{
name: "编辑器 JSON → 多个圆",
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":3},{"lat":26,"lon":101,"radiusKm":5}]`}},
want: want{areas: 2, lat: 25, lon: 100, km: 3},
},
{
name: "编辑器 JSON 空数组 → 不限区域",
values: url.Values{"areas": {"[]"}},
want: want{areas: 0},
},
{
name: "坏 JSON → 报错而不是当成「不限区域」",
values: url.Values{"areas": {"[{lat:25]"}},
wantErr: "无法解析",
},
{
name: "半径越界 → 报错",
values: url.Values{"areas": {`[{"lat":25,"lon":100,"radiusKm":0.001}]`}},
wantErr: "半径",
},
{
name: "纬度越界 → 报错",
values: url.Values{"areas": {`[{"lat":91,"lon":100,"radiusKm":3}]`}},
wantErr: "超出经纬度",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := topicAreasFromForm(formPost(t, tc.values))
if tc.wantErr != "" {
if err == nil {
t.Fatalf("期望报错(含 %q),却通过了:%+v", tc.wantErr, got)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("错误信息里应含 %q,实得 %q", tc.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("不该报错: %v", err)
}
if len(got) != tc.want.areas {
t.Fatalf("圆的个数 = %d,期望 %d(%+v)", len(got), tc.want.areas, got)
}
if tc.want.areas > 0 {
if got[0].Lat != tc.want.lat || got[0].Lon != tc.want.lon || got[0].RadiusKm != tc.want.km {
t.Fatalf("第一个圆 = %+v,期望 {%v %v %v}", got[0], tc.want.lat, tc.want.lon, tc.want.km)
}
}
})
}
}
// wgs84 底图上点的圆心必须换算成 GCJ-02 再存:地点过滤是拿它跟 places 的 GCJ-02 坐标
// 直接比球面距离的,不换算会整体偏几百米(历史上照片导入那次就是这么偏掉的)。
func TestTopicAreasFromFormConvertsWGS84(t *testing.T) {
const wgsLat, wgsLon = 25.6, 100.2
gcjLat, gcjLon := geo.WGS84ToGCJ02(wgsLat, wgsLon)
args := []url.Values{
// 编辑器路径
{"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"wgs84"}},
// 老表单路径
{"center_lat": {fmt.Sprint(wgsLat)}, "center_lon": {fmt.Sprint(wgsLon)},
"radius_km": {"3"}, "crs": {"wgs84"}},
}
for i, v := range args {
got, err := topicAreasFromForm(formPost(t, v))
if err != nil {
t.Fatalf("[%d] %v", i, err)
}
if len(got) != 1 {
t.Fatalf("[%d] 应得 1 个圆,实得 %d", i, len(got))
}
if !closeTo(got[0].Lat, gcjLat) || !closeTo(got[0].Lon, gcjLon) {
t.Errorf("[%d] 没换算成 GCJ-02:得 %.6f,%.6f 期望 %.6f,%.6f", i, got[0].Lat, got[0].Lon, gcjLat, gcjLon)
}
if d := math.Hypot(got[0].Lat-wgsLat, got[0].Lon-wgsLon); d < 1e-4 {
t.Errorf("[%d] 换算前后竟然一样(说明根本没换算)", i)
}
}
// crs 不是 wgs84 时保持原值
got, err := topicAreasFromForm(formPost(t, url.Values{
"areas": {fmt.Sprintf(`[{"lat":%v,"lon":%v,"radiusKm":3}]`, wgsLat, wgsLon)}, "crs": {"gcj02"}}))
if err != nil {
t.Fatal(err)
}
if got[0].Lat != wgsLat || got[0].Lon != wgsLon {
t.Errorf("gcj02 底图不该换算,实得 %+v", got[0])
}
}
// ---------- SQL 占位符纪律 ----------
var placeholderRe = regexp.MustCompile(`\$(\d+)`)
// topicAreaCond 拼进 SQL 里的 $n 必须**恰好**是 [prefix+1, len(args)] 这个连续区间:
// 不跳号也不越界。这正是「专题内容全变 0」那类事故的判据 —— 少算一个就是 pgx
// 「expected N arguments, got M」,而列表页会把错误吞掉,只表现为数字变 0。
//
// 注意断言的是「本段自己用的那 3n 个编号」:前缀那几个(列表页批量的 dataset/start/end)
// 由查询里的别处引用,不该出现在区域条件里。
func TestTopicAreaCondPlaceholderDiscipline(t *testing.T) {
for _, n := range []int{1, 2, 3, 7} {
for _, prefix := range []int{3, 0} { // 3 = 列表页批量(dataset/start/end 先占位)
areas := make([]TopicArea, 0, n)
for i := 0; i < n; i++ {
areas = append(areas, TopicArea{
Lat: 20 + float64(i), Lon: 100 + float64(i), RadiusKm: 3})
}
topic := &Topic{Areas: areas}
args := make([]any, 0, prefix+3*n)
for i := 0; i < prefix; i++ {
args = append(args, i)
}
cond := topicAreaCond(topic, &args)
if len(args) != prefix+3*n {
t.Fatalf("n=%d prefix=%d:参数个数 = %d,期望 %d", n, prefix, len(args), prefix+3*n)
}
seen := map[int]int{}
maxIdx, count := 0, 0
for _, m := range placeholderRe.FindAllStringSubmatch(cond, -1) {
idx, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("解析占位符 %q 失败", m[1])
}
if seen[idx] == 0 {
count++
}
seen[idx]++
if idx > maxIdx {
maxIdx = idx
}
}
// 最大值必须正好是最后一个参数 —— 否则下一个条件接着编号就会错位
if maxIdx != len(args) {
t.Errorf("n=%d prefix=%d:最大编号 = $%d,应正好是 $%d(否则后续条件会错位)\n%s",
n, prefix, maxIdx, len(args), cond)
}
// 且必须是 prefix+1 起、3n 个互不相同的连续编号
if count != 3*n {
t.Errorf("n=%d prefix=%d:引用了 %d 个不同编号,期望 %d\n%s", n, prefix, count, 3*n, cond)
}
for idx := prefix + 1; idx <= len(args); idx++ {
if seen[idx] == 0 {
t.Errorf("n=%d prefix=%d:$%d 没被引用(跳号)\n%s", n, prefix, idx, cond)
}
}
for idx := 1; idx <= prefix; idx++ {
if seen[idx] != 0 {
t.Errorf("n=%d prefix=%d:不该引用前缀的 $%d(那是查询别处的参数)", n, prefix, idx)
}
}
// OR 的个数 = 圆的个数 - 1(并集语义:落在任一圆内即可)
if got := strings.Count(cond, " OR "); got != n-1 {
t.Errorf("n=%d:OR 个数 = %d,期望 %d\n%s", n, got, n-1, cond)
}
}
}
// 不限区域时既不加参数也不加条件
args := []any{1, 2, 3}
if cond := topicAreaCond(&Topic{}, &args); cond != "" || len(args) != 3 {
t.Errorf("不限区域时应空手而归,实得 cond=%q args=%d", cond, len(args))
}
}
// 老行(areas 还没回填)与测试里直接构造 Topic 的写法都得继续工作。
func TestTopicAreaListFallsBackToLegacyCircle(t *testing.T) {
legacy := Topic{CenterLat: 25, CenterLon: 100, RadiusKm: 30}
if !legacy.HasArea() {
t.Error("只有老的三列时 HasArea 应为真")
}
if got := legacy.AreaList(); len(got) != 1 || got[0].RadiusKm != 30 {
t.Errorf("应退回单圆,实得 %+v", got)
}
// Areas 非空时以 Areas 为准,老的列不再参与
both := Topic{CenterLat: 1, CenterLon: 1, RadiusKm: 1,
Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 26, Lon: 101, RadiusKm: 4}}}
if got := both.AreaList(); len(got) != 2 || got[0].Lat != 25 {
t.Errorf("应以 Areas 为准,实得 %+v", got)
}
// 半径 0 / 无坐标 = 不限区域
if (Topic{}).HasArea() || (Topic{CenterLat: 25, CenterLon: 100}).HasArea() {
t.Error("没有圆时 HasArea 应为假")
}
}
// 出库值不能是 JSON null:areas 列有 NOT NULL,而 json.Marshal(nil slice) 恰好给 "null"。
func TestTopicAreasColumnNeverNull(t *testing.T) {
for _, in := range [][]TopicArea{nil, {}, {{Lat: 1, Lon: 2, RadiusKm: 3}}} {
got := string(topicAreasColumn(in))
if got == "null" {
t.Fatalf("输入 %+v 时写成了 null(列上有 NOT NULL)", in)
}
var back []TopicArea
if err := json.Unmarshal([]byte(got), &back); err != nil {
t.Fatalf("%s 不是合法 JSON: %v", got, err)
}
if len(back) != len(in) {
t.Errorf("往返后圆的个数 %d ≠ %d", len(back), len(in))
}
}
}
// ---------- 视野贴合 ----------
// 外接矩形必须把**每一个**圆都包住(漏一个圆,那个圆里的迷雾就会被挤到视野外)。
func TestTopicAreasBoundsCoversEveryCircle(t *testing.T) {
areas := []TopicArea{
{Lat: 25.0, Lon: 100.0, RadiusKm: 3},
{Lat: 30.5, Lon: 110.25, RadiusKm: 10},
{Lat: 20.0, Lon: 95.0, RadiusKm: 50},
}
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(areas)
if !ok {
t.Fatal("应返回 ok")
}
for _, a := range areas {
dLat := a.RadiusKm / 111.32
cos := math.Cos(a.Lat * math.Pi / 180)
dLon := a.RadiusKm / (111.32 * cos)
if a.Lat-dLat < minLat-1e-9 || a.Lat+dLat > maxLat+1e-9 ||
a.Lon-dLon < minLon-1e-9 || a.Lon+dLon > maxLon+1e-9 {
t.Errorf("圆 %+v 没被外接矩形包住:纬度[%v,%v] 经度[%v,%v]",
a, minLat, maxLat, minLon, maxLon)
}
}
// 单圆时与「圆心 ± r/111.32」等价(原来 fitCircle 用 toBounds(2r) 给的就是这个正方形)
one := []TopicArea{{Lat: 25.0, Lon: 100.0, RadiusKm: 30}}
minLat, maxLat, minLon, maxLon, _ = topicAreasBounds(one)
if !closeTo(minLat, 25-30/111.32) || !closeTo(maxLat, 25+30/111.32) {
t.Errorf("单圆纬度区间 = [%v,%v]", minLat, maxLat)
}
wantDLon := 30 / (111.32 * math.Cos(25*math.Pi/180))
if !closeTo(minLon, 100-wantDLon) || !closeTo(maxLon, 100+wantDLon) {
t.Errorf("单圆经度区间 = [%v,%v],期望 ±%v", minLon, maxLon, wantDLon)
}
// 空 → 没有矩形
if _, _, _, _, ok := topicAreasBounds(nil); ok {
t.Error("没有圆时不该返回矩形")
}
}
// 贴合参数:有显示区就走 bbox、不再给 fitCircle(多圆表达不了圆心+半径)。
func TestFitParamsUsesBBoxForAreas(t *testing.T) {
circle, bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
if string(circle) != "null" {
t.Errorf("限定显示区时不该再给 fitCircle,实得 %s", circle)
}
var b struct{ MinLat, MaxLat, MinLon, MaxLon float64 }
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("fitBBox 不是合法 JSON: %s (%v)", bbox, err)
}
if !(b.MinLat < 25 && b.MaxLat > 25 && b.MinLon < 100 && b.MaxLon > 100) {
t.Errorf("fitBBox 应包住圆心:%+v", b)
}
// 不限区域时仍退回点集包围盒
circle, bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
if string(circle) != "null" {
t.Errorf("点集路径不该给 fitCircle,实得 %s", circle)
}
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("点集 fitBBox 不是合法 JSON: %s", bbox)
}
if b.MinLat != 21 || b.MaxLat != 23 || b.MinLon != 99 || b.MaxLon != 103 {
t.Errorf("点集包围盒 = %+v", b)
}
// 两者都没有 → 两个 null
circle, bbox = fitParams(Topic{}, nil)
if string(circle) != "null" || string(bbox) != "null" {
t.Errorf("既无显示区又无点时应给两个 null,实得 %s / %s", circle, bbox)
}
}
// 经纬度夹取:极地 / 超大半径不至于算出 ±inf 或越界值喂给 fitBounds。
func TestTopicAreasBoundsClampsToGlobe(t *testing.T) {
minLat, maxLat, minLon, maxLon, ok := topicAreasBounds([]TopicArea{{Lat: 89.9, Lon: 179.9, RadiusKm: 2000}})
if !ok {
t.Fatal("应返回 ok")
}
for _, v := range []float64{minLat, maxLat} {
if v < -90 || v > 90 || math.IsInf(v, 0) || math.IsNaN(v) {
t.Errorf("纬度夹取后仍是 %v", v)
}
}
for _, v := range []float64{minLon, maxLon} {
if v < -180 || v > 180 || math.IsInf(v, 0) || math.IsNaN(v) {
t.Errorf("经度夹取后仍是 %v", v)
}
}
}
// ---------- 个数上限 ----------
func TestNormalizeTopicAreasLimits(t *testing.T) {
ok := make([]TopicArea, maxTopicAreas)
for i := range ok {
ok[i] = TopicArea{Lat: 20 + float64(i)/100, Lon: 100, RadiusKm: 3}
}
if _, err := normalizeTopicAreas(ok, ""); err != nil {
t.Errorf("%d 个圆应当允许:%v", maxTopicAreas, err)
}
tooMany := append(append([]TopicArea(nil), ok...), TopicArea{Lat: 25, Lon: 100, RadiusKm: 3})
if _, err := normalizeTopicAreas(tooMany, ""); err == nil {
t.Errorf("%d 个圆应当拒绝", len(tooMany))
}
for _, bad := range []TopicArea{
{Lat: math.NaN(), Lon: 100, RadiusKm: 3},
{Lat: 25, Lon: math.Inf(1), RadiusKm: 3},
{Lat: 25, Lon: 100, RadiusKm: math.NaN()},
{Lat: 25, Lon: 100, RadiusKm: topicAreaMaxRadiusKm + 1},
{Lat: 25, Lon: 100, RadiusKm: 0},
} {
if _, err := normalizeTopicAreas([]TopicArea{bad}, ""); err == nil {
t.Errorf("%+v 应当被拒绝", bad)
}
}
}