Files
op-packages/luci-app-substore/tests/backend_i18n_test.lua
T

353 lines
15 KiB
Lua
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.
-- backend_i18n_test.lua — 后端返回的用户可见文案必须是「有译文的英文 msgid」
-- 用法:lua5.1 tests/backend_i18n_test.lua
--
-- 背景(LEGACY_ISSUES 7.7):
-- root/usr/share/substore/*.lua 里的失败原因会经两条路到达用户 ——
-- 控制器 back_to_list / back_to_nodes 的 ?err=,以及 meta.error
-- (视图里的 msg.translate)。而**后端模块不能 require("luci.i18n")**:
-- substore-cron.sh 在独立的 lua 进程里跑 core.sync,那里没有 LuCI 环境。
-- 所以后端一律返回语言中立的英文 msgid,翻译只发生在显示边界。
--
-- 两件事必须同时成立,各有一组断言:
-- A 后端源码里不得再有中文字面量 —— 中文写死在后端,英文界面下就会冒出中文,
-- 而且它永远进不了翻译表(这正是 7.7 要修的东西)。
-- B 后端返回/存入/拼接的每个 msgid 都必须在 po/zh_Hans/substore.po 里有条目 ——
-- 漏条目时 translate 回退成 msgid,中文界面下冒出英文。
--
-- B 的「哪些字面量算用户可见」不靠猜:只认三种**语法位置**,它们穷举了后端的
-- 全部出口 ——
-- 1) return 语句里的字面量(`return nil, "…"` / `return false, "…"` / 带 serr or)
-- 2) `error = "…"` 字段(写进 meta.error,视图会显示)
-- 3) msg.compose / msg.join / msg.compose_list 的参数(组合消息的各个片段)
-- 外加一个显式例外:parser.lua 的 FORMAT_LABELS 表值 —— 它会被拼进
-- 「混用多种格式」那条组合消息,所以同样要有译文,但形状不是上面三种。
--
-- 「三种位置上就是文案」这条规则并非在每个模块里都成立(见 NOT_MESSAGES 与
-- SCAN_FILES 的逐条说明),所以扫描面是显式列举的,不是「扫全部后端文件」。
package.path = "./root/usr/share/?.lua;" .. package.path
local util = require("substore.util")
local msg = require("substore.msg")
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 BACKEND_DIR = "root/usr/share/substore/"
local CONTROLLER = "root/usr/lib/lua/luci/controller/admin/substore.lua"
local PO = "po/zh_Hans/substore.po"
-- ---------- CJK 判定(与 view_i18n_test.lua 同一套按字节的判据) ----------
local function has_cjk(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
return true
end
end
return false
end
-- 允许保留 CJK 的字面量。按**字面量内容**匹配,不按行号 ——
-- 行号会随编辑漂移,内容不会。每一条都必须给出理由。
local CJK_OK = {
["[^,%s,]+"] = "node.lua 的关键词拆分正则:字符类里那个全角逗号是**匹配目标**," ..
"翻译它会让「香港,日本」这类全角分隔写法不再被拆开",
}
-- ---------- 源码扫描 ----------
-- 剥掉注释。**不**碰字符串 —— 注释里的中文不算用户可见文本,
-- 而字符串里的中文正是要抓的东西。
local function strip_comments(src)
local out, i, n = {}, 1, #src
while i <= n do
local c = src:sub(i, i)
if c == "-" and src:sub(i, i + 1) == "--" then
-- 长注释 --[[ … ]] / --[==[ … ]==]
local eq = src:match("^%[(=*)%[", i + 2)
if eq then
local close = "]" .. eq .. "]"
local e = src:find(close, i + 4 + #eq, true)
e = e and (e + #close - 1) or n
out[#out + 1] = (src:sub(i, e):gsub("[^\n]", ""))
i = e + 1
else
local e = src:find("\n", i, true) or (n + 1)
out[#out + 1] = (src:sub(i, e - 1):gsub("[^\n]", ""))
i = e
end
elseif c == '"' or c == "'" then
local j, buf = i + 1, { c }
while j <= n do
local d = src:sub(j, j)
if d == "\\" then buf[#buf + 1] = src:sub(j, j + 1); j = j + 2
elseif d == c or d == "\n" then break
else buf[#buf + 1] = d; j = j + 1 end
end
buf[#buf + 1] = c
out[#out + 1] = table.concat(buf)
i = j + 1
else
out[#out + 1] = c
i = i + 1
end
end
return table.concat(out)
end
-- 遍历源码,收集三种语法位置上的双引号字面量。
-- 用「当前语句种类」跟踪:遇到 return / error = / msg.*( 就置位,
-- 到 depth 为 0 的换行处复位(Lua 里没有分号,语句就是按行断的)。
-- 只收「含字母且含空格」的字面量:纯符号串("%s"、":" 之类)不可能是文案,
-- 单字("ok"、"/")也不是 —— 这两条把噪声挡在外面,不是靠猜。
local function scan_msgids(src)
local ids, seen = {}, {}
local i, n, depth, kind = 1, #src, 0, nil
local function at(k) return src:sub(i, i + #k - 1) == k end
local function take(s)
if kind and s:find("%a") and s:find(" ") and not seen[s] then
seen[s] = true
ids[#ids + 1] = s
end
end
while i <= n do
local c = src:sub(i, i)
if c == '"' then
local j, buf = i + 1, {}
while j <= n do
local d = src:sub(j, j)
if d == "\\" then buf[#buf + 1] = src:sub(j, j + 1); j = j + 2
elseif d == '"' or d == "\n" then break
else buf[#buf + 1] = d; j = j + 1 end
end
take(table.concat(buf))
i = j + 1
elseif c == "\n" then
if depth == 0 then kind = nil end
i = i + 1
else
if at("return") and not src:sub(i - 1, i - 1):match("[%w_]") then kind = "return" end
if at("error =") then kind = "error" end
if at("msg.compose(") or at("msg.join(") or at("msg.compose_list(") then kind = "compose" end
if c == "(" or c == "{" or c == "[" then depth = depth + 1
elseif c == ")" or c == "}" or c == "]" then depth = depth - 1 end
i = i + 1
end
end
return ids
end
-- FORMAT_LABELS 表块内的**表值**(见文件头「显式例外」)。
-- 只取 `key = "value"` 里等号右边那一个 —— 左边是格式标识("wireguard-conf"
-- 之类),它只用来查表,不会被拼进任何给用户看的句子。
local function scan_format_labels(src)
local ids = {}
local in_block = false
for line in (src .. "\n"):gmatch("([^\n]*)\n") do
if line:find("local FORMAT_LABELS", 1, true) then in_block = true
elseif in_block then
if line:match("^}") then in_block = false
else
local v = line:match('=%s*"([^"]*)"')
if v then ids[#ids + 1] = v end
end
end
end
return ids
end
local po = util.read_file(PO) or ""
-- 全部后端模块(A 的扫描面:CJK 判定不挑模块)。
local FILES = {}
for _, name in ipairs({ "core", "http", "node", "parser", "output", "output_formats",
"output_clash_meta", "output_egern", "output_wireguard_conf", "probe", "util", "msg" }) do
FILES[#FILES + 1] = BACKEND_DIR .. name .. ".lua"
end
FILES[#FILES + 1] = CONTROLLER
-- B 的扫描面:上面那条「三种语法位置上的字面量就是用户可见文案」成立的模块。
-- util.lua **不在**其中 —— 它同样有这三种位置,但那 23 条命中全是 json_decode
-- 的内部诊断与 shell 片段(调用方一律丢弃,换成自己的 "Failed to parse JSON"),
-- 真正直达用户的只有 human_duration 的返回值,那一条下面**调用**它来断言(B2),
-- 比在源码里认字面量更接近事实。
local SCAN_FILES = {}
for _, name in ipairs({ "core", "http", "node", "parser", "output", "output_formats",
"output_clash_meta", "output_egern", "output_wireguard_conf", "probe", "msg" }) do
SCAN_FILES[#SCAN_FILES + 1] = BACKEND_DIR .. name .. ".lua"
end
-- 同样落在上面三种语法位置、但**不是**给用户看的字面量。逐条列出而不是按模式
-- 匹配 —— 新增一条就得在这里做一次有意识的判断;模式匹配会让新写的文案悄悄
-- 漏过去。下面还有一条断言:表里每一项都必须仍能在源码里找到,防止它腐烂成
-- 「什么都放行」。
local NOT_MESSAGES = {
-- parser.lua:逐行宽容解析的丢弃原因。parse_lines 只取第一个返回值(节点表),
-- M.parse_uri 的调用者同样如此 —— 原因串既不进 meta.error 也不进 ?err=,
-- 用户看到的是 "Unrecognized subscription format" / "Failed to parse JSON"。
-- (注意 "bad wireguard conf: …" 两条**不在**这里:它们由 M.parse 原样上抛,
-- 是用户可见文案,已改写成完整的英文 msgid 并进了翻译表。)
["bad ss"] = true, ["bad ss (no @)"] = true, ["bad ssr"] = true,
["bad vless"] = true, ["bad trojan"] = true, ["bad vmess"] = true,
["bad vmess b64"] = true, ["bad vmess json"] = true,
["bad hysteria"] = true, ["bad hysteria2"] = true, ["bad anytls"] = true,
["bad socks"] = true, ["bad tuic"] = true,
["bad wireguard"] = true, ["bad wireguard json"] = true,
["no scheme"] = true, ["unsupported proto "] = true,
-- http.lua / probe.lua:拼给 shell 的命令行片段(探测 wget/curl、ping 连通性),
-- 原样交给 io.popen,不是给人看的。
["command -v "] = true, [" >/dev/null 2>&1"] = true, [" https_proxy="] = true,
["ping -c 1 -W "] = true, [" 2>/dev/null"] = true,
-- output_formats.lua / output_clash_meta.lua:YAML/INI 输出的模板片段
-- (缩进、分隔符、小节名),会被写进配置文件正文,不是错误提示。
["' .. v .. '"] = true, ["' .. n["] = true, ["[Proxy Group]"] = true,
[" = select"] = true, [", DIRECT"] = true, [", tag="] = true,
["' .. s .. '"] = true, ["proxies: []"] = true,
}
-- ---------- A:后端源码里不得再有中文字面量 ----------
local cjk_bad = {}
for _, file in ipairs(FILES) do
local src = util.read_file(file)
if src then
for lit in strip_comments(src):gmatch('"([^"\n]*)"') do
if has_cjk(lit) and not CJK_OK[lit] then
cjk_bad[#cjk_bad + 1] = file .. ": " .. lit
end
end
end
end
check("no CJK literals left in backend sources", #cjk_bad == 0)
if #cjk_bad > 0 then
for _, b in ipairs(cjk_bad) do print(" CJK literal: " .. b) end
end
-- 例外表本身不能变成「什么都放行」:每条都必须真的还在源码里,
-- 否则它会悄悄掩盖掉一个本该被抓住的中文字面量。
for lit, why in pairs(CJK_OK) do
local found = false
for _, file in ipairs(FILES) do
local src = util.read_file(file)
if src and strip_comments(src):find('"' .. lit .. '"', 1, true) then found = true break end
end
check("CJK exception still present (" .. lit:sub(1, 12) .. "…): " .. why:sub(1, 20) .. "…", found)
end
-- ---------- B:每个后端 msgid 都要有 zh-cn 译文 ----------
local missing, checked = {}, 0
local scanned = {} -- 扫描到的全部字面量(含被排除的),给 NOT_MESSAGES 的存在性检查用
for _, file in ipairs(SCAN_FILES) do
local src = util.read_file(file)
if src then
local clean = strip_comments(src)
local ids = scan_msgids(clean)
if file:find("parser%.lua$") then
for _, v in ipairs(scan_format_labels(clean)) do ids[#ids + 1] = v end
end
for _, id in ipairs(ids) do
scanned[id] = true
if not NOT_MESSAGES[id] then
checked = checked + 1
if po:find('msgid "' .. id .. '"', 1, true) == nil then
missing[#missing + 1] = file .. ": " .. id
end
end
end
end
end
check("every backend 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
-- 排除表不能腐烂:每条都得仍能在源码里找到,否则它只是在无意义地放行。
local stale = {}
for id in pairs(NOT_MESSAGES) do
if not scanned[id] then stale[#stale + 1] = id end
end
check("no stale entry in the NOT_MESSAGES table", #stale == 0)
if #stale > 0 then
for _, s in ipairs(stale) do print(" stale exclusion: " .. s) end
end
-- ---------- B2:util.human_duration 的返回值逐段查表 ----------
-- 它是组合消息(数字 + 单位 msgid),且是 util.lua 唯一直达用户的输出 ——
-- 直接调用它,比在源码里认字面量更接近事实。
-- 判据与扫描器一致:含字母的段必须查得到译文;纯数字段原样显示,不查表。
local hd_cases = {
{ 10 * 86400, " days" }, { 8 * 3600, " hours" }, { 30 * 60, " minutes" },
{ -1, nil }, { 30, nil },
}
for _, case in ipairs(hd_cases) do
local out = util.human_duration(case[1])
local bad = {}
for seg in (out .. msg.SEP):gmatch("(.-)" .. msg.SEP) do
if seg:find("%a") and po:find('msgid "' .. seg .. '"', 1, true) == nil then
bad[#bad + 1] = seg
end
end
local label = ("human_duration(%d) segments are translated"):format(case[1])
check(label, #bad == 0)
if #bad > 0 then
for _, b in ipairs(bad) do print(" no po entry: human_duration segment " .. b) end
end
if case[2] then
check(("human_duration(%d) carries the unit msgid"):format(case[1]),
out:find(msg.SEP .. case[2], 1, true) ~= nil)
end
end
-- ---------- B3:控制器里裸写的 msgid 也要有译文 ----------
-- 控制器能 require("luci.i18n"),绝大多数文案在源头就 _() 掉了(那部分由
-- view_i18n_test 的 B2 覆盖)。但**存进 meta.error 的**那条必须留 msgid ——
-- 它会被持久化,读它的可能是另一个语言的会话,只能在显示时翻译。
-- 形状唯一且明确:`error = "…"`。
local ctl_src = util.read_file(CONTROLLER) or ""
local ctl_missing = {}
for i, line in ipairs((function()
local t = {}
for l in (ctl_src .. "\n"):gmatch("([^\n]*)\n") do t[#t + 1] = l end
return t
end)()) do
if not line:match("^%s*%-%-") then
for id in line:gmatch('error = "([^"]*)"') do
if po:find('msgid "' .. id .. '"', 1, true) == nil then
ctl_missing[#ctl_missing + 1] = i .. " " .. id
end
end
end
end
check("every persisted 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
-- 扫描面不能退化成空集(正则写错时 #missing 也会是 0,那是假绿)。
-- 当前实际命中约 80 条;阈值取 70,留出正常增删的余量。
check("backend msgid scan is not vacuous", checked >= 70)
-- 反向:po 里为后端准备的那批条目不能是死条目 —— 每条都得真的被后端用到。
-- 只查本文件标记出来的那段(后端 msgid 段),模板/控制器的条目另有测试覆盖。
local backend_section = po:match("# 后端模块(root/usr/share/substore/%*%.lua)返回的失败原因(.*)$") or ""
local unused = {}
for id in backend_section:gmatch('msgid "([^"]*)"') do
local used = false
for _, file in ipairs(FILES) do
local src = util.read_file(file)
if src and strip_comments(src):find('"' .. id .. '"', 1, true) then used = true break end
end
if not used then unused[#unused + 1] = id end
end
check("no dead backend msgid in the po", #unused == 0)
if #unused > 0 then
for _, u in ipairs(unused) do print(" dead po entry: " .. u) end
end
print("")
print(("backend_i18n_test: %d passed, %d failed"):format(passed, failed))
os.exit(failed == 0 and 0 or 1)