427 lines
19 KiB
C++
427 lines
19 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 scrolling box: children measure past its edges, panning moves them and is
|
|
// clamped to the content, and what is scrolled out of the box is neither hit
|
|
// nor painted outside it.
|
|
bench.gui.trace.clear();
|
|
run(state,
|
|
"tree.reset()\n"
|
|
"local list = tree.create(nil, {type = 'box', w = 'fill', h = 'fill',\n"
|
|
" scrollX = true, scrollY = true})\n"
|
|
"local rows = {}\n"
|
|
"for i = 1, 5 do rows[i] = tree.create(list, {type = 'box', w = 400, h = "
|
|
"100, interactive = true}) end\n"
|
|
"tree.setStyle(list, {background = 0xFFFFFF, fill = 0xFFFFFF, border = "
|
|
"0x333333})\n"
|
|
// The box is the panel; the content is deliberately larger on both axes.
|
|
"assert(tree.layout(list, 0, 0, 320, 240))\n"
|
|
"local maxX, maxY = tree.getScrollRange(list)\n"
|
|
"assert(maxX == 400 - 320, 'content is wider than the box')\n"
|
|
"assert(maxY == 5 * 100 - 240, 'content is taller than the box')\n"
|
|
// Panning shifts the children, and getRect keeps answering screen space.
|
|
"tree.setScroll(list, 30, 150)\n"
|
|
"local x, y = tree.getRect(rows[1])\n"
|
|
"assert(x == -30 and y == -150, 'row moved by the scroll')\n"
|
|
"local sx, sy = tree.getScroll(list)\n"
|
|
"assert(sx == 30 and sy == 150)\n"
|
|
// The box itself does not move, only what it holds.
|
|
"local bx, by = tree.getRect(list)\n"
|
|
"assert(bx == 0 and by == 0)\n"
|
|
// Row 1 is scrolled above the box, so a tap at the top hits row 2.
|
|
"assert(tree.hit(list, 10, 10) == rows[2], 'scrolled-out row is not hit')\n"
|
|
// Clamped, never past the content's far edge or before its start.
|
|
"tree.setScroll(list, 9999, 9999)\n"
|
|
"local cx, cy = tree.getScroll(list)\n"
|
|
"assert(cx == maxX and cy == maxY, 'clamped to the content')\n"
|
|
"tree.setScroll(list, -50, -50)\n"
|
|
"local zx, zy = tree.getScroll(list)\n"
|
|
"assert(zx == 0 and zy == 0, 'clamped at the origin')\n"
|
|
"tree.draw(list)\n"
|
|
"tree.dropScratch()");
|
|
// The subtree is scissored to the box, so a row hanging past it cannot paint
|
|
// over whatever sits outside.
|
|
assert(bench.gui.trace.find("clip(0,0,320,240);") != std::string::npos);
|
|
assert(bench.gui.trace.find("unclip;") != std::string::npos);
|
|
|
|
// A scrolled axis has no size to hand down, so the box needs one of its own.
|
|
expectError(state, "tree.reset()\n"
|
|
"local l = tree.create(nil, {type = 'box', scrollY = "
|
|
"true})\n"
|
|
"tree.create(l, {type = 'box', w = 10, h = 10})\n"
|
|
"assert(tree.layout(l, 0, 0, 320, 240))");
|
|
|
|
// 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;
|
|
}
|