修「填了显示名却说还没有中文名」,并给枚举拼错拦一道

用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」,
保存成功、框里也回填了,页面却仍挂着「还没有中文名」。

两件事叠在一起:

1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 ——
   填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。
   补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。

2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是
   MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后
   那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线:
   编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口
   (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(),
   不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。
   中文值和已知枚举不打扰。

另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串
含裸换行 ⇒ 整块 <script> 语法错误、页面交互全哑,而模板解析测试照样通过。
新增 inline_script_test.go 扫所有内联脚本块(现在 31 个),字符串跨行就报错
并指出文件行号,已做变异验证。
This commit is contained in:
tamakyi
2026-09-20 12:22:11 +08:00
parent 82e63412c0
commit 693ee82ab8
5 changed files with 245 additions and 1 deletions
+150
View File
@@ -0,0 +1,150 @@
package web
import (
"io/fs"
"path/filepath"
"strings"
"testing"
)
// TestInlineScriptHasNoMultilineStrings 守住「模板里的内联 JS 不许出现跨行字符串」。
//
// JS 的单引号字符串不能含裸换行;真出现一个,那一整块 <script> 会语法错误、
// **整段交互静默失效**——页面没有任何提示,控制台之外看不出来,
// 而 TestPageTemplatesParse 照样通过(模板本身是合法的)。
//
// 2026-09-20 就是这么栽的:用脚本改模板时 `\n` 被多转义了一层,写进去成了真换行,
// 当时如果这条测试在,一眼就能看到。
func TestInlineScriptHasNoMultilineStrings(t *testing.T) {
checked := 0
err := fs.WalkDir(tmplFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
return err
}
raw, err := fs.ReadFile(tmplFS, path)
if err != nil {
return err
}
for _, blk := range scriptBlocks(string(raw)) {
checked++
if bad, ok := unterminatedString(blk.text); ok {
t.Errorf("%s:%d 内联 JS 里的字符串跨行了(第 %d 行起的这段):字符串不能含裸换行,"+
"否则整块 <script> 语法错误、页面上所有交互静默失效。\n%s",
path, blk.line+bad.line-1, blk.line+bad.line-1, strings.TrimSpace(bad.text))
}
}
return nil
})
if err != nil {
t.Fatalf("遍历模板失败: %v", err)
}
if checked == 0 {
t.Fatal("一个内联 <script> 块都没扫到:模板目录或写法变了?")
}
t.Logf("扫了 %d 个内联脚本块", checked)
}
type scriptBlock struct {
line int // 块内容在整个文件里的起始行号
text string // <script> 与 </script> 之间的内容
}
// scriptBlocks 抠出每个 <script> 块的内容与它在文件里的起始行号。
func scriptBlocks(s string) []scriptBlock {
var out []scriptBlock
line := 1
for {
i := strings.Index(s, "<script")
if i < 0 {
break
}
line += strings.Count(s[:i], "\n")
j := strings.Index(s[i:], ">")
if j < 0 {
break
}
line += strings.Count(s[i:i+j+1], "\n")
rest := s[i+j+1:]
end := strings.Index(rest, "</script>")
if end < 0 {
break
}
start := line
out = append(out, scriptBlock{line: start, text: rest[:end]})
line += strings.Count(rest[:end], "\n") + strings.Count("</script>", "\n") + 1
s = rest[end:]
}
return out
}
// unterminatedString 找「单/双引号字符串里出现裸换行」的第一处。
// 跳过 // 行注释、/* */ 块注释、反引号模板串,以及 Go 模板动作 {{…}}
// (动作里可能带引号,不该被当成 JS 字符串的边界)。
func unterminatedString(js string) (scriptBlock, bool) {
var (
quote byte // 当前所在字符串的引号,0 表示不在字符串里
tick bool // 反引号模板串
blockC bool // /* */ 里
action int // 处于 {{…}} 里的深度
line = 1
)
for i := 0; i < len(js); i++ {
c := js[i]
if c == '\n' {
line++
if quote != 0 {
from := strings.LastIndex(js[:i], "\n") + 1
return scriptBlock{line: line, text: js[from:i]}, true
}
continue
}
if blockC {
if c == '*' && i+1 < len(js) && js[i+1] == '/' {
blockC = false
i++
}
continue
}
if action > 0 {
if c == '}' && i+1 < len(js) && js[i+1] == '}' {
action = 0
i++
}
continue
}
if tick {
if c == '`' {
tick = false
}
continue
}
if quote != 0 {
if c == '\\' {
i++
} else if c == quote {
quote = 0
}
continue
}
switch {
case c == '{' && i+1 < len(js) && js[i+1] == '{':
action++
i++
case c == '/' && i+1 < len(js) && js[i+1] == '/':
nl := strings.IndexByte(js[i:], '\n')
if nl < 0 {
return scriptBlock{}, false
}
line += strings.Count(js[i:i+nl], "\n")
i += nl
case c == '/' && i+1 < len(js) && js[i+1] == '*':
blockC = true
i++
case c == '`':
tick = true
case c == '\'' || c == '"':
quote = c
}
}
return scriptBlock{}, false
}