e3153f57c5
The firmware knew four paths and called four globals, so the card could not change its own layout or put anything around an app. It now loads one file, and the table that file returns owns the rest: start() mounts the route, home and data name the tree, and every callback is a field on it rather than a global the app and its chrome would have to share.
338 lines
14 KiB
C++
338 lines
14 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(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(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(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)");
|
|
|
|
// 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"
|
|
" 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'),\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 on_touch_move
|
|
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(input.getTouch == nil and input.isPressed ~= nil)");
|
|
}
|
|
|
|
// 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;
|
|
}
|