#include #include #include #include #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(settings.getRotation() == 0)\n" "assert(settings.setRotation(90))\n" "assert(settings.getTimezone() == 'UTC0')\n" "assert(settings.setTimezone('EST5EDT'))"); assert(bench.settings.degrees == 90); assert(bench.settings.tz == "EST5EDT"); expectError(state, "settings.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(gui.getWidth() == 320 and gui.getHeight() == 240)\n" "assert(gui.FONT_UI == 2 and gui.STYLE_BOLD == 1)\n" "assert(gui.color(255, 0, 0) == 0xFF0000)\n" "gui.setRotation(180)\n" "gui.clear()\n" "gui.fillPolygon({1, 2, 3}, {4, 5, 6}, 0)\n" "gui.drawText(gui.FONT_UI, 0, 0, 'hi')"); assert(bench.gui.degrees == 180); assert(bench.gui.trace == "clear;fillPolygon3;drawText(hi);"); expectError(state, "gui.fillPolygon({1, 2}, {3}, 0)"); expectError(state, "gui.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})"); 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(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(settings.setCalibration(100, 200, 300, 400))\n" "local x, y = input.getTouch()\n" "assert(x == 10 and y == 20)\n" "assert(input.isTouched())\n" "assert(input.isPressed('confirm') and not input.isPressed('back'))\n" "assert(#input.getButtons() == 4 and input.getButtons()[3] == " "'confirm')"); assert(bench.touch.calibration[3] == 400); // Chrome control and gradients are core: an e-ink provider flattens what it // cannot show. run(state, "gui.setFullscreen(true)\n" "gui.roundRect(0, 0, 10, 10, 4, 0, 0xFF, 0x00)\n" "gui.roundRect(0, 0, 10, 10, 4, 0, nil, nil, 0xFF)"); assert(bench.gui.fullscreen && bench.gui.gradient); assert(bench.gui.trace.find("roundRect(-,border);") != std::string::npos); bench.gui.trace.clear(); run(state, "node.reset()\n" "local root = node.create(nil, {type = 'box', w = 'fill', h = 'fill', " "pad = 4, gap = 2})\n" "local label = node.create(root, {type = 'text', label = 'Hello'})\n" "local button = node.create(root, {type = 'button', h = 40, interactive " "= true})\n" "node.setStyle(root, {background = 0xFFFFFF, fill = 0xFFFFFF, color = 0, " "face = 0xEEEEEE,\n" " border = 0x333333, focusColor = 0xFF0000})\n" "assert(node.layout(root, 0, 0, 320, 240))\n" "local x, y, w, h = node.getRect(label)\n" "assert(x == 4 and y == 4 and w == 312 and h == 16)\n" "assert(node.getLabel(label) == 'Hello')\n" "assert(node.hit(root, 10, 40) == button)\n" "assert(node.hit(root, 10, 200) == nil)\n" "assert(node.focusFirst(root) == button)\n" "assert(node.getFocus() == button)\n" "assert(node.moveFocus(root, 'up') == button)\n" "node.setPressed(button, true)\n" "assert(node.isPressed(button))\n" "assert(node.getCount() == 3 and node.getFootprint() > 0)\n" "node.draw(root)\n" "node.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, "node.create(nil, {type = 'nope'})"); run(state, "node.reset()\n" "local root = node.create(nil, {type = 'custom', w = 'fill', h = " "'fill'})\n" "painted = 0\n" "node.setPainter(function(id, x, y, w, h) painted = painted + w end)\n" "assert(node.layout(root, 0, 0, 320, 240))\n" "node.draw(root)\n" "assert(painted == 320)"); // Callbacks: only init failing stops an app, and a release fires the tap // alias after the up. run(state, "events = {}\n" "local function note(name) return function(a) events[#events + 1] = name " ".. ':' .. tostring(a) end end\n" "function init(arg) events[#events + 1] = 'init:' .. tostring(arg) end\n" "function draw(delta) events[#events + 1] = 'draw:' .. delta end\n" "on_touch_down = note('down')\n" "on_touch_up = note('up')\n" "on_touch = note('tap')\n" "on_button_up = note('bup')\n" "on_button = note('btap')"); bench.gui.commits = 0; assert(runtime.callInit("book.epub")); runtime.callDraw(33); runtime.callTouch(esp32lua::TouchPhase::Down, 5, 6); runtime.callTouch(esp32lua::TouchPhase::Move, 5, 7); // the app defines no on_touch_move runtime.callTouch(esp32lua::TouchPhase::Up, 5, 8); runtime.callButton("confirm", false); run(state, "assert(table.concat(events, ' ') == " "'init:book.epub draw:33 down:5 up:5 tap:5 bup:confirm btap:confirm')"); // One commit per visit to the app, so six calls and not seven: the release // and its tap alias are one visible change, and the move nobody handled still // ends a batch. assert(bench.gui.commits == 6); // 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); run(state, "function init() error('boom') end"); assert(!runtime.callInit("")); assert(bench.log.message.find("init: ") == 0); run(state, "function draw() error('kaboom') end"); runtime.callDraw(1); // a failed frame logs and the app keeps running assert(bench.log.message.find("draw: ") == 0); // 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(input.getTouch == nil and input.isPressed ~= nil)"); } // App loading: a fresh state per app, require reaching the app directory and // /.lua/lib, and navigation applied between batches rather than inside a // callback. { fake::Bench host; 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" "function init(arg) started = greet.hello() .. ':' .. tostring(arg) " "end"; host.fs.files["/.lua/apps/Reader/main.lua"] = "local page = require('page')\n" "function init(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"] = "function init() 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"] = "function init() 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; }