以前专题的中心/半径只用来定视图,迷雾图层一直是全局的;管理页那句 「迷雾由地图按视野自动呈现,不需要单独圈选」其实是把「没做」写成了 「不用做」。现在专题圈了区域时,迷雾只在那个圆内显示。 做法是给迷雾单独一个 pane(zIndex 250,底图 200 之上)再对该 pane 做 clip-path: circle()——不能直接裁 tilePane,底图也在里面,会把地图一起挖空。 两个坑: - 圆心必须先 project() 到底图坐标系(与 fitCircle 一致),否则整体偏移; - 裁剪圆的注册必须排在 applyFit() 之后:视图未定时 latLngToContainerPoint() 会抛「Set map center and zoom first.」,把后续图层添加一起打断——实测 表现是 pane 建好了、迷雾瓦片一张都不出来,而且不报错。 迷雾是累积快照、没有时间维度,所以只按区域收口,不按专题的时间范围收口。 守卫 TestTopicFogIsClippedToArea 钉住两端与注册顺序。
374 lines
15 KiB
Go
374 lines
15 KiB
Go
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 = `<form method="get" action="/admin/edit"`
|
||
params := []string{`name="q"`, `name="src"`, `name="sus"`, `name="vq"`,
|
||
`name="mt"`, `name="mp"`, `name="mfrom"`, `name="mto"`}
|
||
n := 0
|
||
rest := src
|
||
for {
|
||
i := strings.Index(rest, open)
|
||
if i < 0 {
|
||
break
|
||
}
|
||
n++
|
||
rest = rest[i:]
|
||
j := strings.Index(rest, "</form>")
|
||
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 这个原生 <details>。
|
||
//
|
||
// 这条守卫钉的是**成对关系**: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:筛选区是原生 <input type=date>,而挂载代码只写了
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 专题的迷雾要和「区域」成对:模板传 fogArea、地图脚本认 fogArea ----------
|
||
//
|
||
// 2026-10-10 用户实测发现:专题圈了区域,迷雾却还是全局的。而管理页原本的说明写的是
|
||
// 「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」—— **文档与实现不一致**
|
||
// (迷雾一直是全局瓦片,完全不认区域),这种「说明写了、实现没做」最容易蒙混过关。
|
||
//
|
||
// 这条守卫钉住两端与一处顺序:
|
||
// ① topic.html 必须把专题区域传给地图;
|
||
// ② rond-map.js 必须实现圆形收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面);
|
||
// ③ 裁剪圆的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛
|
||
// 「Set map center and zoom first.」,那行异常会把后面的图层添加一起打断
|
||
// (实测:pane 建好了、迷雾瓦片一张都没有)。
|
||
func TestTopicFogIsClippedToArea(t *testing.T) {
|
||
tpl, err := tmplFS.ReadFile("templates/topic.html")
|
||
if err != nil {
|
||
t.Fatalf("读取 topic.html 失败: %v", err)
|
||
}
|
||
if !strings.Contains(string(tpl), "fogArea:") {
|
||
t.Error("topic.html 没把专题区域(fogArea)传给地图 —— 迷雾不会被收口到那个圆内")
|
||
}
|
||
|
||
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.fogArea", "createPane('rondFog')", "clipPath"} {
|
||
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 定义(也就是视图设定)之后 —— " +
|
||
"视图未定时算裁剪圆会抛错,并把后面的图层添加一起打断")
|
||
}
|
||
}
|
||
|
||
// ---------- 字阶纪律: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 读写(写法变了,这条守卫失效)")
|
||
}
|
||
}
|