现象:输入任意正常半径(3、3.5、7.25…)提交时弹 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」。 根因:<input type="number"> 的 **step 是相对 min 递增**的,不是「step 的整数倍」。 min=0.05 + step=0.5 ⇒ 合法值只有 0.05 / 0.55 / 1.05 …,于是 3 和 3.5 全被判非法。 而这是**浏览器原生**提示:JS 与后端都拦不到(我们自己的校验根本没机会说话)。 实测对照(同一元素换 step,其余不动): step=0.5 :0.05✓ 1✗ 3✗ 3.5✗ 7.25✗ 13.37✗ step=0.05 :0.05✓ 1✓ 3✓ 3.5✓ 7.25✓ 13.37✗ ← 仍会拦任意小数 step=any :全 ✓ ← 采用 保留 min/max(真实边界,且给 spinner 范围),只把 step 放开交给 JS/后端校验, 理由与写法都记在模板注释里。 同一 bug 类加了通配守卫,防的是下次换个页面又踩: - TestNumberInputStepNeverMismatchesMin:扫**所有**模板的数字框, 凡「有 min 且有数值 step」就要求 min 是 step 的整数倍(或改用 any); - TestTopicAreaRadiusInputAllowsAnyStep:列表里的输入框是 JS 动态建的,模板扫不到, 单独钉住 step='any'。 变异验证过:把 step 改回 0.5,守卫立刻红。 实测(无头):输入 3.5 后 validationMessage 为空、form.checkValidity() 为真 ⇒ 回车提交不再弹气泡;隐藏字段正确更新为 radiusKm:3.5;越界值只给我们的中文提示 (stepMismatch=false、rangeUnderflow=true,说明「step 误判」这一类已消除)。 全站其它数字框(如迷雾遮罩的 radius_m,min=50/step=50)本来就合法,未改动。
575 lines
21 KiB
Go
575 lines
21 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/fs"
|
||
"math"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 编辑器:两边写死的上限必须一致 ----------
|
||
//
|
||
// 前端 validate() 与后端 normalizeTopicAreas() 各写了一份上限。不一致的表现很别扭:
|
||
// 前端放行、后端拒绝 ⇒ 用户看到一句红字却不知道改哪里(甚至以为是自己填错了)。
|
||
// 这两份数字没有办法共享(一边 Go 一边 JS),所以直接静态比对。
|
||
func TestTopicAreaLimitsMatchBetweenGoAndJS(t *testing.T) {
|
||
js, err := staticFS.ReadFile("static/js/topic-areas.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 topic-areas.js 失败: %v", err)
|
||
}
|
||
src := string(js)
|
||
get := func(name string) string {
|
||
m := regexp.MustCompile(name + `\s*=\s*([0-9.]+)`).FindStringSubmatch(src)
|
||
if m == nil {
|
||
t.Fatalf("topic-areas.js 里找不到 %s 的定义", name)
|
||
}
|
||
return m[1]
|
||
}
|
||
cases := []struct {
|
||
jsName string
|
||
want float64
|
||
}{
|
||
{"MAX_AREAS", float64(maxTopicAreas)},
|
||
{"MIN_KM", topicAreaMinRadiusKm},
|
||
{"MAX_KM", topicAreaMaxRadiusKm},
|
||
}
|
||
for _, c := range cases {
|
||
got, err := strconv.ParseFloat(get(c.jsName), 64)
|
||
if err != nil {
|
||
t.Fatalf("解析 %s 失败: %v", c.jsName, err)
|
||
}
|
||
if got != c.want {
|
||
t.Errorf("%s:JS 写的是 %v,后端是 %v —— 两边必须一致(前端放行后端拒绝,用户不知道改哪里)",
|
||
c.jsName, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 编辑器:模板契约 ----------
|
||
//
|
||
// 钉住「编辑器的挂点」与「旧入口已经拆掉」:
|
||
//
|
||
// ① 三个手填坐标输入框必须没了 —— 留着就是第二套入口,而它只写第 1 个圆,
|
||
// 用户会觉得「我明明填了坐标,怎么只认一个」;
|
||
// ② areas 的初值必须**渲染进隐藏字段**(不是只放在 JS 里):脚本没加载上时提交的
|
||
// 仍是原值,否则一次保存就把已有显示区静默清空;
|
||
// ③ 编辑器脚本必须在页面里引入,且 init 的挂点 id 与模板里的 id 对得上。
|
||
func TestTopicAdminUsesMapEditorNotCoordInputs(t *testing.T) {
|
||
b, err := tmplFS.ReadFile("templates/admin/topics.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 admin/topics.html 失败: %v", err)
|
||
}
|
||
body := string(b)
|
||
for _, gone := range []string{`name="center_lat"`, `name="center_lon"`, `name="radius_km"`} {
|
||
if strings.Contains(body, gone) {
|
||
t.Errorf("admin/topics.html 不该再有 %s —— 显示区改由地图点选,留着就是第二套只写第 1 个圆的入口", gone)
|
||
}
|
||
}
|
||
for _, need := range []string{
|
||
`id="area-map"`, `id="area-list"`, `id="area-new-km"`, `id="area-clear"`, `id="area-hint"`,
|
||
`id="topic-form"`, `name="areas"`, `name="crs"`,
|
||
`/static/js/topic-areas.js`, `rondTopicAreas.init(`,
|
||
} {
|
||
if !strings.Contains(body, need) {
|
||
t.Errorf("admin/topics.html 里缺 %q —— 显示区编辑器没挂全", need)
|
||
}
|
||
}
|
||
// areas 初值要在隐藏字段上(服务端渲染),不是只传进 JS
|
||
if !strings.Contains(body, `name="areas" value="{{.Form.AreasJSON}}"`) {
|
||
t.Error(`areas 的初值必须渲染进隐藏字段(value="{{.Form.AreasJSON}}"):` +
|
||
"脚本没加载上时要能原样提交,否则一次保存就清空已有显示区")
|
||
}
|
||
// 日期控件那条既有约定不能被挤掉
|
||
if !strings.Contains(body, "rondDatetime.attachAll") {
|
||
t.Error("admin/topics.html 里的日期控件初始化没了")
|
||
}
|
||
}
|
||
|
||
// 后台专题页要有地图 ⇒ 必须设 p.HasMap(布局按它决定引不引入 Leaflet)。
|
||
// 漏了的表现很隐蔽:脚本加载了、编辑器对象也在,就是 window.L 永远 undefined,
|
||
// init 直接静默返回 —— 页面看着「什么都没发生」,控制台也不报错。实测就是这么踩的。
|
||
func TestTopicsAdminPageLoadsLeaflet(t *testing.T) {
|
||
src, err := os.ReadFile(pkgFile(t, "handlers_topics.go"))
|
||
if err != nil {
|
||
t.Fatalf("读取 handlers_topics.go 失败: %v", err)
|
||
}
|
||
body := string(src)
|
||
i := strings.Index(body, "func (s *Server) topicsAdminPage(")
|
||
if i < 0 {
|
||
t.Fatal("handlers_topics.go 里找不到 topicsAdminPage")
|
||
}
|
||
// 取到下一个顶层 func 为止(函数体不会嵌套顶层 func)
|
||
rest := body[i:]
|
||
if j := strings.Index(rest[1:], "\nfunc "); j >= 0 {
|
||
rest = rest[:j+1]
|
||
}
|
||
if !strings.Contains(rest, "HasMap = true") {
|
||
t.Error("topicsAdminPage 必须设 p.HasMap = true —— 不设的话布局不引入 Leaflet," +
|
||
"显示区编辑器会静默不出现(脚本在、编辑器在,就是 window.L 是 undefined)")
|
||
}
|
||
}
|
||
|
||
// ---------- 数字输入框:step 是相对 min 递增的 ----------
|
||
//
|
||
// <input type="number" min="0.05" step="0.5"> 的合法值**不是** 0.5 的倍数,而是
|
||
// min + n*step = 0.05 / 0.55 / 1.05 …:用户填 3 或 3.5 会被 Chrome 判非法,提交时弹
|
||
// 「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」——这是**浏览器原生**提示,
|
||
// JS 与后端都拦不到,只能在源头避免(2026-10-12 专题显示区的半径输入框就是这么踩的)。
|
||
//
|
||
// 规则:要么 step="any"(随便填,交给 JS/后端校验),要么 min 必须是 step 的整数倍。
|
||
// 这条守卫扫**所有模板**里的数字框,不是只盯着出问题那个 —— 否则下次换个页面又会踩。
|
||
func TestNumberInputStepNeverMismatchesMin(t *testing.T) {
|
||
tagRe := regexp.MustCompile(`<input\b[^>]*>`)
|
||
attr := func(tag, name string) string {
|
||
m := regexp.MustCompile(name + `="([^"]*)"`).FindStringSubmatch(tag)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
return m[1]
|
||
}
|
||
checked := 0
|
||
for _, dir := range []string{"templates/*.html", "templates/admin/*.html"} {
|
||
names, err := fs.Glob(tmplFS, dir)
|
||
if err != nil {
|
||
t.Fatalf("列举 %s 失败: %v", dir, err)
|
||
}
|
||
for _, name := range names {
|
||
body, err := tmplFS.ReadFile(name)
|
||
if err != nil {
|
||
t.Fatalf("读取 %s 失败: %v", name, err)
|
||
}
|
||
for _, tag := range tagRe.FindAllString(string(body), -1) {
|
||
if !strings.Contains(tag, `type="number"`) {
|
||
continue
|
||
}
|
||
step := attr(tag, "step")
|
||
if step == "" || step == "any" {
|
||
continue
|
||
}
|
||
minRaw := attr(tag, "min")
|
||
if minRaw == "" {
|
||
continue
|
||
}
|
||
mn, e1 := strconv.ParseFloat(minRaw, 64)
|
||
st, e2 := strconv.ParseFloat(step, 64)
|
||
if e1 != nil || e2 != nil || st <= 0 {
|
||
continue
|
||
}
|
||
checked++
|
||
if r := math.Mod(mn, st); r > 1e-9 && st-r > 1e-9 {
|
||
t.Errorf("%s 里 min=%q step=%q 是非法组合:step 相对 min 递增,合法值只有 min+n*step "+
|
||
"⇒ 正常输入会被浏览器判非法并弹原生提示。改成 step=\"any\",或让 min 是 step 的整数倍。\n %s",
|
||
name, minRaw, step, tag)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
t.Logf("扫过 %d 个「有 min 且有小数 step」的数字框", checked)
|
||
}
|
||
|
||
// 编辑器里列表的半径输入框是 JS 动态建的,模板守卫扫不到,单独钉一下。
|
||
func TestTopicAreaRadiusInputAllowsAnyStep(t *testing.T) {
|
||
js, err := staticFS.ReadFile("static/js/topic-areas.js")
|
||
if err != nil {
|
||
t.Fatalf("读取 topic-areas.js 失败: %v", err)
|
||
}
|
||
src := string(js)
|
||
if !strings.Contains(src, "inp.step = 'any'") {
|
||
t.Error("topic-areas.js 里半径输入框必须 inp.step = 'any' —— " +
|
||
"写死小数 step 会和 min 组合出「正常值被判非法」的原生提示")
|
||
}
|
||
if regexp.MustCompile(`inp\.step\s*=\s*'[0-9]`).MatchString(src) {
|
||
t.Error("topic-areas.js 里半径输入框的 step 别写死数字(见 step 相对 min 递增那条)")
|
||
}
|
||
}
|