mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 15:52:52 +08:00
250 lines
9.9 KiB
Lua
250 lines
9.9 KiB
Lua
-- view_i18n_test.lua — 视图模板的界面文本必须走 i18n(英文界面不得出现中文)
|
|
-- 用法:lua5.1 tests/view_i18n_test.lua
|
|
--
|
|
-- 背景:LuCI 的 <%:msgid%> 按当前语言查 .lmo,查不到就回退显示 msgid(英文)。
|
|
-- 而**直接写在模板里的中文字面量根本不进翻译表** —— 英文界面下原样显示中文。
|
|
-- 本仓库只有 zh-cn 一份 .lmo,所以「英文界面」等价于 translate() 恒等(msgid 原样输出)。
|
|
--
|
|
-- 三组断言:
|
|
-- A 真渲染:把模板按 LuCI 的方式重建成 Lua chunk,用恒等 translate 渲染一遍,
|
|
-- 输出里不得出现 CJK。这直接复现「英文界面显示中文」这个缺陷 ——
|
|
-- 中文字面量在输出里,而 <%:…%> 输出的是英文 msgid。
|
|
-- B po 覆盖:模板里每个 msgid 都必须在 po/zh_Hans/substore.po 里有条目,
|
|
-- 否则中文界面会显示英文。
|
|
-- C 字面文本:去掉 <% %> 代码块与 HTML 注释后,剩下的字面 HTML 里不得有 CJK。
|
|
-- A 只覆盖能离线渲染的模板(需要数据 fixture 的 nodes.htm / output.htm 跳过),
|
|
-- C 用静态扫描补上这个缺口。
|
|
--
|
|
-- A 与 C 都只看「用户可见文本」:HTML 注释(<!-- -->)与 JS 行注释(//)会进
|
|
-- 渲染结果,但用户看不见,模板里写中文注释是允许的。
|
|
|
|
package.path = "./root/usr/share/?.lua;" .. package.path
|
|
|
|
local core = require("substore.core")
|
|
local util = require("substore.util")
|
|
|
|
local passed, failed = 0, 0
|
|
local function check(name, cond)
|
|
if cond then passed = passed + 1; print("PASS " .. name)
|
|
else failed = failed + 1; print("FAIL " .. name) end
|
|
end
|
|
|
|
local function lines_of(s)
|
|
local t = {}
|
|
for l in (s .. "\n"):gmatch("([^\n]*)\n") do t[#t + 1] = l end
|
|
return t
|
|
end
|
|
|
|
-- ---------- CJK 判定 ----------
|
|
-- UTF-8 里的 CJK 统一落在 U+4E00..U+9FFF(三字节 E4B8 80 .. E9BF BF)。
|
|
-- 按字节扫:任意一个三字节序列落在该区间即判定为中文。
|
|
local function cjk_snippet(s)
|
|
for i = 1, #s - 2 do
|
|
local b1, b2, b3 = s:byte(i), s:byte(i + 1), s:byte(i + 2)
|
|
if b1 and b2 and b3 and b1 >= 0xE4 and b1 <= 0xE9
|
|
and b2 >= 0x80 and b2 <= 0xBF and b3 >= 0x80 and b3 <= 0xBF then
|
|
local from = math.max(1, i - 40)
|
|
return (s:sub(from, math.min(#s, i + 40)):gsub("%s+", " "))
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- 把一段标记里的「非可见文本」抹掉:HTML 注释、以及整行的 // 注释。
|
|
-- 抹成等量空白而不是删除 —— 保留换行才能让后面的行号对得上源码。
|
|
local function blank_nontext(s)
|
|
s = s:gsub("<!%-%-.-%-%->", function(m) return (m:gsub("[^\n]", "")) end)
|
|
local out = {}
|
|
for _, line in ipairs(lines_of(s)) do
|
|
out[#out + 1] = line:match("^%s*//") and (line:gsub("[^\n]", "")) or line
|
|
end
|
|
return table.concat(out, "\n")
|
|
end
|
|
|
|
local VIEW_DIR = "root/usr/lib/lua/luci/view/substore/"
|
|
local VIEWS = {
|
|
"subscriptions.htm", "nodes.htm", "node_edit.htm", "local_form.htm",
|
|
"form.htm", "combo.htm", "output.htm",
|
|
}
|
|
|
|
-- ---------- 数据目录改到 /tmp,绝不碰真实的 /etc/substore ----------
|
|
-- LIST_FILE / NODES_DIR 是 require 时算好的常量,必须一并改掉。
|
|
local TMPDIR = "/tmp/substore_i18n_" .. tostring(os.time()) .. "_" .. tostring(math.random(1e6))
|
|
os.execute("rm -rf " .. TMPDIR)
|
|
core.DATA_DIR = TMPDIR
|
|
core.LIST_FILE = TMPDIR .. "/subscriptions.json"
|
|
core.NODES_DIR = TMPDIR .. "/nodes"
|
|
core.ensure_dirs()
|
|
|
|
-- 造一条启用了定时更新的订阅:subscriptions.htm 只在「有订阅且 cron_enable 为真」时
|
|
-- 才渲染「定时: …」那一行,没有数据的话 Part A 覆盖不到它。
|
|
local cron_id = core.add("CronSub", "http://example.com/cron.yaml",
|
|
{ cron_enable = "1", cron_time = "0 3 * * *" })
|
|
check("fixture: cron subscription created", type(cron_id) == "string" and cron_id ~= "")
|
|
|
|
-- ---------- 模板 → 可执行 chunk(与 tests/subscriptions_format_gate_test.lua 同构) ----------
|
|
-- <% %> 里的代码原样保留,字面 HTML 与 <%= %> / <%: %> 都变成 __w(...)。
|
|
-- 关键:<%:msgid%> 输出的是 **msgid 本身** —— 这正是英文界面(无 en .lmo)的行为。
|
|
local function build_chunk(src)
|
|
local out = { "local __w = __substore_w" }
|
|
local i, n = 1, #src
|
|
while i <= n do
|
|
local s = src:find("<%", i, true)
|
|
if not s then
|
|
out[#out + 1] = "__w(" .. string.format("%q", src:sub(i)) .. ")"
|
|
break
|
|
end
|
|
if s > i then
|
|
out[#out + 1] = "__w(" .. string.format("%q", src:sub(i, s - 1)) .. ")"
|
|
end
|
|
local e = src:find("%>", s + 2, true)
|
|
if not e then return nil, "unclosed <% at " .. s end
|
|
local tag = src:sub(s + 2, e - 1)
|
|
local kind = tag:sub(1, 1)
|
|
local body = tag:sub(2)
|
|
if kind == "=" then
|
|
out[#out + 1] = "__w(" .. body .. ")"
|
|
elseif kind == ":" then
|
|
out[#out + 1] = "__w(" .. string.format("%q", body) .. ")"
|
|
elseif kind == "+" then
|
|
out[#out + 1] = "__w()" -- header / footer 不参与断言
|
|
else
|
|
out[#out + 1] = tag
|
|
end
|
|
out[#out + 1] = "\n"
|
|
i = e + 2
|
|
end
|
|
return table.concat(out, "\n")
|
|
end
|
|
|
|
-- ---------- LuCI 替身 ----------
|
|
-- translate 恒等 = 英文界面:msgid 原样输出,查不到译文时不额外加中文。
|
|
local luci_stub = {
|
|
util = {
|
|
pcdata = function(s) return tostring(s or "") end,
|
|
urlencode = function(s) return tostring(s or "") end,
|
|
},
|
|
i18n = { translate = function(s) return tostring(s or "") end },
|
|
dispatcher = {
|
|
context = { query = {} },
|
|
build_url = function(...)
|
|
return "/cgi-bin/luci/" .. table.concat({ ... }, "/")
|
|
end,
|
|
},
|
|
}
|
|
local http_stub = {
|
|
formvalue = function() return nil end, -- 无 id ⇒ 各表单走「新增」分支,不需要 fixture
|
|
getenv = function() return nil end,
|
|
}
|
|
local real_require = require
|
|
local function fake_require(name)
|
|
if name == "luci.http" then return http_stub end
|
|
if name == "luci.util" then return luci_stub.util end
|
|
if name == "luci.i18n" then return luci_stub.i18n end
|
|
return real_require(name)
|
|
end
|
|
|
|
local function render(file)
|
|
local f = assert(io.open(VIEW_DIR .. file, "rb"))
|
|
local src = f:read("*a")
|
|
f:close()
|
|
local chunk, cerr = build_chunk(src)
|
|
if not chunk then return nil, cerr end
|
|
local fn, lerr = loadstring(chunk, file)
|
|
if not fn then return nil, lerr end
|
|
local buf = {}
|
|
local env = setmetatable({
|
|
__substore_w = function(s) if s ~= nil then buf[#buf + 1] = tostring(s) end end,
|
|
require = fake_require,
|
|
luci = luci_stub,
|
|
token = "TESTTOKEN", -- LuCI 注入的 CSRF token
|
|
}, { __index = _G })
|
|
setfenv(fn, env)
|
|
local ok, err = pcall(fn)
|
|
if not ok then return nil, err end
|
|
return table.concat(buf)
|
|
end
|
|
|
|
-- ---------- A:渲染后不得出现 CJK ----------
|
|
-- 五个模板都能在「无数据」下渲染:form / combo / local_form / node_edit 走新增或
|
|
-- 「节点不存在」分支,subscriptions 走空列表分支。
|
|
for _, file in ipairs({ "form.htm", "combo.htm", "local_form.htm", "node_edit.htm", "subscriptions.htm" }) do
|
|
local html, err = render(file)
|
|
if not html then
|
|
check(file .. " renders", false)
|
|
print(" " .. tostring(err))
|
|
else
|
|
check(file .. " renders", true)
|
|
local snip = cjk_snippet(blank_nontext(html))
|
|
check(file .. " renders English only", snip == nil)
|
|
if snip then print(" CJK in output: …" .. snip .. "…") end
|
|
end
|
|
end
|
|
|
|
-- ---------- B:模板里的 msgid 必须都有 zh-cn 译文 ----------
|
|
local po = util.read_file("po/zh_Hans/substore.po") or ""
|
|
local missing = {}
|
|
for _, file in ipairs(VIEWS) do
|
|
local src = util.read_file(VIEW_DIR .. file) or ""
|
|
for i, line in ipairs(lines_of(src)) do
|
|
if not line:match("^%s*%-%-") and not line:match("^%s*//") then
|
|
local ids = {}
|
|
for id in line:gmatch("<%%:(.-)%%>") do ids[#ids + 1] = id end
|
|
for id in line:gmatch('luci%.i18n%.translate%("(.-)"%)') do ids[#ids + 1] = id end
|
|
for id in line:gmatch("luci%.i18n%.translate%('(.-)'%)") do ids[#ids + 1] = id end
|
|
for _, id in ipairs(ids) do
|
|
if po:find('msgid "' .. id .. '"', 1, true) == nil then
|
|
missing[#missing + 1] = file .. ":" .. i .. " " .. id
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
check("every view msgid has a zh-cn translation", #missing == 0)
|
|
if #missing > 0 then
|
|
for _, m in ipairs(missing) do print(" no po entry: " .. m) end
|
|
end
|
|
|
|
-- ---------- B2:控制器里 _("…") 的 msgid 同样必须有 zh-cn 译文 ----------
|
|
-- 控制器的失败原因经 ?err= 带回页面显示,是用户可见文本。漏了 po 条目时
|
|
-- translate 回退成 msgid,中文界面下就冒出英文(与模板漏条目是同一个缺陷,
|
|
-- 只是入口不同)。控制器里的 msgid 一律写英文原文,见文件头的说明。
|
|
local ctl_src = util.read_file("root/usr/lib/lua/luci/controller/admin/substore.lua") or ""
|
|
local ctl_missing = {}
|
|
for i, line in ipairs(lines_of(ctl_src)) do
|
|
if not line:match("^%s*%-%-") then
|
|
for id in line:gmatch('_%("(.-)"%)') do
|
|
if po:find('msgid "' .. id .. '"', 1, true) == nil then
|
|
ctl_missing[#ctl_missing + 1] = i .. " " .. id
|
|
end
|
|
end
|
|
end
|
|
end
|
|
check("every controller msgid has a zh-cn translation", #ctl_missing == 0)
|
|
if #ctl_missing > 0 then
|
|
for _, m in ipairs(ctl_missing) do print(" no po entry: controller:" .. m) end
|
|
end
|
|
|
|
-- ---------- C:字面 HTML 文本里不得有 CJK ----------
|
|
-- <% %> 代码块抹成空白(保留换行,行号不漂)。剩下的是模板里写死的界面文本,
|
|
-- 它不会进翻译表 —— 英文界面下原样输出中文。
|
|
local literal_bad = {}
|
|
for _, file in ipairs(VIEWS) do
|
|
local src = util.read_file(VIEW_DIR .. file) or ""
|
|
local text = blank_nontext(src:gsub("<%%.-%%>", function(m) return (m:gsub("[^\n]", "")) end))
|
|
for i, line in ipairs(lines_of(text)) do
|
|
local snip = cjk_snippet(line)
|
|
if snip then literal_bad[#literal_bad + 1] = file .. ":" .. i .. " …" .. snip .. "…" end
|
|
end
|
|
end
|
|
check("no hardcoded CJK in template literal text", #literal_bad == 0)
|
|
if #literal_bad > 0 then
|
|
for _, b in ipairs(literal_bad) do print(" " .. b) end
|
|
end
|
|
|
|
os.execute("rm -rf " .. TMPDIR)
|
|
|
|
print("")
|
|
print(("view_i18n_test: %d passed, %d failed"):format(passed, failed))
|
|
os.exit(failed == 0 and 0 or 1)
|