Files
rond-with-you/internal/web/frontend_fixes_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

403 lines
17 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)
}
}
}
// ---------- 专题的迷雾要和「显示区」成对:模板传 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 读写(写法变了,这条守卫失效)")
}
}