-- 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)