Files
rond-with-you/internal/web/frontend_fixes_test.go
T
tamakyi eb35abb7f2 专题页的迷雾按专题圈的区域收口
以前专题的中心/半径只用来定视图,迷雾图层一直是全局的;管理页那句
「迷雾由地图按视野自动呈现,不需要单独圈选」其实是把「没做」写成了
「不用做」。现在专题圈了区域时,迷雾只在那个圆内显示。

做法是给迷雾单独一个 pane(zIndex 250,底图 200 之上)再对该 pane 做
clip-path: circle()——不能直接裁 tilePane,底图也在里面,会把地图一起挖空。

两个坑:
- 圆心必须先 project() 到底图坐标系(与 fitCircle 一致),否则整体偏移;
- 裁剪圆的注册必须排在 applyFit() 之后:视图未定时 latLngToContainerPoint()
  会抛「Set map center and zoom first.」,把后续图层添加一起打断——实测
  表现是 pane 建好了、迷雾瓦片一张都不出来,而且不报错。

迷雾是累积快照、没有时间维度,所以只按区域收口,不按专题的时间范围收口。
守卫 TestTopicFogIsClippedToArea 钉住两端与注册顺序。
2026-10-10 10:41:27 +08:00

374 lines
15 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 (
"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 读写(写法变了,这条守卫失效)")
}
}