package web import ( "io/fs" "os" "regexp" "strings" "testing" ) // ---------- F5:地图脚本里的 localStorage 访问必须走兜底 ---------- // // 隐私模式 / 禁用 Cookie 的浏览器里 localStorage 读写都会抛异常,裸调用会把整段脚本打断 // (地图白屏、图层不出来,且控制台之外看不出来)。统一走 lsGet/lsSet。 func TestMapLocalStorageGoesThroughGuard(t *testing.T) { b, err := staticFS.ReadFile("static/js/rond-map.js") if err != nil { t.Fatalf("读取 rond-map.js 失败: %v", err) } src := string(b) if !strings.Contains(src, "function lsGet(k)") || !strings.Contains(src, "function lsSet(k, v)") { t.Fatal("rond-map.js 里找不到 lsGet/lsSet 兜底(写法变了,这条守卫失效)") } if n := strings.Count(src, "window.localStorage."); n != 2 { t.Errorf("裸 localStorage 访问应只剩 lsGet/lsSet 里那 2 处,实际 %d 处"+ "(其余一律要走 lsGet/lsSet,否则隐私模式下整段脚本会抛异常)", n) } } // ---------- F6:配置未就绪时的第二次 preview 不能丢点 ---------- func TestMapPreviewQueuesPendingPoints(t *testing.T) { b, err := staticFS.ReadFile("static/js/rond-map.js") if err != nil { t.Fatalf("读取 rond-map.js 失败: %v", err) } src := string(b) body := funcBody(src, "preview: function (opts)") if body == "" { t.Fatal("在 rond-map.js 里找不到 preview(写法变了,这条守卫失效)") } queue := strings.Index(body, "previewQueue[opts.id] = points;") live := strings.Index(body, "if (live) {") if queue < 0 || live < 0 || queue > live { t.Error("preview:必须在 `if (live)` 之前就把本次的点记进 previewQueue," + "否则配置未就绪时第二次 preview 的点会被静默丢掉") } if !strings.Contains(body, "if (previewQueue[opts.id]) points = previewQueue[opts.id];") { t.Error("preview:配置回调里没把 previewQueue 排队的点取回来画") } if strings.Contains(src, "previewPending") { t.Error("rond-map.js 里不该再有 previewPending(已由 previewQueue 取代)") } } // ---------- F8:EXIF 全 0 时间不能变成 1899-11-30 ---------- func TestExifRejectsOutOfRangeDate(t *testing.T) { b, err := staticFS.ReadFile("static/js/photo-exif.js") if err != nil { t.Fatalf("读取 photo-exif.js 失败: %v", err) } src := string(b) guard := strings.Index(src, "Y >= 1900") mk := strings.Index(src, "res.shot = new Date(Y, Mo - 1, D, H, Mi, S);") flag := strings.Index(src, "res.hasTime = true;") if guard < 0 || mk < 0 || flag < 0 || guard > mk || guard > flag { t.Error("photo-exif.js:拍摄时间必须先过范围校验再赋给 res.shot / hasTime" + "(否则「0000:00:00」会被 Date 归一成 1899-11-30,被当成到达时间写进库)") } } // ---------- F9:findExifItemID 读 count 前必须卡住宽度 ---------- func TestExifItemIDChecksBoundsBeforeRead(t *testing.T) { b, err := staticFS.ReadFile("static/js/photo-exif.js") if err != nil { t.Fatalf("读取 photo-exif.js 失败: %v", err) } body := funcBody(string(b), "function findExifItemID(") if body == "" { t.Fatal("在 photo-exif.js 里找不到 findExifItemID(写法变了,这条守卫失效)") } bounds := strings.Index(body, "p + 2 > u8.length") read := strings.Index(body, "dv.getUint16(p)") if bounds < 0 || read < 0 || bounds > read { t.Error("findExifItemID:读 count 之前必须按 ver 卡住宽度(p+2 / p+4)——" + "末尾恰好相等(start+4 == length)时旧守卫会放行,越界会抛 RangeError") } } // ---------- F16:数据编辑页每个筛选表单都要带上全部筛选条件 ---------- // // 三个表单各自只放自己那几个控件,其余条件靠隐藏域带着;漏一个,用户一提交 // 就会把别的列表的筛选悄悄清掉(原来「到访」表单只带 vq+back)。 func TestEditSearchFormsCarryAllFilters(t *testing.T) { b, err := tmplFS.ReadFile("templates/admin/edit.html") if err != nil { t.Fatalf("读取 edit.html 失败: %v", err) } src := string(b) const open = `
") if j < 0 { t.Fatalf("第 %d 个筛选表单没有收尾", n) } body := rest[:j] for _, p := range params { if !strings.Contains(body, p) { t.Errorf("第 %d 个筛选表单少了 %s:一提交就会把别的列表的筛选清掉", n, p) } } rest = rest[j:] } if n < 3 { t.Fatalf("只找到 %d 个 /admin/edit 筛选表单,预期至少 3 个(写法变了,这条守卫失效)", n) } } // ---------- F17:`safe` 这个现成的转义出口不该留着 ---------- func TestNoSafeEscapeFunc(t *testing.T) { b, err := os.ReadFile(pkgFile(t, "view.go")) if err != nil { t.Fatalf("读取 view.go 失败: %v", err) } if strings.Contains(string(b), `"safe":`) { t.Error("view.go 里不该再有 `safe` 模板函数(模板一处都没用,却是个现成的 HTML 注入出口)") } } // ---------- F18:纯图标按钮要有 aria-label,图片要有 alt ---------- func TestPlaceImageControlsAreLabelled(t *testing.T) { b, err := tmplFS.ReadFile("templates/place.html") if err != nil { t.Fatalf("读取 place.html 失败: %v", err) } src := string(b) if !strings.Contains(src, `aria-label="删除这张图片"`) { t.Error("place.html 模板渲染的删除按钮(×)缺 aria-label") } if !strings.Contains(src, `setAttribute('aria-label', '删除这张图片')`) { t.Error("place.html 用 JS 新建的删除按钮(×)缺 aria-label") } if strings.Contains(src, `img.alt = ''`) { t.Error("place.html 新建缩略图时 alt 不能留空") } } // ---------- 窄屏导航:链接行收进菜单(原来是横向滚动,最右边那个字会被切掉) ---------- // // 620px 以下链接行一定放不下(实测:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴 // 后面的按钮,而渐隐提示实际上看不出来)。现在整行收进 .nav-menu 这个原生
。 // // 这条守卫钉的是**成对关系**:CSS 的两半(桌面藏菜单 / 窄屏藏链接行)与模板里的两个类 // 缺任何一个,窄屏就会退回老样子、或者宽屏凭空多出一个菜单按钮。 func TestNarrowNavCollapsesToMenu(t *testing.T) { css, err := os.ReadFile(pkgFile(t, "static/css/app.css")) if err != nil { t.Fatalf("读取 app.css 失败: %v", err) } src := string(css) for _, want := range []string{ ".nav-menu { display: none; }", // 桌面:菜单按钮不出现 ".nav-links { display: none; }", // 窄屏:链接行不出现 ".nav-menu { display: block; flex: 1; }", // 窄屏:菜单按钮取而代之 } { if !strings.Contains(src, want) { t.Errorf("app.css 缺少 %q —— 「桌面藏菜单 / 窄屏藏链接行」这两半必须成对,"+ "否则窄屏会退回「横向滚动 + 最右边的字被切掉」", want) } } b, err := tmplFS.ReadFile("templates/layout.html") if err != nil { t.Fatalf("读取 layout.html 失败: %v", err) } nav := string(b) if !strings.Contains(nav, `class="nav-links"`) || !strings.Contains(nav, `class="nav-menu"`) { t.Error("layout.html 里 .nav-links 与 .nav-menu 必须都在:CSS 的两半分别作用在这两个元素上") } if n := strings.Count(nav, `{{template "navitems" .}}`); n != 2 { t.Errorf("导航链接应当渲染两遍(宽屏平铺 + 窄屏菜单),实际 %d 处", n) } } // ---------- 日期输入必须走统一入口 attachAll ---------- // // 2026-10-09 用户报的 bug:筛选区是原生 ,而挂载代码只写了 // querySelectorAll('input[type=datetime-local]') —— **类型变体没人管**,于是 Chrome 的年段 // 吞下 6 位数字,敲 20260606 变成 202606/06、el.value 还是空的(筛选条件静默丢掉)。 // // 守卫三件事: // // ① 模板里不许再出现按 type 挑日期框的选择器 —— 那个选择器只能待在 datetime-input.js 里; // ② layout.html 必须**无条件**引入 datetime-input.js(公共页的筛选框也要用,那边不加载 flatpickr); // ③ 统一入口 attachAll 必须存在,且四类页面都调它。 func TestDateInputsGoThroughAttachAll(t *testing.T) { js, err := staticFS.ReadFile("static/js/datetime-input.js") if err != nil { t.Fatalf("读取 datetime-input.js 失败: %v", err) } if !strings.Contains(string(js), "function attachAll(") { t.Fatal("datetime-input.js 里没有 attachAll —— 统一入口没了,以后新加的输入类型又会被漏掉") } layout, err := tmplFS.ReadFile("templates/layout.html") if err != nil { t.Fatalf("读取 layout.html 失败: %v", err) } lay := string(layout) // 日期控件与它的日历弹层必须**无条件**加载:筛选区在公共页上也有日期框,而 // 「按页开开关」这种写法漏过一次(2026-10-09 的日期框 bug 就是只挂了 datetime-local)。 for _, needle := range []string{ "/static/vendor/flatpickr/flatpickr.min.js", "/static/vendor/flatpickr/flatpickr.min.css", "/static/js/datetime-input.js", "rondDatetime.attachAll(", } { if !strings.Contains(lay, needle) { t.Errorf("layout.html 里缺 %s —— 那一页的日期框会退回原生控件(没有日历弹层)", needle) } } if strings.Contains(lay, "{{if .Flatpickr}}") { t.Error("layout.html 里又出现了 {{if .Flatpickr}} 这种按页开关 —— 日期控件已改成无条件加载," + "开关一旦漏设,那一页的日期框就没有日历弹层(别再引入会漏的东西)") } names, err := fs.Glob(tmplFS, "templates/*.html") if err != nil { t.Fatalf("列举模板失败: %v", err) } adminNames, err := fs.Glob(tmplFS, "templates/admin/*.html") if err != nil { t.Fatalf("列举后台模板失败: %v", err) } sels := []string{"input[type=datetime-local]", "input[type=date]"} for _, name := range append(names, adminNames...) { b, err := tmplFS.ReadFile(name) if err != nil { t.Fatalf("读取 %s 失败: %v", name, err) } for _, sel := range sels { if strings.Contains(string(b), sel) { t.Errorf("%s 里又出现手写的日期选择器 %q —— 一律走 rondDatetime.attachAll(),"+ "否则新加的输入类型会被漏掉(2026-10-09 的日期框 bug 就是这么来的)", name, sel) } } } for _, name := range []string{"templates/admin/edit.html", "templates/admin/photos.html", "templates/admin/topics.html"} { b, err := tmplFS.ReadFile(name) if err != nil { t.Fatalf("读取 %s 失败: %v", name, err) } if !strings.Contains(string(b), "attachAll(") { t.Errorf("%s 没有调用 attachAll(它自己那批日期框会退回原生控件)", name) } } } // ---------- 专题的迷雾要和「显示区」成对:模板传 fogAreas、地图脚本认 fogAreas ---------- // // 2026-10-10 用户实测发现:专题圈了区域,迷雾却还是全局的。而管理页原本的说明写的是 // 「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」—— **文档与实现不一致** // (迷雾一直是全局瓦片,完全不认区域),这种「说明写了、实现没做」最容易蒙混过关。 // // 这条守卫钉住两端与两处顺序: // ① topic.html 必须把专题显示区传给地图(且**无条件**传,不限区域时是空数组); // ② rond-map.js 必须实现收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面) // 且多圆要交给 fog-clip.js 的 pathFor; // ③ fog-clip.js 必须在 rond-map.js **之前**加载(否则 window.rondFogClip 还没有); // ④ 裁剪的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛 // 「Set map center and zoom first.」,那行异常会把后面的图层添加一起打断 // (实测:pane 建好了、迷雾瓦片一张都没有)。 func TestTopicFogIsClippedToAreas(t *testing.T) { tpl, err := tmplFS.ReadFile("templates/topic.html") if err != nil { t.Fatalf("读取 topic.html 失败: %v", err) } body := string(tpl) if !strings.Contains(body, "fogAreas:") { t.Error("topic.html 没把专题显示区(fogAreas)传给地图 —— 迷雾不会被收口") } // 无条件传(不要包在 {{if .Topic.HasArea}} 里):条件分支是「有区域忘了新增分支」 // 这类漏传的温床,而空数组对脚本来说就是「不限区域」,没必要分两条路。 if !strings.Contains(body, "fogAreas: {{.Topic.AreasJSON}}") { t.Error("fogAreas 应当无条件下发 {{.Topic.AreasJSON}}(不限区域时为空数组),别包在条件里") } js, err := staticFS.ReadFile("static/js/rond-map.js") if err != nil { t.Fatalf("读取 rond-map.js 失败: %v", err) } src := string(js) for _, needle := range []string{"opts.fogAreas", "createPane('rondFog')", "clipPath", "rondFogClip.pathFor"} { if !strings.Contains(src, needle) { t.Errorf("rond-map.js 里缺 %q —— 迷雾的收口没实现或被改掉了", needle) } } // 顺序:注册点必须排在 `var applyFit = function` 之后 —— 放早了(比如塞在迷雾图层那段里) // 就是在视图定下来之前算裁剪圆,会抛「Set map center and zoom first.」并打断后续图层添加。 reg := strings.Index(src, "map.on('move zoom viewreset resize', applyFogClip)") fitDef := strings.Index(src, "var applyFit = function") if reg < 0 || fitDef < 0 || reg < fitDef { t.Error("applyFogClip 的注册必须排在 applyFit 定义(也就是视图设定)之后 —— " + "视图未定时算裁剪圆会抛错,并把后面的图层添加一起打断") } // 多圆路径拼在 fog-clip.js 里,它必须在 rond-map.js 之前加载 clip, err := staticFS.ReadFile("static/js/fog-clip.js") if err != nil { t.Fatalf("读取 fog-clip.js 失败: %v", err) } if !strings.Contains(string(clip), "rondFogClip") { t.Error("fog-clip.js 没有暴露 rondFogClip —— rond-map.js 拿不到路径构造函数") } layout, err := tmplFS.ReadFile("templates/layout.html") if err != nil { t.Fatalf("读取 layout.html 失败: %v", err) } lay := string(layout) iClip := strings.Index(lay, "/static/js/fog-clip.js") iMap := strings.Index(lay, "/static/js/rond-map.js") if iClip < 0 { t.Error("layout.html 没引入 fog-clip.js —— 专题的多圆裁剪会静默退化成「不裁剪」") } else if iMap < 0 || iClip > iMap { t.Error("fog-clip.js 必须排在 rond-map.js 之前加载") } } // ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ---------- // // 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 / // 17 / 18 / 21px 十几档字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶; // 模板里另有 54 处内联字号。现在:文本走 --fs-*,表单控件走 --fs-control(窄屏必须 ≥16px, // 否则 iOS 聚焦会放大整页),字形走 --glyph-*(22px 的 ✕ 塞进字阶只会把按钮撑坏)。 func TestNoBareFontSizePx(t *testing.T) { re := regexp.MustCompile(`font-size:\s*[^;\n]*[0-9]+(\.[0-9]+)?px`) check := func(name string, b []byte) { for _, m := range re.FindAllString(string(b), -1) { t.Errorf("%s 里还有裸字号 %q —— 一律走 --fs-* / --fs-control / --glyph-* 令牌", name, m) } } b, err := os.ReadFile(pkgFile(t, "static/css/app.css")) if err != nil { t.Fatalf("读取 app.css 失败: %v", err) } check("app.css", b) 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) } check(name, body) } } } // ---------- F10:补全页的 sessionStorage 读写都要包在 try 里 ---------- // // 隐私模式下 sessionStorage 本身就抛,脏数据又会让 JSON.parse 抛;任何一处漏了 try, // 整段脚本就从那一行断掉——下拉候选、草稿恢复全部哑掉,而且控制台之外看不出来。 func TestFillupSessionStorageIsGuarded(t *testing.T) { b, err := tmplFS.ReadFile("templates/admin/fillup.html") if err != nil { t.Fatalf("读取 fillup.html 失败: %v", err) } src := string(b) needles := []string{"JSON.parse(sessionStorage", "sessionStorage.setItem"} total := 0 for _, n := range needles { rest := src for { i := strings.Index(rest, n) if i < 0 { break } total++ if open := strings.LastIndex(rest[:i], "try {"); open < 0 || i-open > 160 { t.Errorf("fillup.html:`%s` 前面没有紧挨着的 try {(隐私模式 / 脏数据会中断整段脚本)", n) } rest = rest[i+len(n):] } } if total == 0 { t.Fatal("fillup.html 里找不到 sessionStorage 读写(写法变了,这条守卫失效)") } }