Files
esp32-lua-api/native/test/runtime_test.cpp
T
evan 26f3fdb6e6 feat(runtime)!: sys.startApp replaces routing, history and app identity
The runtime kept a back stack, a launcher fallback, an app id and a title
because a teardown destroys the Lua that would otherwise hold them. Only
the first of those is true: everything about where an app came from can
ride in the arguments, and the arguments are the one value that has to
outlive the VM.

So the runtime now does four things -- close the state, load a path, hand
the next state its arguments, defer the swap to a batch boundary -- and
sys.startApp(path, args) is the whole of navigation. Routing, history,
titles and data directories move to the Lua file a firmware boots, where
they can differ per product without a flag on Runtime.

Arguments cross as JSON, encoded while the sending state still holds the
table, so a function or a cycle raises at the call rather than stranding
a launch. start(args) receives the decoded table, or nil at boot, which
is how the entry file knows to open its own launcher.

Removes launch, replace, back, canGoBack, getAppID, getAppTitle,
setAppTitle and getAppDataPath, along with the home and data fields.
LANDSCAPE.md goes with them: it recorded a divergence from firmwares that
have since migrated.
2026-08-05 17:04:35 -04:00

377 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(args) events[#events + 1] = 'start:' .. "
"args.app .. ':' .. args.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(esp32lua::MAIN_PATH,
"{\"app\":\"Reader\",\"arg\":\"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(esp32lua::MAIN_PATH));
assert(chrome.log.message.find("start: ") == 0);
assert(!hosted.hasApp());
// Arguments that are not JSON leave no app running rather than a state with
// no start() behind it.
chrome.fs.files["/.lua/main.lua"] = "return { start = function() end }";
assert(!hosted.startApp(esp32lua::MAIN_PATH, "{not json"));
assert(!hosted.hasApp());
chrome.fs.files["/.lua/main.lua"] = "return 7";
assert(!hosted.startApp(esp32lua::MAIN_PATH));
assert(chrome.log.message == "/.lua/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.
// Routing, history and the launcher are all this file's, built out of the
// arguments it is handed; the runtime knows only the path it loads.
host.fs.files["/.lua/main.lua"] =
"package.path = '/.lua/lib/?.lua'\n"
"return {\n"
" start = function(args)\n"
" args = args or {app = 'Home'}\n"
" route, history = args.app, args.history or {}\n"
" local dir = '/.lua/apps/' .. route\n"
" package.path = dir .. '/?.lua;/.lua/lib/?.lua'\n"
" app = assert(loadfile(dir .. '/main.lua'))()\n"
" app.init(args.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(esp32lua::MAIN_PATH));
assert(app.appPath() == esp32lua::MAIN_PATH);
run(app.state(), "assert(started == 'hi:nil' and route == 'Home')");
// The arguments cross the teardown as JSON, so a nested table survives and
// the history is whatever main.lua chose to put in it.
run(app.state(),
"sys.startApp('/.lua/main.lua', "
"{app = 'Reader', arg = 'book.epub', history = {'Home'}})");
assert(app.hasPendingNavigation());
run(app.state(), "assert(started == 'hi:nil')"); // still running until
// applied
assert(app.applyPendingNavigation());
run(app.state(), "assert(started == 'page:book.epub')\n"
"assert(route == 'Reader' and history[1] == 'Home')");
// Going back is the same call with the stack main.lua kept, popped by
// main.lua: nothing in C++ remembers where the app came from.
run(app.state(), "sys.startApp('/.lua/main.lua', {app = history[1]})");
assert(app.applyPendingNavigation());
run(app.state(), "assert(route == 'Home' and #history == 0)");
// Arguments JSON cannot carry raise at the call, leaving the app running.
expectError(app.state(), "sys.startApp('/.lua/main.lua', {f = print})");
assert(!app.hasPendingNavigation());
run(app.state(), "assert(route == 'Home')");
expectError(app.state(), "sys.startApp('/.lua/main.lua', 'not a table')");
// A missing file, a broken app, and a traversal all leave nothing running.
assert(!app.startApp("/.lua/absent.lua"));
assert(!app.hasApp() && app.state() == nullptr);
host.fs.files["/.lua/apps/Broken/main.lua"] =
"return {init = function() error('nope') end}";
assert(!app.startApp(esp32lua::MAIN_PATH, "{\"app\":\"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;
}