Files
rond-with-you/internal/web/topic_areas_test.go
T
tamakyi 44763dfc3f 专题的多圆显示区:前台按并集裁剪迷雾
上一批把显示区改成了「一组圆」,这一批让前台真正认多个圆。

- clip-path 从 circle() 换成 path():circle() 只表达得了一个圆。每个圆一段
  圆弧子路径、**同绕向**(sweep=0),nonzero 填充规则下多段自然取并集,
  重叠处不会互相挖洞。
- 拼路径的活单独放 fog-clip.js(rond-map.js 是个大 IIFE,函数取不出来),
  这样能直接跑 node 单测;layout 里必须排在 rond-map.js 之前加载。
- 顺带清掉已经死掉的 fitCircle:多圆之后「圆心 + 半径」表达不了,留着就是
  两套并行的贴合口径。fitParams 现在只给 fitBBox(单圆时外接矩形正是那个
  圆的外接正方形,视野等价)。
- 守卫改名 TestTopicFogIsClippedToAreas,钉住两端(模板发 fogAreas ↔ 脚本认
  fogAreas)与两处顺序(裁剪注册在 applyFit 之后、fog-clip.js 在 rond-map.js
  之前);fogAreas 必须**无条件**下发(空数组 = 不限区域),不要包在条件里。
- verify.sh 挂进前端单测:以前只靠人记得手动跑 = 等于没有。找不到 node 直接
  失败而不是跳过(跳过会让验收看着全绿、实际一行前端脚本都没验)。

验证(临时两圆专题,SQL 造的,已删):
- fog-clip.test.js 15 条:把生成的 path 解析回圆弧再验几何,含绕向一致性与
  半径 0/负数的夹取。
- 端到端像素验证:自校准出「CSS 容器坐标 → 图像像素」(scale≈2、offset≈19.2,
  正是 site_cdp 的 --clip 外扩 10px×scale,别再手算);三张掩码图证明并集语义
  —— A 独有点只有 A 亮、B 独有点只有 B 亮、**重叠中心两圆都亮且并集也亮
  (无洞)**,整体集合差异 0.013%。
- 迷雾掩码:86,776 个像素,按两圆并集的解析式判 **圆外 0 个**;两个圆内分别
  有 52,609 / 46,757 个迷雾像素 ⇒ 第二个圆真的被认了,不是只看第一个。
2026-10-12 16:44:55 +08:00

397 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("没有圆时不该返回矩形")
}
}
// 贴合参数:有显示区就用所有圆的并集外接矩形,否则退回点集包围盒;
// 两者都没有时给 null(脚本据此再退回它自己的 fit)。
func TestFitParamsUsesBBox(t *testing.T) {
var b struct{ MinLat, MaxLat, MinLon, MaxLon float64 }
bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
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)
}
// 多圆:矩形要同时包住两个圆,而不是只包住第一个
bbox = fitParams(Topic{Areas: []TopicArea{
{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 30, Lon: 110, RadiusKm: 5}}}, nil)
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("多圆 fitBBox 不是合法 JSON: %s (%v)", bbox, err)
}
if !(b.MinLat < 25 && b.MaxLat > 30 && b.MinLon < 100 && b.MaxLon > 110) {
t.Errorf("多圆 fitBBox 应同时包住两个圆:%+v", b)
}
// 不限区域时退回点集包围盒
bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
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,交给脚本自己的点集贴合
if got := fitParams(Topic{}, nil); string(got) != "null" {
t.Errorf("既无显示区又无点时应给 null,实得 %s", got)
}
}
// 经纬度夹取:极地 / 超大半径不至于算出 ±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)
}
}
}