在没有 Lua 解释器的机器上离线校验 Lua 代码——把 Lua 塞进 Python 里,用桩件和虚拟时钟 把状态机真跑起来。
场景:改了 UE / UnLua 项目的 Lua,但本机没有 Source/、开不了编辑器、编译不了;
代码又靠 Timer 轮询驱动,超时分支要等几十秒。这套方法能在几毫秒内把逻辑跑完并断言。
这是一个 Agent Skill:
SKILL.md描述完整方法论,examples/里是可直接运行的示例和两个工具脚本。
- 语法编译检查 ——
lupa的lua.compile(src)只编译不执行, 不需要任何依赖就能查出语法错误,10 秒挡住低级错。 - 桩件 + 虚拟时钟 —— 把 Lua 侧所有外部依赖换成桩件,再手动驱动 tick, 真跑状态机并断言行为。
「能编译」和「逻辑对」是两件事,所以两层都要。
1. 不加虚拟时钟,超时分支永远测不到。
deadline = os.time() + 15 在离线自测里永远不会到点——几十次 tick 在几毫秒内跑完,
看起来"用例卡住了",其实只是时间没走。把 os.time 替换成手动递增的变量即可。
2. Timer:AddRepeatTimer(nil, 1, fn) 实际传了 4 个参数。
冒号语法糖会把 Timer 自身算作第一个参数。按 function(_, _, fn) 接,fn 拿到的会是
间隔 1,报错是 attempt to call a number value。用「取最后一个参数 + 类型断言」最稳:
真实签名变了会立刻报错,而不是留到测试跑出莫名结果。
3. 每条用例前必须 reset 定时器。
上一条失败用例留下的 Timer 会继续在后续测试里 tick,导致"莫名多出一次 Finish 调用"
这种假失败,排查很费时间。顺带建议每条用例收尾断言 TimerCount() == 0 ——
专抓"异常分支忘了 StopTimer",这类泄漏正常路径完全看不出来。
4. 桩件必须在 loadfile 被测模块之前塞进 package.loaded。
否则被测模块里的 require 会直接失败。
5. 直接按 encoding='utf-8' 读文件,会把好文件判成语法错。
| 文件形态 | 后果 |
|---|---|
| UTF-8 带 BOM | 编译失败 unexpected symbol near '<\239>'(文件没问题,是读法的问题) |
| GBK / UTF-16 | 脚本直接 UnicodeDecodeError 崩掉,整个门禁挂死 |
带 #! shebang |
编译失败 unexpected symbol near '#'(lua.compile 不跳 shebang 行,只有 loadfile 会跳) |
examples/read_lua.py 是处理好的版本,可直接复用。
6. lupa 是 Lua 5.5,比 UE 项目常用的 5.4 更严格。
5.5 把 for 循环变量变成了只读(const),所以 for k, v in pairs(t) do k = f(k) end
这种 5.4 里完全合法的写法会直接编译失败:
attempt to assign to const variable 'k'
这是假阳性,别直接当语法错报给同事。 反过来也要留神:「编译通过」只证明符合 Lua 5.5, 不证明符合你项目实际用的那个版本。汇报口径写「按 Lua 5.5 语法通过」最稳。
python -m venv .venv
.venv/Scripts/python.exe -m pip install lupa # macOS / Linux 用 .venv/bin/python
python examples/run_selftest.py # → 合计 18 通过 / 0 失败
python examples/read_lua.py <你的 Lua 目录> # 全量语法门禁(退出码可直接接 CI)
python examples/scan_project.py <你的 Lua 目录> # 分档体检,看哪些值得写测试examples/
├── task_runner.lua # 被测模块:轮询异步任务,带超时 / 防重入 / 失败降级
├── _selftest.lua # 18 条断言,5 个用例(正常完成 / 超时 / 接口报错 / 防重入+Abort / 抛异常降级)
├── run_selftest.py # 用 lupa 跑 _selftest.lua,退出码 0/1,可直接接进 CI
├── read_lua.py # 第一层稳健读法(编码 / BOM / shebang)+ 语法门禁 CLI
└── scan_project.py # 全项目扫描分档:语法门禁 + 第二层适配度排行
把 task_runner.lua 换成你自己的模块、_selftest.lua 换成你的桩件与断言即可。
一个项目几十上百个 Lua,别平均用力。scan_project.py 一次给出两件事:
- 全量语法门禁 —— 几百个文件一起过一遍
lua.compile,适合放在提交前兜底 (注意上面第 6 条的 5.5 假阳性) - 第二层适配度排行 —— 按
Timer/os.time/require/ 分支数 / 行数 打分
实测一个真实 UE 项目(数百个 Lua、十几万行):约三分之一属于「第二层基本用不上」 (纯配置表、薄绑定层、只做转发的组件),而分数最高的那批集中在自动化测试套件上。 这个结论本身就有用——它告诉你精力该压在哪三十来个文件上。
| 能证明 | 不能证明 |
|---|---|
| 语法正确 | 真实控件名 / 面板名对不对 |
| 状态机的推进、超时、失败、幂等 | 引擎 API 的真实行为(UnLua 绑定、UMG 布局) |
| 上报 / 日志的调用时序 | 真实时间下的表现(虚拟时钟是加速的) |
| 参数解析、防重入、守卫分支 | 需要真机 / 编辑器的副作用(截图、录屏、网络) |
建议结论口径:「状态机逻辑已离线验证,控件名仍需在引擎里对齐」, 不要说成"已验证通过"。这条纪律比技术本身更重要。
还有一类硬边界:如果模块用 os.clock() 或引擎自己的时间源取时间(不是 os.time()),
虚拟时钟拦不住,超时分支离线永远跑不到——此时退回第一层,或在交付说明里老实写
「超时分支未验证」。
写成可测的样子成本很低,SKILL.md 第七节列了五条,关键在于:
- 依赖通过
require引入,不要直接摸全局(这样能用package.loaded换桩) - 计时统一走
os.time()(虚拟时钟可拦),不要用os.clock() - 周期性逻辑挂在可替换的 Timer 上,不要自己
while true - 每个"外部世界"的读操作都包
pcall,桩件返回 nil 时走日志分支而不是抛错
MIT,见 LICENSE。