Files
esp32-lua-api/native/test/runtime_test.cpp
T
evan 772618ef89 feat(json): JSON for apps through vendored lua-cjson
lua-cjson decodes straight onto the Lua stack, so a response costs its
text plus the table it becomes rather than a document in between, and it
brings the encode half that a C tokenizer would have left to write here.

It is a module rather than a global: a global namespace is a contract a
firmware implements, and nothing about this needs a provider. Registering
into package.preload also puts it ahead of the SD-card searcher, so an
implementation cannot be shadowed, and an app that never requires it
never pays for the module.

Depth is capped at 32 through the module's own knobs rather than by
patching the vendored source. Decoding recurses on the C stack and
upstream defaults to 1000, which assumes a server rather than a FreeRTOS
task.
2026-08-05 16:41:51 -04:00

370 lines
16 KiB
C++

#include <cassert>
#include <cstdio>
#include <string>
#include <lua/runtime.h>
#include "fake_providers.h"
extern "C" {
#include "lauxlib.h"
#include "lua.h"
}
namespace {
void run(lua_State* state, const char* chunk) {
if (luaL_dostring(state, chunk) != LUA_OK) {
std::fprintf(stderr, "lua error: %s\n", lua_tostring(state, -1));
assert(false);
}
}
void expectError(lua_State* state, const char* chunk) {
assert(luaL_dostring(state, chunk) != LUA_OK);
lua_pop(state, 1);
}
} // namespace
int main() {
fake::Bench bench;
esp32lua::Runtime runtime(bench.providers());
assert(runtime.open());
lua_State* state = runtime.state();
run(state, "log.info('shared runtime')");
assert(bench.log.level == esp32lua::LogLevel::Info);
assert(bench.log.message == "shared runtime");
run(state, "assert(sys.hasFeature('screen'))\n"
"assert(screen.getRotation() == 0)\n"
"assert(screen.setRotation(90))\n"
"assert(screen.getTheme() == 'light')\n"
"assert(screen.setTheme('dark'))\n"
"assert(sys.getTimezone() == 'UTC0')\n"
"assert(sys.setTimezone('EST5EDT'))");
assert(bench.gui.degrees == 90);
assert(bench.gui.themeName == "dark");
assert(bench.sys.tz == "EST5EDT");
expectError(state, "screen.setRotation(45)");
run(state, "assert(sys.getAPIVersion() == 1)\n"
"assert(sys.hasFeature('touch') and sys.hasFeature('buttons'))\n"
"assert(not sys.hasFeature('eink'))\n"
"local free, total, largest = sys.getMemory()\n"
"assert(free == 100 and total == 200 and largest == 50)");
bench.fs.files["/notes.txt"] = "first\nsecond";
run(state, "assert(fs.MAX_READ_BYTES == 65536)\n"
"assert(fs.exists('/notes.txt') and not fs.exists('/missing'))\n"
"assert(fs.fileSize('/notes.txt') == 12)\n"
"local size, err = fs.fileSize('/missing')\n"
"assert(size == nil and err == 'no such file')\n"
"assert(fs.readFile('/notes.txt', 64) == 'first\\nsecond')\n"
"local line, nextOffset = fs.readLineAt('/notes.txt', 0, 64)\n"
"assert(line == 'first' and nextOffset == 6)\n"
"assert(fs.readLineAt('/notes.txt', 12, 64) == nil)\n"
"assert(fs.listFiles('/')[1] == 'main.lua')\n"
"assert(fs.writeFile('/out.bin', 'a\\0b'))");
assert(bench.fs.written.size() == 3);
expectError(state, "fs.readFile('/notes.txt', 999999)");
run(state, "assert(screen.getWidth() == 320 and screen.getHeight() == 240)\n"
"assert(screen.FONT_UI == 2 and screen.STYLE_BOLD == 1)\n"
"assert(screen.color(255, 0, 0) == 0xFF0000)\n"
"assert(screen.setRotation(180))\n"
"screen.clear()\n"
"screen.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n"
"screen.drawText(screen.FONT_UI, 0, 0, 'hi')");
assert(bench.gui.degrees == 180);
assert(bench.gui.trace == "clear;fillPolygon3;drawText(hi);");
expectError(state, "screen.fillPolygon({1, 2}, {3}, 0)");
expectError(state, "screen.color(300, 0, 0)");
run(state, "local response = http.get('https://example.test', {maxBytes = "
"16, headers = {Accept = 'text/plain'}})\n"
"assert(response.status == 404 and response.body == 'missing')\n"
"assert(http.urlencode('a b/c~') == 'a%20b%2Fc~')\n"
"assert(http.download('https://example.test/f', '/f', {maxBytes = "
"32}) == 32)");
assert(bench.http.method == "GET" && bench.http.limit == 16);
assert(bench.http.headers.size() == 1 &&
bench.http.headers[0].name == "Accept");
expectError(state, "http.get('https://example.test', {maxBytes = 999999})");
// cjson is required rather than global, because the library provides it and no
// firmware implements it. Decoding is lua-cjson's; what is asserted here is
// the wiring and the depth limit the panel's C stack needs.
run(state, "local cjson = require 'cjson'\n"
"assert(rawget(_G, 'cjson') == nil)\n"
"local value = cjson.decode('{\"a\":[1,2.5,true],\"b\":\"x\\\\u00e9\"}')\n"
"assert(math.type(value.a[1]) == 'integer' and value.a[2] == 2.5)\n"
"assert(value.a[3] == true and value.b == 'x\\u{e9}')\n"
"assert(cjson.decode('null') == cjson.null)\n"
"assert(cjson.encode({1, 2, 3}) == '[1,2,3]')\n"
"assert(not pcall(cjson.decode, '{'))\n"
"assert(not pcall(cjson.encode, print))\n"
"local deep = {}; for _ = 1, 40 do deep = {deep} end\n"
"assert(not pcall(cjson.encode, deep))\n"
"assert(not pcall(cjson.decode, string.rep('[', 40)))");
run(state, "assert(wifi.scan()[1].ssid == 'home')\n"
"assert(wifi.isConnected())\n"
"assert(wifi.getLocalIP() == '192.168.1.5')\n"
"assert(wifi.getStatus().state == 'connected')\n"
"assert(wifi.connect())");
assert(bench.wifi.savedReconnect);
run(state, "assert(not ble.isInitialized())\n"
"assert(ble.init())\n"
"assert(ble.isInitialized())\n"
"assert(ble.scan()[1].address == 'aa:bb')\n"
"assert(#ble.read('svc', 'chr') == 3)\n"
"assert(ble.write('svc', 'chr', 'x\\0y'))");
assert(bench.ble.duration == 3000);
assert(bench.ble.value.size() == 3);
run(state, "fired = 0\n"
"once = timer.after(10, function() fired = fired + 1 end)\n"
"repeated = timer.every(5, function() fired = fired + 10 end)");
assert(bench.timer.scheduled.size() == 2);
runtime.callTimer(bench.timer.scheduled[0]);
runtime.callTimer(bench.timer.scheduled[0]); // a one-shot never fires twice
runtime.callTimer(bench.timer.scheduled[1]);
runtime.callTimer(bench.timer.scheduled[1]);
run(state, "assert(fired == 21)\n"
"assert(timer.cancel(repeated))\n"
"assert(not timer.cancel(repeated))");
assert(bench.timer.cancelled.size() == 1);
expectError(state, "timer.after(0, function() end)");
run(state,
"assert(touch.setCalibration(100, 200, 300, 400))\n"
"local x, y = touch.getPoint()\n"
"assert(x == 10 and y == 20)\n"
"assert(touch.isTouched())\n"
"assert(buttons.isPressed('confirm') and not buttons.isPressed('back'))\n"
"assert(#buttons.getAll() == 4 and buttons.getAll()[3] == "
"'confirm')");
assert(bench.touch.calibration[3] == 400);
// Gradients are core: an e-ink provider flattens what it cannot show.
run(state, "screen.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n"
"screen.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)");
assert(bench.gui.gradient);
assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos);
bench.gui.trace.clear();
run(state,
"tree.reset()\n"
"local root = tree.create(nil, {type = 'box', w = 'fill', h = 'fill', "
"pad = 4, gap = 2})\n"
"local label = tree.create(root, {type = 'text', label = 'Hello'})\n"
"local button = tree.create(root, {type = 'button', h = 40, interactive "
"= true})\n"
"tree.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, "
"face = 0xEEEEEE,\n"
" border = 0x333333, focusColor = 0xFF0000})\n"
"assert(tree.layout(root, 0, 0, 320, 240))\n"
"local x, y, w, h = tree.getRect(label)\n"
"assert(x == 4 and y == 4 and w == 312 and h == 16)\n"
"assert(tree.getLabel(label) == 'Hello')\n"
"assert(tree.hit(root, 10, 40) == button)\n"
"assert(tree.hit(root, 10, 200) == nil)\n"
"assert(tree.focusFirst(root) == button)\n"
"assert(tree.getFocus() == button)\n"
"assert(tree.moveFocus(root, 'up') == button)\n"
"tree.setPressed(button, true)\n"
"assert(tree.isPressed(button))\n"
"assert(tree.getCount() == 3 and tree.getFootprint() > 0)\n"
"tree.draw(root)\n"
"tree.dropScratch()");
assert(bench.gui.trace.find("drawText(Hello);") != std::string::npos);
// The bordered box rounds its corners; the button fills without one.
assert(bench.gui.trace.find("roundRect(fill,border);") != std::string::npos);
assert(bench.gui.trace.find("roundRect(fill,-);") != std::string::npos);
expectError(state, "tree.create(nil, {type = 'nope'})");
run(state,
"tree.reset()\n"
"local root = tree.create(nil, {type = 'custom', w = 'fill', h = "
"'fill'})\n"
"painted = 0\n"
"tree.setPainter(function(id, x, y, w, h) painted = painted + w end)\n"
"assert(tree.layout(root, 0, 0, 320, 240))\n"
"tree.draw(root)\n"
"assert(painted == 320)");
// A timer firing inside draw is still one batch.
run(state, "function draw() timer.after(1, function() end) end\n"
"nested = timer.after(1, function() draw() end)");
bench.gui.commits = 0;
runtime.callTimer(bench.timer.scheduled.back());
assert(bench.gui.commits == 1);
// Callbacks land on the table main.lua returns, only start() failing stops an
// app, and a release fires the tap alias after the up.
{
fake::Bench chrome;
chrome.fs.files["/.lua/main.lua"] =
"events = {}\n"
"local function note(name) return function(a) events[#events + 1] = "
"name .. ':' .. tostring(a) end end\n"
"return {\n"
" start = function(route, arg) events[#events + 1] = 'start:' .. "
"route .. ':' .. arg end,\n"
" draw = function(delta) if delta < 0 then error('kaboom') end\n"
" events[#events + 1] = 'draw:' .. delta end,\n"
" onTouchDown = note('down'),\n"
" onTouchUp = note('up'),\n"
" onTouch = note('tap'),\n"
" onButtonUp = note('bup'),\n"
" onButton = note('btap'),\n"
"}\n";
esp32lua::Runtime hosted(chrome.providers());
assert(hosted.startApp("Reader", "book.epub"));
chrome.gui.commits = 0;
hosted.callDraw(33);
hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6);
hosted.callTouch(esp32lua::TouchPhase::Move, 5,
7); // main.lua defines no onTouchMove
hosted.callTouch(esp32lua::TouchPhase::Up, 5, 8);
hosted.callButton("confirm", false);
run(hosted.state(),
"assert(table.concat(events, ' ') == "
"'start:Reader:book.epub draw:33 down:5 up:5 tap:5 bup:confirm "
"btap:confirm')");
// One commit per visit, so five calls and not six: the release and its tap
// alias are one visible change, and the move nobody handled still ends a
// batch.
assert(chrome.gui.commits == 5);
hosted.callDraw(-1); // a failed frame logs and the app keeps running
assert(chrome.log.message.find("draw: ") == 0);
assert(hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] =
"return { start = function() error('boom') end }";
assert(!hosted.startApp("Reader"));
assert(chrome.log.message.find("start: ") == 0);
assert(!hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] = "return 7";
assert(!hosted.startApp("Reader"));
assert(chrome.log.message == "main.lua returned no table");
}
// A feature callback without its provider is a wiring bug, not a silent
// no-op.
{
fake::Bench headless;
esp32lua::Providers providers = headless.providers();
providers.touch = nullptr;
esp32lua::Runtime noTouch(providers);
assert(noTouch.open());
noTouch.callTouch(esp32lua::TouchPhase::Down, 1, 1);
assert(headless.log.message == "callTouch without a touch provider");
run(noTouch.state(), "assert(touch == nil and buttons.isPressed ~= nil)");
}
// A screenless firmware still runs: no gui provider, no screen/tree
// namespaces, but a callback batch still completes.
{
fake::Bench screenless;
esp32lua::Providers providers = screenless.providers();
providers.gui = nullptr;
esp32lua::Runtime noScreen(providers);
assert(noScreen.open());
assert(!noScreen.hasFeature("screen"));
run(noScreen.state(), "assert(not sys.hasFeature('screen'))\n"
"assert(screen == nil and tree == nil)\n"
"assert(sys.getTimezone() == 'UTC0')");
noScreen.callDraw(0);
}
// App loading: a fresh state per app, main.lua deciding where apps and
// modules live, and navigation applied between batches rather than inside a
// callback.
{
fake::Bench host;
// The tree is main.lua's, so the test states it the way a card would.
host.fs.files["/.lua/main.lua"] =
"package.path = '/.lua/lib/?.lua'\n"
"return {\n"
" home = 'Home',\n"
" data = '/.lua/data/?',\n"
" start = function(route, arg)\n"
" local dir = '/.lua/apps/' .. route\n"
" package.path = dir .. '/?.lua;/.lua/lib/?.lua'\n"
" app = assert(loadfile(dir .. '/main.lua'))()\n"
" app.init(arg)\n"
" end,\n"
"}\n";
host.fs.files["/.lua/lib/greet.lua"] =
"return {hello = function() return 'hi' end}";
host.fs.files["/.lua/apps/Home/main.lua"] =
"local greet = require('greet')\n"
"return {init = function(arg) started = greet.hello() .. ':' .. "
"tostring(arg) end}";
host.fs.files["/.lua/apps/Reader/main.lua"] =
"local page = require('page')\n"
"return {init = function(arg) started = page.name .. ':' .. arg end}";
host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}";
host.fs.files["/.lua/apps/Reader/Notes/main.lua"] =
"return {init = function() end}";
esp32lua::Runtime app(host.providers());
assert(app.startApp("Home"));
assert(app.appId() == "Home" && app.appTitle() == "Home");
assert(app.appDataPath() == "/.lua/data/Home");
run(app.state(), "assert(started == 'hi:')");
// A subapp shares the app ID, so both routes share one data directory.
run(app.state(), "sys.launch('Reader', 'book.epub')");
assert(app.hasPendingNavigation());
run(app.state(), "assert(started == 'hi:')"); // the current app keeps
// running until applied
assert(app.applyPendingNavigation());
run(app.state(), "assert(started == 'page:book.epub')");
run(app.state(), "sys.launch('Reader/Notes')");
assert(app.applyPendingNavigation());
assert(app.appPath() == "Reader/Notes" && app.appId() == "Reader");
assert(app.appDataPath() == "/.lua/data/Reader");
// Back unwinds history, then lands on the launcher.
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Reader");
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Home");
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Home");
// sys.replace does not grow history, so back from it still reaches the
// launcher.
run(app.state(), "sys.replace('Reader', 'other.epub')");
assert(app.applyPendingNavigation() && app.appPath() == "Reader");
run(app.state(), "sys.back()");
assert(app.applyPendingNavigation() && app.appPath() == "Home");
// A missing app, a broken app, and a traversal all leave nothing running.
assert(!app.startApp("Absent"));
assert(!app.hasApp() && app.state() == nullptr);
host.fs.files["/.lua/apps/Broken/main.lua"] =
"return {init = function() error('nope') end}";
assert(!app.startApp("Broken"));
assert(!app.hasApp());
assert(!app.startApp("../secrets"));
assert(host.log.message.find("refusing to start") == 0);
}
// Missing core providers leave no Lua state behind.
esp32lua::Providers incomplete = bench.providers();
incomplete.fs = nullptr;
esp32lua::Runtime rejected(incomplete);
assert(!rejected.open());
assert(rejected.state() == nullptr);
return 0;
}