Files
wadamesh/scripts/lua-harness/harness.lua
T

3221 lines
148 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Mock of the wada.* host (mirrors LuaAppHost.cpp argument checks) + scenarios.
local debug, io, loadfile, print, error, assert, pcall, type, tostring, string, math, table, ipairs, pairs =
debug, io, loadfile, print, error, assert, pcall, type, tostring, string, math, table, ipairs, pairs
local APP_PATH, SCENARIO = APP_PATH, SCENARIO
local BUDGET, INIT_BUDGET = 100000, 500000
local clock_ms = 1000
local toasts, fails, maxinstr = {}, 0, 0
local dm_sent = {} -- send_dm call log: { {to, text}, ... }
local rng_state = 0x2545F491
local function rng32() -- xorshift32 in Lua's 32-bit integers (LUA_32BITS wraps)
local x = rng_state
x = x ~ (x << 13); x = x ~ (x >> 17); x = x ~ (x << 5)
rng_state = x
return x
end
local input_prompt = nil -- the open wada.ui.input prompt: { cb, max_len, ... }
local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not an integer: " .. tostring(v)) end
local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end
local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end
local function valid_utf8(text)
local i, n = 1, #text
while i <= n do
local first = text:byte(i)
local width = first <= 0x7F and 1
or (first >= 0xC2 and first <= 0xDF and 2)
or (first >= 0xE0 and first <= 0xEF and 3)
or (first >= 0xF0 and first <= 0xF4 and 4) or 0
if width == 0 or i + width - 1 > n then return false end
for j = 2, width do
local byte = text:byte(i + j - 1)
if byte < 0x80 or byte > 0xBF then return false end
end
local second = width > 1 and text:byte(i + 1) or 0
if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
(first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then return false end
i = i + width
end
return true
end
-- instruction budget like guardedCall(): error() out of the hook, pcall catches
local function guarded(budget, fn, ...)
local count = 0
debug.sethook(function() count = count + budget; error("instruction budget exceeded (app tick too long)", 0) end, "", budget)
local t0 = os and os.clock() or 0
local ok, err = pcall(fn, ...)
debug.sethook()
if not ok then fails = fails + 1; print(" APP ERROR: " .. tostring(err)) end
return ok
end
-- ---- mock wada ----------------------------------------------------------
local cfg = {} -- per-scenario device config
local widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
local drawlog = { text = {}, circles = {}, ops = 0 }
local storekv = {}
local audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
local audio_log = {}
local function audio_host_close()
audio_state.state = "stopped"
audio_log[#audio_log + 1] = "release"
end
local function mkcanvas(w, h)
checkint(w, "canvas w"); checkint(h, "canvas h")
assert(w > 0 and w <= 480 and h > 0 and h <= 480, "canvas size 1..480")
widgets.canvases = widgets.canvases + 1
local c = { w = w, h = h }
function c:fill(col) checkcol(col, "fill"); drawlog.ops = drawlog.ops + 1 end
function c:rect(x, y, rw, rh, col, filled, radius)
checkint(x, "rect x"); checkint(y, "rect y"); checkint(rw, "rect w"); checkint(rh, "rect h"); checkcol(col, "rect")
drawlog.ops = drawlog.ops + 1 end
function c:line(x1, y1, x2, y2, col, width)
checkint(x1, "line x1"); checkint(y1, "line y1"); checkint(x2, "line x2"); checkint(y2, "line y2"); checkcol(col, "line")
if width ~= nil then checkint(width, "line width") end
drawlog.ops = drawlog.ops + 1 end
function c:circle(x, y, r, col, filled, sw)
checkint(x, "circle x"); checkint(y, "circle y"); checkint(r, "circle r"); checkcol(col, "circle")
if sw ~= nil then checkint(sw, "circle stroke") end
drawlog.circles[#drawlog.circles + 1] = { x = x, y = y, r = r, col = col, filled = filled }
drawlog.ops = drawlog.ops + 1 end
function c:text(x, y, s, col, size)
checkint(x, "text x"); checkint(y, "text y"); checkstr(s, "text"); checkcol(col, "text")
if size ~= nil then checkint(size, "text size") end
drawlog.text[#drawlog.text + 1] = tostring(s); drawlog.ops = drawlog.ops + 1 end
function c:pos(x, y) checkint(x, "canvas pos x"); checkint(y, "canvas pos y"); c.x, c.y = x, y end
return c
end
local labels = {}
local function mklabel(text, x, y, size, col)
checkstr(text, "label text"); if x then checkint(x, "label x") end; if y then checkint(y, "label y") end
if size then checkint(size, "label size") end; checkcol(col, "label")
widgets.labels = widgets.labels + 1
widgets.seq = (widgets.seq or 0) + 1
local l = { text = tostring(text), x = x, y = y, size = size, seq = widgets.seq }
function l:set(s) checkstr(s, "label:set"); l.text = tostring(s) end
function l:pos(px, py) checkint(px, "label:pos"); checkint(py, "label:pos") end
function l:color(c) checkcol(c, "label:color") end
function l:width(w) checkint(w, "label:width"); l.w = w end
labels[#labels + 1] = l
return l
end
local buttons = {}
local function mkbutton(text, x, y, w, h, fn)
checkstr(text, "button text"); checkint(x, "button x"); checkint(y, "button y")
if w then checkint(w, "button w") end; if h then checkint(h, "button h") end
widgets.buttons = widgets.buttons + 1
local b = mklabel(text, x, y, 14, nil); b.fn = fn; buttons[#buttons + 1] = b
return b
end
-- wada.ui.list: rows with optional callbacks, same argument checks as uiList/lsAdd
local lists = {}
local map_obj, map_centers = nil, {}
local function mklist(x, y, w, h)
checkint(x, "list x"); checkint(y, "list y"); checkint(w, "list w"); checkint(h, "list h")
assert(w > 0 and h > 0, "list: width and height must be positive")
widgets.seq = (widgets.seq or 0) + 1
local l = { rows = {}, x = x, y = y, w = w, h = h, seq = widgets.seq }
function l:add(text, fn)
checkstr(text, "list:add text"); assert(fn == nil or type(fn) == "function", "list:add fn")
l.rows[#l.rows + 1] = { text = tostring(text), fn = fn }; return #l.rows
end
function l:set(i, text) checkint(i, "list:set"); checkstr(text, "list:set"); if l.rows[i] then l.rows[i].text = tostring(text) end end
function l:color(i, c) checkint(i, "list:color"); checkcol(c, "list:color") end
function l:clear() l.rows = {} end
function l:count() return #l.rows end
function l:select(i) checkint(i, "list:select") end
function l:selected() return 0 end
function l:pos(px, py) checkint(px, "list:pos"); checkint(py, "list:pos") end
lists[#lists + 1] = l
return l
end
-- ---- mock SD card: mirrors wada.sd in LuaAppHost.cpp -------------------------
-- cfg.sdtree is built by mktree(): a dir is { dir = {name -> node}, order = {names} },
-- a file is { data = "bytes" }. Paging, path rules, error strings and the threat
-- policy (SdThreat.h) all follow the firmware, so an app tested here behaves the
-- same on a card.
local SD_PAGE_MAX = 192
local SD_THREAT_EXT = {
exe = "Windows program", scr = "Windows program", com = "Windows program",
pif = "Windows program", cpl = "Windows program", msi = "Windows program",
msp = "Windows program", dll = "Windows program", jar = "Windows program",
bat = "Windows script", cmd = "Windows script", vbs = "Windows script",
vbe = "Windows script", js = "Windows script", jse = "Windows script",
wsf = "Windows script", wsh = "Windows script", hta = "Windows script",
ps1 = "Windows script", reg = "Windows script", msc = "Windows script",
lnk = "Windows shortcut", url = "Windows shortcut", scf = "Windows shortcut",
}
local function sd_safe(path)
if type(path) ~= "string" or path == "" or path:sub(1, 1) ~= "/" or #path >= 192 then return false end
if #path > 1 and path:sub(-1) == "/" then return false end
if path:find("[\0-\31\127\\]") or path:find("//", 1, true) then return false end
for seg in path:gmatch("[^/]+") do if seg == "." or seg == ".." then return false end end
return true
end
local function sd_node(path)
local node = cfg.sdtree or { dir = {}, order = {} }
for seg in path:gmatch("[^/]+") do
if not node.dir then return nil end
node = node.dir[seg]
if not node then return nil end
end
return node
end
local function sd_classify(path, node)
local name = path:match("[^/]+$")
if name:lower() == "autorun.inf" then return "autorun file" end
local e = name:match("%.([^.]+)$")
if e and SD_THREAT_EXT[e:lower()] then return SD_THREAT_EXT[e:lower()] end
local d = node.data
if #d >= 64 and d:sub(1, 2) == "MZ" then
local off = d:byte(0x3D) | (d:byte(0x3E) << 8) | (d:byte(0x3F) << 16) | (d:byte(0x40) << 24)
if off >= 64 and off <= 65536 and off <= #d - 4 and d:sub(off + 1, off + 4) == "PE\0\0" then
return "renamed Windows program"
end
end
return nil
end
local function sd_file(path)
if not sd_safe(path) or path == "/" then return nil, "bad path" end
if cfg.sd_nocard then return nil, "no sd" end
local node = sd_node(path)
if not node then return nil, "not found" end
if node.dir then return nil, "not a file" end
return node
end
local function sdmock_list(path, start, max)
if path == nil then path = "/" end
checkstr(path, "sd.list path")
start = start or 1; checkint(start, "sd.list start")
assert(start >= 1, "sd.list: start must be 1 or more")
max = max or SD_PAGE_MAX; checkint(max, "sd.list max")
assert(max >= 1 and max <= SD_PAGE_MAX, "sd.list: max must be 1..192")
if not sd_safe(path) then return nil, "bad path" end
if cfg.sd_nocard then return nil, "no sd" end
local node = sd_node(path)
if not node then return nil, "not found" end
if not node.dir then return nil, "not a directory" end
cfg.sd_calls = cfg.sd_calls or {}
cfg.sd_calls[#cfg.sd_calls + 1] = { path = path, start = start, max = max }
local out, count, i = {}, 0, start
while node.order[i] do
if count >= max then out.truncated = true; out.next = start + count; break end
local name = node.order[i]
local child = node.dir[name]
count = count + 1
out[count] = { name = name, type = child.dir and "dir" or "file",
size = child.data and #child.data or 0, mtime = 0 }
i = i + 1
end
return out
end
local function sdmock_check(path)
checkstr(path, "sd.check path")
local node, err = sd_file(path)
if not node then return nil, err end
return sd_classify(path, node) or false
end
local function sdmock_remove(path)
checkstr(path, "sd.remove path")
local node, err = sd_file(path)
if not node then return nil, err end
local why = sd_classify(path, node)
if not why then return nil, "not a threat" end
if cfg.sd_remove_fail and cfg.sd_remove_fail[path] then return nil, cfg.sd_remove_fail[path] end
local parent = sd_node(path:match("^(.*)/[^/]+$") ~= "" and path:match("^(.*)/[^/]+$") or "/")
local name = path:match("[^/]+$")
parent.dir[name] = nil
for i, n in ipairs(parent.order) do if n == name then table.remove(parent.order, i); break end end
cfg.sd_removed = cfg.sd_removed or {}
cfg.sd_removed[#cfg.sd_removed + 1] = path
return why
end
-- Build a card from { ["/path/file"] = "bytes" | true (an empty folder) }.
local function mktree(files)
local root = { dir = {}, order = {} }
local paths = {}
for path in pairs(files) do paths[#paths + 1] = path end
table.sort(paths)
for _, path in ipairs(paths) do
local node, segs = root, {}
for seg in path:gmatch("[^/]+") do segs[#segs + 1] = seg end
for i, seg in ipairs(segs) do
if not node.dir[seg] then
local is_dir = i < #segs or files[path] == true
node.dir[seg] = is_dir and { dir = {}, order = {} } or { data = files[path] }
node.order[#node.order + 1] = seg
end
node = node.dir[seg]
end
end
return root
end
local function sd_exists(path) return sd_node(path) ~= nil end
local function build_wada()
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} }
wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B }
wada.ui.canvas = mkcanvas
wada.ui.label = mklabel
wada.ui.button = mkbutton
wada.ui.scroll = function(on) widgets.scroll = (on == nil) or on end
wada.ui.text_h = function(sz) return (sz or 12) + 4 end
-- upstream's exact measurement: ~0.48 of the line height per character
wada.ui.text_w = function(str, sz)
local n = 0
for _ in str:gmatch('[%z\1-\127\194-\244]') do n = n + 1 end
return math.floor(n * ((sz or 12) + 4) * 0.48)
end
wada.ui.chart = function() error("chart not mocked") end
wada.ui.list = mklist
wada.ui.clear = function() labels, buttons, lists = {}, {}, {}; widgets.cleared = (widgets.cleared or 0) + 1 end
wada.ui.text_lines = function(str, width, sz)
checkstr(str, "text_lines"); checkint(width, "text_lines width")
if width <= 0 then return 1 end
local lines = 0
for part in (tostring(str) .. "\n"):gmatch("(.-)\n") do
local w = wada.ui.text_w(part, sz or 14)
lines = lines + math.max(1, (w + width - 1) // width)
end
return lines
end
wada.sys.millis = function() return clock_ms end
wada.sys.keep_awake = function(on) cfg.awake = (on == nil) or on end
wada.sys.toast = function(msg, ms) checkstr(msg, "toast"); toasts[#toasts + 1] = tostring(msg) end
wada.sys.board = function() return { w = cfg.w, h = cfg.h, touch = cfg.caps.touch } end
-- sysRandom (882a2046 + LuaRandomRange.h): random() a raw 32-bit value, random(n)
-- 1..n, random(lo, hi) lo..hi in either order, the full 32-bit range included.
-- Deterministic per scenario (xorshift32), as the device's esp_random is not.
wada.sys.random = function(lo, hi)
if lo == nil then return rng32() end
checkint(lo, "random lo"); lo = math.tointeger(lo)
if hi == nil then hi = lo; lo = 1 else checkint(hi, "random hi"); hi = math.tointeger(hi) end
if hi < lo then lo, hi = hi, lo end
local span = hi - lo + 1 -- wraps like the uint32 span: 0 = all 2^32
local r = rng32() & 0x7FFFFFFF
if span <= 0 then return lo + r end -- span >= 2^31: any r below it is in range
return lo + r % span
end
-- cfg.epoch is UTC seconds; cfg.datetime is the local civil table the
-- firmware would derive from it. Both nil = no clock, the same state as the
-- device before a GPS/NTP/mesh time sync.
wada.sys.epoch = function() return cfg.epoch end
wada.sys.datetime = function() return cfg.datetime end
wada.sys.beep = function() return false end
-- wada.ui.input(title, initial, cb [, max_len]) as uiInput checks it: optional
-- strings, a function, max_len an integer clamped to 1..2000 (63 when left out),
-- one prompt at a time. The scenario answers with answer_input(text or nil).
wada.ui.input = function(title, initial, cb, max_len)
if title ~= nil then checkstr(title, "input title") end
if initial ~= nil then checkstr(initial, "input initial") end
assert(type(cb) == "function", "input: cb must be a function")
if max_len == nil then max_len = 63 else checkint(max_len, "input max_len"); max_len = math.tointeger(max_len) end
if max_len < 1 then max_len = 1 end
if max_len > 2000 then max_len = 2000 end
if input_prompt then error("an input prompt is already open") end
input_prompt = { cb = cb, max_len = max_len, title = title, initial = initial }
end
wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard,
input = true, input_len = true,
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
accel = cfg.caps.accel, discover = cfg.caps.discover,
panel = cfg.caps.panel or false,
map = cfg.caps.sdk_ext or false,
sd_list = cfg.caps.sd_list or false,
sd_clean = cfg.caps.sd_clean or false,
audio = cfg.caps.audio or false,
audio_wav = cfg.caps.audio or false,
audio_mp3 = cfg.caps.audio or false,
audio_sd = cfg.caps.audio_sd or false } end
if cfg.caps.sdk_ext then
wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end
wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end
end
if cfg.caps.compass then
wada.sys.compass = function() return cfg.compass and cfg.compass() or nil end
end
if cfg.caps.accel then
wada.sys.accel = function() return cfg.accel and cfg.accel() or { x = 0, y = 0, z = 1 } end
end
wada.mesh.contacts = function() return cfg.contacts or {} end
wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end
wada.mesh.stats = function() return {} end
wada.mesh.rx_log = function() return {} end
wada.mesh.discover = function() return "probe-1" end
wada.mesh.discovered = function() return cfg.discovered or {} end
wada.mesh.discover_clear = function() cfg.discovered = {} end
wada.mesh.send_dm = function(name, text)
checkstr(name, "send_dm name"); checkstr(text, "send_dm text")
dm_sent[#dm_sent + 1] = { to = name, text = text }
return true
end
do
local R = 6371000.0
local function hav(a) local s = math.sin(a / 2); return s * s end
local function geo_dist(la1, lo1, la2, lo2)
local p1, p2 = math.rad(la1), math.rad(la2)
local dp, dl = math.rad(la2 - la1), math.rad(lo2 - lo1)
local a = hav(dp) + math.cos(p1) * math.cos(p2) * hav(dl)
return R * 2 * math.atan(math.sqrt(a), math.sqrt(1 - a))
end
local function geo_bear(la1, lo1, la2, lo2)
local p1, p2 = math.rad(la1), math.rad(la2)
local dl = math.rad(lo2 - lo1)
local y = math.sin(dl) * math.cos(p2)
local x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return (math.deg(math.atan(y, x)) + 360) % 360
end
local CARDS = {"N","NE","E","SE","S","SW","W","NW"}
wada.geo = {
distance = geo_dist,
bearing = geo_bear,
cardinal = function(d) return CARDS[math.floor((d + 22.5) / 45) % 8 + 1] end,
}
end
-- wada.fs: a real in-memory filesystem, not a set of no-ops. append used to
-- throw the data away and read/write/list were missing outright, so any app
-- that wrote a file and read it back failed here while working perfectly on
-- device. That is what made wardrive 1.2 look like it had regressed the
-- emoji fix: it errored on a nil fs.read at the first flush, long before
-- anything was rendered, and the scenario reported the unrendered label.
-- Semantics follow LuaAppHost.cpp: read returns data plus the file's TOTAL
-- size so a caller can window a growing log, an offset at or past the end is
-- an empty string rather than nil, and a missing file is nil.
fsfiles = {}
wada.fs.write = function(name, data)
checkstr(name, "fs.write name"); checkstr(data, "fs.write data")
fsfiles[name] = data; return true
end
wada.fs.append = function(name, data)
checkstr(name, "fs.append name"); checkstr(data, "fs.append data")
fsfiles[name] = (fsfiles[name] or "") .. data; return true
end
wada.fs.read = function(name, off, want)
checkstr(name, "fs.read name")
-- fsSafeName: a name outside [A-Za-z0-9._-], over 32 characters or with a
-- leading dot is unreadable (nil) even if it is in the folder.
if not tostring(name):match("^[%w%._%-]+$") or #name > 32 or name:sub(1, 1) == "." then return nil end
local d = fsfiles[name]
if d == nil then return nil end
off = off or 0
if off < 0 then return nil end
local total = #d
if off >= total then return "", total end
want = want or (total - off)
if want < 0 then return nil end
if want > 32768 then want = 32768 end -- kFsMaxFile: one read never returns more
return d:sub(off + 1, off + want), total
end
wada.fs.list = function()
local out, names = {}, {}
for n in pairs(fsfiles) do names[#names + 1] = n end
table.sort(names) -- deterministic: a hash order would make tests flaky
for _, n in ipairs(names) do out[#out + 1] = { name = n, size = #fsfiles[n] } end
return out
end
wada.fs.remove = function(name)
checkstr(name, "fs.remove name")
local had = fsfiles[name] ~= nil
fsfiles[name] = nil
return had
end
wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end
wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string")
assert(v == nil or type(v) == "string" or type(v) == "number", "store values must be strings or numbers")
storekv[k] = v end
wada.timer.every = function(ms) checkint(ms, "timer.every"); if ms < 33 then ms = 33 end; widgets.timer_ms = ms end
wada.timer.stop = function() widgets.timer_ms = nil end
wada.timer.after = function(ms, fn) checkint(ms, "timer.after ms"); assert(type(fn) == "function", "timer.after fn") end
if cfg.caps.audio then
wada.audio = {}
wada.audio.play = function(path)
assert(type(path) == "string", "audio.play path must be a string")
local source = "app"
if path:sub(1, 3) == "sd:" then
if not cfg.caps.audio_sd then return nil, "no sd" end
local card_path = path:sub(4)
if card_path:sub(1, 1) ~= "/" or card_path:find("//", 1, true) or
card_path:find("/../", 1, true) or card_path:sub(-3) == "/.." or
card_path:sub(-2) == "/." or card_path:sub(-1) == "/" then
return nil, "bad path"
end
source = "sd"
elseif #path == 0 or #path > 32 or path:sub(1, 1) == "." or
not path:match("^[A-Za-z0-9._-]+$") then
return nil, "bad path"
end
local format = path:lower():sub(-4)
if format ~= ".wav" and format ~= ".mp3" then return nil, "unsupported format" end
audio_state = { state = "playing", path = path, source = source,
format = format:sub(2), error = nil }
audio_log[#audio_log + 1] = "play:" .. path
return true
end
wada.audio.pause = function()
if audio_state.state ~= "playing" then return false end
audio_state.state = "paused"; audio_log[#audio_log + 1] = "pause"; return true
end
wada.audio.resume = function()
if audio_state.state ~= "paused" then return false end
audio_state.state = "playing"; audio_log[#audio_log + 1] = "resume"; return true
end
wada.audio.stop = function()
if audio_state.state ~= "playing" and audio_state.state ~= "paused" then return false end
audio_state.state = "stopped"; audio_log[#audio_log + 1] = "stop"; return true
end
wada.audio.status = function()
local out = {}
for key, value in pairs(audio_state) do out[key] = value end
return out
end
end
if cfg.caps.sd_list then
wada.sd = { list = sdmock_list }
if cfg.caps.sd_clean then wada.sd.check = sdmock_check; wada.sd.remove = sdmock_remove end
end
if cfg.caps.sdk_ext then
local function mkmap(mx, my, mw, mh)
if map_obj then error("only one map view at a time") end
local m = { x = mx, y = my, w = mw, h = mh, _zoom = 10, _lat = 0, _lon = 0 }
function m:center(lat, lon, z)
self._lat = lat; self._lon = lon
if z then self._zoom = z end
map_centers[#map_centers + 1] = { lat = lat, lon = lon, z = self._zoom }
end
function m:zoom(z) if z then self._zoom = z else return self._zoom end end
function m:marker(lat, lon, col, sz) end
function m:line(la1, lo1, la2, lo2, col, w) end
function m:clear() end
function m:tiles() return cfg.map_tiles or 0 end
function m:redraw() end
function m:to_screen(lat, lon) return math.floor(mw / 2), math.floor(mh / 2) end
function m:to_latlon(x, y) return self._lat + (mh/2-y)*0.001, self._lon + (x-mw/2)*0.001 end
function m:close() map_obj = nil end
map_obj = m
return m
end
wada.map = { view = mkmap }
end
return wada
end
-- ---- scenario driver -------------------------------------------------------------
local function load_app()
local chunk, err = loadfile(APP_PATH)
assert(chunk, err)
local app
assert(guarded(INIT_BUDGET, function() app = chunk() end), "chunk failed")
assert(type(app) == "table", "app did not return a table")
return app
end
local function send(app, ev) if app.on_input then guarded(BUDGET, app.on_input, ev) end end
local function tick(app, n, dt)
for _ = 1, (n or 1) do clock_ms = clock_ms + (dt or 150); if app.on_tick then guarded(BUDGET, app.on_tick, dt or 150) end end
end
local function key(app, k, code) send(app, { type = "key", key = k, code = code or (k and k:byte()) or 0 }) end
local function swipe(app, dir) send(app, { type = "swipe", dir = dir, x = 0, y = 0 }) end
local function tap(app, x, y) send(app, { type = "down", x = x, y = y }); send(app, { type = "up", x = x, y = y }) end
local function drag(app, x, y, x2, y2, dir) send(app, { type = "down", x = x, y = y }); send(app, { type = "swipe", dir = dir, x = 0, y = 0 }); send(app, { type = "up", x = x2, y = y2 }) end
local function enter(app, w, h) tap(app, w // 2, h // 2); key(app, "enter", 13) end
-- The user answers the open wada.ui.input prompt: OK with `text` (the field keeps at
-- most max_len characters, as lv_textarea_set_max_length does), or cancel with nil.
local function answer_input(text)
local p = assert(input_prompt, "no input prompt is open")
input_prompt = nil
if text ~= nil then
local n, cut = 0, #text
for pos = 1, #text do
local b = text:byte(pos)
if b < 0x80 or b >= 0xC0 then -- a character starts here
n = n + 1
if n > p.max_len then cut = pos - 1; break end
end
end
text = text:sub(1, cut)
end
guarded(BUDGET, p.cb, text)
return p
end
local function label_dump()
local out = {}
for _, l in ipairs(labels) do if l.text ~= "" then out[#out + 1] = l.text end end
return table.concat(out, " | ")
end
local function reset_world()
widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 }
lists = {}
map_obj, map_centers = nil, {}
clock_ms = 1000
dm_sent = {}
audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
dm_sent = {}
audio_log = {}
rng_state = 0x2545F491
input_prompt = nil
end
-- simulated magnetometer: Earth field 0.45 G at true heading `deg` (device frame ==
-- sensor frame, orient 0), plus a hard-iron bias; tilt ignored.
local BIAS = { x = 1.5, y = -0.8, z = 0.3 }
-- the real M9's accelerometer reads ~0.08 g high on Y lying flat: about 5
-- degrees of tilt that is not there
local ABIAS = { x = -0.02, y = 0.08, z = -0.01 }
-- Physical simulation of the M9 in a known attitude, in the MEASURED frames:
-- body/accel: +X top edge (forward), +Y right edge, +Z into the screen (down)
-- magnetometer: +Y forward, +X left, +Z down -> raw = (-by, bx, bz) + bias
-- Field: San Francisco-like, 0.24 G horizontal north, 0.42 G down (dip ~60).
-- The accelerometer reads specific force, so the skyward axis reads +1 and the
-- level device reads z = -1, exactly as the real one was measured to.
local BH, BV = 0.24, 0.42
local function attitude(hdg, roll, pitch)
local h, r, p = math.rad(hdg), math.rad(roll or 0), math.rad(pitch or 0)
-- earth (north, east, down) -> body, ZYX yaw-pitch-roll
local function e2b(n, e, d)
local x1 = n * math.cos(h) + e * math.sin(h) -- yaw
local y1 = -n * math.sin(h) + e * math.cos(h)
local z1 = d
local x2 = x1 * math.cos(p) - z1 * math.sin(p) -- pitch
local y2 = y1
local z2 = x1 * math.sin(p) + z1 * math.cos(p)
local x3 = x2 -- roll
local y3 = y2 * math.cos(r) + z2 * math.sin(r)
local z3 = -y2 * math.sin(r) + z2 * math.cos(r)
return x3, y3, z3
end
local bx, by, bz = e2b(BH, 0, BV) -- magnetic field in body axes
local gx, gy, gz = e2b(0, 0, 1) -- "down" in body axes
return { x = -by + BIAS.x, y = bx + BIAS.y, z = bz + BIAS.z }, -- magnetometer raw
{ x = -gx + ABIAS.x, y = -gy + ABIAS.y, z = -gz + ABIAS.z } -- accelerometer raw
end
local contacts_fixture = {
{ name = "Repeater A", type = 2, ago_s = 10, lat = 37.80000, lon = -122.40000 },
{ name = "Bob", type = 1, ago_s = 300, lat = 37.70000, lon = -122.50000 },
{ name = "NoPos", type = 1, ago_s = 5, lat = 0, lon = 0 },
{ name = "Carol", type = 1, ago_s = 60, lat = 38.00000, lon = -122.00000 },
}
local scenarios = {}
scenarios.wardrive_utf8 = function()
cfg = {
w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true },
discovered = {
{ pubkey = "01020304", name = "Ouderkerk☀️", type = 2,
rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 }
}
}
cfg.gps = function()
return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000,
sats = 9, alt_m = 3, time = 1787620000 }
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing")
assert(guarded(BUDGET, buttons[1].fn))
tick(app, 1, 100)
tick(app, 1, 4000)
local found = false
for _, label in ipairs(labels) do
assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8")
if label.text:find("Ouderkerk☀", 1, true) then found = true end
end
assert(found, "emoji-bearing repeater name was not rendered")
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- Start/Stop names the next press, relabelled in place: only safe with the
-- button:set fix (caps().panel, beta_95). The old-firmware case is below.
scenarios.wardrive_start_stop = function()
cfg = {
w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false,
discover = true, panel = true },
}
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local b = assert(buttons[1], "Wardrive start/stop button missing")
assert(b.text == "Start", "a stopped survey should offer Start, got '" .. b.text .. "'")
assert(guarded(BUDGET, b.fn))
assert(b.text == "Stop", "a running survey should offer Stop, got '" .. b.text .. "'")
assert(guarded(BUDGET, b.fn))
assert(b.text == "Start", "a stopped survey should offer Start again, got '" .. b.text .. "'")
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- Without caps().panel, relabelling would corrupt memory, so the label stays.
scenarios.wardrive_start_stop_old_firmware = function()
cfg = {
w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false,
discover = true, panel = false },
}
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local b = assert(buttons[1], "Wardrive start/stop button missing")
assert(guarded(BUDGET, b.fn))
assert(b.text == "Start",
"firmware without the relabel fix must keep the fixed label, got '" .. b.text .. "'")
assert(toasts[#toasts] == "Survey running", "the toast should still report the state")
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- The declination model, as it actually ships. Rather than testing a copy in
-- out/wmm, this pulls the do-block straight out of the app file that gets
-- sideloaded, so an inlining mistake fails here instead of on the device.
-- Reference values are NOAA's own calculator for WMM2025 at epoch 2026.6.
scenarios.declination = function()
local src = assert(io.open(APP_PATH)):read("a")
local a = src:find("-- WMM-GEN BEGIN", 1, true)
assert(a, "the declination module is missing from the app")
local e = src:find("end -- WMM-GEN END", a, true)
assert(e, "the generated block is not closed by its END marker")
local chunk = src:sub(a, e + #"end -- WMM-GEN END") .. "\nreturn declination"
local declination = assert(load(chunk, "decl"))()
local REF = {
{ 37.75, -122.45, 12.8470, 22928.0, "harness fixture SF" },
{ 43.66, -70.26, -14.4653, 20159.9, "Portland ME" },
{ -33.87, 151.21, 12.8236, 24625.1, "Sydney" },
{ 64.13, -21.90, -11.0771, 13242.4, "Reykjavik" },
{ -70.00, 100.00, -103.9366, 12624.7, "Antarctic (steep)" },
{ 82.00, -100.00, -34.1519, 1905.5, "82N: weak-field zone" },
}
local worst = 0
for _, r in ipairs(REF) do
local lat, lon, want_d, want_h, name = r[1], r[2], r[3], r[4], r[5]
local got_d, got_h = declination(lat, lon, 2026.6)
local err = math.abs(((got_d - want_d + 540) % 360) - 180)
if err > worst then worst = err end
print(string.format(" %-22s %9.4f vs %9.4f (%.4f) H=%.0f nT", name, got_d, want_d, err, got_h))
assert(err < 0.02, "declination wrong at " .. name)
assert(math.abs(got_h - want_h) < 30, "horizontal field wrong at " .. name)
end
print(string.format(" worst error vs NOAA: %.4f deg", worst))
-- the secular variation has to be live, or this is a frozen 2025 snapshot
local d25 = declination(43.66, -70.26, 2025.0)
local d30 = declination(43.66, -70.26, 2030.0)
assert(math.abs(d30 - d25) > 0.2,
string.format("secular variation is not applied (%.3f -> %.3f)", d25, d30))
print(string.format(" Portland ME drifts %+.2f deg across the model's 5 years", d30 - d25))
end
-- Pressing A means "this direction is TRUE north". With no fix the app has no
-- declination to work with, so the offset it stores quietly contains one; if
-- the model then added its own the heading would be wrong by twice the
-- declination -- the very error that sent every marker 22 deg west, but on the
-- dial instead. The offset has to give the declination back when the fix lands.
scenarios.align_nofix = function()
local FIX_DECL = 12.847
-- no self position either: on a real node the stored one is usually enough to
-- get a declination before GPS locks, so being truly blind takes both gone
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
contacts = contacts_fixture }
local true_heading, true_roll, true_pitch = 0, 0, 0
cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end
cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end
local have_fix = false
cfg.gps = function()
if not have_fix then return nil end
return { lat = 37.75, lon = -122.45, sats = 9, alt_m = 42 }
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
key(app, "c") -- calibrate with no fix at all
for i = 1, 140 do
true_heading = (i * 360 / 23) % 360
true_roll = (i * 360 / 11) % 360
true_pitch = ((i * 360 / 17) % 360) - 180
tick(app, 1, 150)
end
true_roll, true_pitch = 0, 0
assert(storekv.cal_ox, "calibration must still work with no GPS")
local function shown()
tick(app, 15)
return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$"))
end
-- the user believes they are facing true north and says so
true_heading = 30 -- i.e. magnetic 30; with no model the app cannot know
tick(app, 15) -- settle: A reads the live tilt, not the sweep's last
key(app, "a"); tick(app, 4)
assert(storekv.align_pd, "an offset set with no declination must be remembered as such")
assert(toasts[#toasts]:find("magnetic"), "the toast must admit it: " .. tostring(toasts[#toasts]))
assert(math.abs(((shown() - 0 + 540) % 360) - 180) <= 2, "must read 000 where north was set")
assert(drawlog.text[#drawlog.text - 1] == "M", "with no model the dial is magnetic")
-- the fix arrives: the model now knows the declination the offset swallowed
have_fix = true
tick(app, 20)
assert(not storekv.align_pd, "the pending flag must clear once the model has a value")
assert(math.abs(((shown() - 0 + 540) % 360) - 180) <= 2,
string.format("the fix must not move a direction the user already fixed (%d)", shown()))
assert(drawlog.text[#drawlog.text - 1] == "T",
"the dial must repaint as TRUE the moment the model has a declination")
print(string.format(" align %d, decl %+.1f: %d before the fix, %d after",
storekv.align, FIX_DECL, 0, shown()))
-- and a quarter turn still counts a quarter turn
true_heading = 30 + 90
assert(math.abs(((shown() - 90 + 540) % 360) - 180) <= 3, "a right turn must count UP")
app.on_close()
end
-- Is a contact MIRRORED rather than merely rotated? A flip and a rotation look
-- identical at one heading, so the existing marker test cannot tell them apart:
-- it checks the dot against the app's OWN bearing, which stays self-consistent
-- even if that bearing has east and west swapped. This checks the bearing
-- against an absolute compass direction instead -- contacts placed due north,
-- east, south, west and north-east of the fixture -- and then checks the dot
-- against it. A longitude sign error puts east at 270; a lat/lon transpose
-- puts north-east at 51.7 instead of 38.3.
scenarios.bearings_absolute = function()
local ME_LAT, ME_LON = 37.75, -122.45
local POINTS = {
{ name = "N", lat = 37.85, lon = -122.45, brg = 0.000 },
{ name = "E", lat = 37.75, lon = -122.30, brg = 89.954 },
{ name = "S", lat = 37.65, lon = -122.45, brg = 180.000 },
{ name = "W", lat = 37.75, lon = -122.60, brg = 270.046 },
{ name = "NE", lat = 37.85, lon = -122.35, brg = 38.284 },
}
local marks = {}
for i, pt in ipairs(POINTS) do
marks[i] = { name = pt.name, type = 1, ago_s = 10, lat = pt.lat, lon = pt.lon }
end
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
contacts = marks, self_lat = ME_LAT, self_lon = ME_LON }
local true_heading, true_roll, true_pitch = 0, 0, 0
cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end
cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end
cfg.gps = function() return { lat = ME_LAT, lon = ME_LON, sats = 9, alt_m = 42 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
key(app, "c") -- calibrate so the dial is live
for i = 1, 140 do
true_heading = (i * 360 / 23) % 360
true_roll = (i * 360 / 11) % 360
true_pitch = ((i * 360 / 17) % 360) - 180
tick(app, 1, 150)
end
true_roll, true_pitch = 0, 0
true_heading = 0
tick(app, 20)
local D2 = 144 / 2
local by_name = {}
for _, pt in ipairs(POINTS) do by_name[pt.name] = pt end
local seen = 0
-- the app picks its own target order (nearest first), so go by the name it
-- reports rather than assuming the contact list order
for _ = 1, #POINTS do
swipe(app, "right"); tick(app, 6)
local dump = label_dump()
local shown_name = dump:match("TGT | ([%w]+) |") or dump:match("TGT | ([%w]+)")
local pt = by_name[shown_name]
assert(pt, "unexpected target selected: " .. tostring(shown_name))
by_name[shown_name] = nil; seen = seen + 1
local brg = tonumber(dump:match("mi%s+(%d%d%d)") or dump:match("km%s+(%d%d%d)")
or dump:match("m%s+(%d%d%d)"))
assert(brg, "could not read a bearing for " .. pt.name .. ": " .. dump:sub(1, 130))
local err = math.abs(((brg - pt.brg + 540) % 360) - 180)
-- the drawn dot, as a screen angle clockwise from up
local dot
for _, c in ipairs(drawlog.circles) do if c.col == 0xE8A33D and c.filled then dot = c end end
assert(dot, "no marker drawn for " .. pt.name)
local ang = math.deg(math.atan(dot.x - D2, -(dot.y - D2))) % 360
local hdg = tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$"))
local want_ang = (pt.brg - hdg) % 360
local aerr = math.abs(((ang - want_ang + 540) % 360) - 180)
print(string.format(" %-2s (%s): bearing %3d want %6.2f err %4.1f | dot %6.1f want %6.2f err %4.1f",
pt.name, tostring(shown_name), brg, pt.brg, err, ang, want_ang, aerr))
assert(err <= 1.5, string.format("%s reads %d, should be %.1f -- bearings are wrong, not just offset",
pt.name, brg, pt.brg))
assert(aerr <= 3, string.format("%s is drawn %.1f deg from where its own bearing puts it", pt.name, aerr))
end
assert(seen == #POINTS, "every contact must be reachable, saw " .. seen)
app.on_close()
end
scenarios.m9 = function()
local FIX_DECL = 12.847 -- WMM2025 at the fixture position, epoch 2026.6
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 }
local true_heading, true_roll, true_pitch = 0, 0, 0
cfg.compass = function() return (attitude(true_heading, true_roll, true_pitch)) end
cfg.accel = function() local _, a = attitude(true_heading, true_roll, true_pitch); return a end
local moving = false
cfg.gps = function()
local g = { lat = 37.75000, lon = -122.45000, sats = 9, alt_m = 42 }
if moving then g.speed_kmh = 23.4; g.course = 215.0 end
return g
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
print(" widgets:", "canvases=" .. widgets.canvases, "labels=" .. widgets.labels, "buttons=" .. widgets.buttons,
"timer=" .. tostring(widgets.timer_ms))
assert(widgets.timer_ms and widgets.timer_ms >= 33)
tick(app, 10)
print(" uncal:", label_dump())
-- calibration: press C, rotate through 360 over 20 s, auto-finish
key(app, "c")
assert(cfg.awake == true, "calibration must hold the screen awake")
for i = 1, 140 do
true_heading = (i * 360 / 23) % 360 -- rotated every way, in one place
true_roll = (i * 360 / 11) % 360
true_pitch = ((i * 360 / 17) % 360) - 180
tick(app, 1, 150)
end
true_roll, true_pitch = 0, 0
assert(cfg.awake == false, "the hold must be released when calibration ends")
assert(storekv.cal_ox, "calibration did not persist; last toast: " .. tostring(toasts[#toasts]))
print(string.format(" cal offsets: %.3f %.3f r=%.3f (bias %.3f %.3f)", storekv.cal_ox, storekv.cal_oy, storekv.cal_r, BIAS.x, BIAS.y))
assert(math.abs(storekv.cal_ox - BIAS.x) < 0.02 and math.abs(storekv.cal_oy - BIAS.y) < 0.02
and math.abs(storekv.cal_oz - BIAS.z) < 0.02, "hard-iron offsets wrong")
assert(storekv.cal_r and math.abs(storekv.cal_r - 0.484) < 0.02, "fitted field radius wrong: " .. tostring(storekv.cal_r))
-- Heading accuracy after calibration. The simulated field points at MAGNETIC
-- north, so a correct app shows magnetic + declination: at the fixture's
-- position (37.75, -122.45) WMM2025 gives +12.85 deg east for 2026.6, which
-- is the clock the app falls back to because this harness returns no
-- datetime. Asserting the offset is present is the regression test for the
-- fault Chris saw on hardware -- every contact sitting ~22 deg west.
for _, th in ipairs({ 0, 45, 90, 180, 270, 359 }) do
true_heading = th; tick(app, 15)
-- drawn order at the centre: digits, degree sign, T/M reference, cardinal
local shown = drawlog.text[#drawlog.text - 3]
local num = tonumber(shown:match("^(%d+)$"))
assert(num, "expected bare digits at the dial centre, got: " .. tostring(shown))
assert(drawlog.text[#drawlog.text - 2] == "\194\176", "degree sign must follow the digits")
assert(drawlog.text[#drawlog.text - 1] == "T",
"with a fix the heading must be marked true, got: " .. tostring(drawlog.text[#drawlog.text - 1]))
local want = (th + FIX_DECL) % 360
local err = math.abs(((num - want + 540) % 360) - 180)
print(string.format(" mag %3d -> true %6.1f shown %s (err %.0f)", th, want, shown, err))
assert(err <= 2, "heading error too large at " .. th)
end
-- targets: d-pad right/left arrive as swipes; enter = tap at body centre
swipe(app, "right"); tick(app, 2); print(" target1:", label_dump())
swipe(app, "right"); swipe(app, "right"); swipe(app, "right"); tick(app, 2); print(" wrap:", label_dump())
swipe(app, "left"); tick(app, 2)
-- OK (synthetic down/up + an enter key event) switches the SELECTED row's
-- units exactly once; up/down moves the selection between ALT and SPD
assert(storekv.u_alt == nil and label_dump():find("ft"), "altitude must default to imperial")
enter(app, cfg.w, cfg.h); tick(app, 2)
assert(storekv.u_alt == 0 and label_dump():find("42 m"), "OK must switch altitude to metric exactly once: " .. label_dump())
enter(app, cfg.w, cfg.h); tick(app, 2)
assert(storekv.u_alt == 1 and label_dump():find("ft"), "OK must switch it back")
swipe(app, "down"); tick(app, 1); clock_ms = clock_ms + 400
enter(app, cfg.w, cfg.h); tick(app, 2)
assert(storekv.u_spd == 0 and storekv.u_alt == 1, "down then OK must switch SPEED, not altitude")
swipe(app, "up"); tick(app, 1); clock_ms = clock_ms + 400
enter(app, cfg.w, cfg.h); tick(app, 2)
assert(storekv.u_alt == 0, "up then OK must switch altitude again")
enter(app, cfg.w, cfg.h); tick(app, 2) -- back to imperial for the rest
swipe(app, "right"); tick(app, 2); print(" target:", label_dump())
-- saturation flag: heading must hold, src line must say so
local keep = cfg.compass
cfg.compass = function() local m = attitude(true_heading, 0, 0); m.ovfl = true; return m end
tick(app, 3); print(" saturated:", label_dump()); assert(label_dump():find("Field saturated"))
cfg.compass = keep; tick(app, 3); assert(not label_dump():find("Field saturated"))
-- every readout string must fit the 320-wide column (~23 glyphs at 12 px)
for _, l in ipairs(labels) do if l.x ~= 0 then assert(#l.text <= 23, "too wide for the M9 column: " .. l.text) end end
-- orientation / flip / clear / unknown keys
-- A PARTIAL turn must be REFUSED: an arc leaves the centre almost anywhere,
-- which is exactly how a silently-accepted bad fit broke north on hardware.
do
local saved = { storekv.cal_ox, storekv.cal_oy }
key(app, "c")
for i = 1, 80 do true_heading = (i * 60 / 80) % 360; tick(app, 1, 150) end -- flat, 60 deg only
key(app, "c"); tick(app, 2)
assert(toasts[#toasts]:find("Not calibrated"), "a partial turn must be refused, got: " .. toasts[#toasts])
assert(storekv.cal_ox == saved[1] and storekv.cal_oy == saved[2],
"a refused calibration must not overwrite the stored one")
end
-- the measured default must be right out of the box: no A press, no offset
do
local function shown0() return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end
for _, th in ipairs({ 0, 90, 180, 270 }) do
true_heading = th; tick(app, 15)
local err = math.abs(((shown0() - (th + FIX_DECL) + 540) % 360) - 180)
assert(err <= 2, string.format("default mapping wrong at %d: showed %d", th, shown0()))
end
assert(storekv.align == nil or storekv.align == 0, "no offset should be needed by default")
assert(storekv.mirror == nil or storekv.mirror == 0, "no mirror should be needed by default")
end
-- the accelerometer's own zero-g offset must come out of the same sweep,
-- or the tilt correction is applied against a gravity vector that is 5
-- degrees wrong
assert(storekv.acc_by and math.abs(storekv.acc_by - ABIAS.y) < 0.02,
"accel bias not recovered: " .. tostring(storekv.acc_by))
do
true_heading, true_roll, true_pitch = 0, 0, 0
tick(app, 10)
local t = label_dump():match("north%s+tilt%s+(%d+)") -- "MAG north tilt 0°"
assert(t and tonumber(t) <= 2, "flat device should read ~0 tilt, got: " .. tostring(t))
end
-- TILT COMPENSATION, the whole point of the IMU: heading must hold while the
-- device is tipped. Without it the error is ~1.5 deg per deg of tilt here.
do
local function shown() return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$")) end
for _, hdg in ipairs({ 0, 90, 200 }) do
true_heading, true_roll, true_pitch = hdg, 0, 0
tick(app, 15)
local level = shown()
for _, tilt in ipairs({ { 20, 0 }, { 0, 20 }, { -15, 15 } }) do
true_roll, true_pitch = tilt[1], tilt[2]
tick(app, 15)
local err = math.abs(((shown() - level + 540) % 360) - 180)
assert(err <= 3, string.format("tilt %d/%d at heading %d moved the reading %d deg (%d -> %d)",
tilt[1], tilt[2], hdg, err, level, shown()))
end
true_roll, true_pitch = 0, 0
end
print(" tilt: heading held within 3 deg through 20 deg of roll and pitch")
end
-- WAYPOINT PLACEMENT: with a known heading and a known target bearing, the
-- amber dot must sit at (bearing - heading) clockwise from the top of the
-- dial. AMBER is 0xE8A33D; the dial centre is (D/2, D/2).
do
true_heading, true_roll, true_pitch = 0, 0, 0
swipe(app, "right"); tick(app, 12) -- pick the first target
print(" rows: " .. label_dump())
-- the target row reads like "7.09 km brg 038° NE": take THAT number, not
-- the first degree sign on the page (the course readout also has one)
local brg = tonumber((label_dump():match("mi%s+(%d%d%d)")) or (label_dump():match("km%s+(%d%d%d)")))
assert(brg, "could not read the target bearing from the panel")
for _, hdg in ipairs({ 0, 90, 210 }) do
true_heading = hdg; tick(app, 20)
local dot
for _, c in ipairs(drawlog.circles) do if c.col == 0xE8A33D and c.filled then dot = c end end
assert(dot, "no waypoint marker drawn")
-- screen angle of the dot, clockwise from up, around the dial centre
local D2 = 144 / 2 -- landscape D on the M9 body
local ang = math.deg(math.atan(dot.x - D2, -(dot.y - D2))) % 360
local want = (brg - hdg) % 360
local err = math.abs(((ang - want + 540) % 360) - 180)
print(string.format(" waypoint: heading %3d bearing %3d -> want %3d, drawn %6.1f, err %.1f",
hdg, brg, want, ang, err))
end
end
-- DIAGNOSTICS must fit: at 12 px the panel is ~22 characters wide in normal
-- layout and ~26 with the key column reclaimed, and a longer line wraps onto
-- the row beneath it.
do
key(app, "d"); tick(app, 4)
for _, l in ipairs(labels) do
if l.text ~= "" and l.x ~= 0 then
assert(#l.text <= 26, "diagnostic row too wide (" .. #l.text .. "): " .. l.text)
end
end
print(" diag rows: " .. label_dump())
key(app, "d"); tick(app, 2)
end
-- align north: with the device pointing at an arbitrary true heading, one
-- press must make THAT direction read 000 and keep the dial turning the
-- right way (a later true heading must read back as itself minus the offset)
true_heading = 137; tick(app, 8)
key(app, "a"); tick(app, 8)
assert(storekv.align ~= nil, "align must persist")
assert(storekv.mirror == nil, "mirror is gone: the frames are measured, not hand-flipped")
assert(toasts[#toasts] == "North set here", "A must set the offset: " .. tostring(toasts[#toasts]))
local function shown_deg()
return tonumber(drawlog.text[#drawlog.text - 3]:match("^(%d+)$"))
end
assert(math.abs(((shown_deg() - 0 + 540) % 360) - 180) <= 2, "the aligned direction must read 000")
true_heading = 137 + 90; tick(app, 15)
assert(math.abs(((shown_deg() - 90 + 540) % 360) - 180) <= 3, "a right turn must count UP")
-- F and O must do NOTHING now: both frames are measured, so a key that
-- hand-flips handedness can only break a correct compass
local before_f = { storekv.align, storekv.mirror, storekv.orient, storekv.flip }
key(app, "f"); key(app, "F"); key(app, "o"); key(app, "O"); tick(app, 5)
assert(storekv.align == before_f[1] and storekv.mirror == before_f[2]
and storekv.orient == before_f[3] and storekv.flip == before_f[4],
"F and O must have no effect")
key(app, "a"); tick(app, 8)
true_heading = 0
key(app, nil, 0x87); key(app, "backspace", 8); key(app, "z"); tick(app, 2)
-- moving: GPS course appears in the readout while the compass still drives the rose
moving = true; tick(app, 5); print(" moving:", label_dump())
-- compass drops out -> GPS course takes over, then nothing
cfg.compass = function() return nil end; tick(app, 15); print(" mag lost:", label_dump())
assert(label_dump():find("GPS course"), "expected GPS-course fallback")
moving = false; tick(app, 5); print(" still:", label_dump())
cfg.gps = function() return nil end; tick(app, 5); print(" no fix:", label_dump())
key(app, "x"); tick(app, 1); assert(storekv.cal_ox == nil, "clear cal")
if app.on_close then guarded(BUDGET, app.on_close) end
print(" toasts:", table.concat(toasts, " / "))
end
scenarios.r8 = function()
cfg = { w = 240, h = 276, caps = { sdk_ext = true, keyboard = false, touch = true, compass = false },
contacts = contacts_fixture, self_lat = 0, self_lon = 0 }
local moving = true
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 6, alt_m = 10, speed_kmh = 5.5, course = 90.0 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
print(" widgets:", "canvases=" .. widgets.canvases, "labels=" .. widgets.labels, "buttons=" .. widgets.buttons, "scroll=" .. tostring(widgets.scroll))
assert(widgets.buttons == 1, "portrait touch board without compass should get one button")
tick(app, 10); print(" gps heading:", label_dump())
assert(label_dump():find("GPS course"))
tap(app, 120, 60); tick(app, 2); print(" tap rose:", label_dump())
drag(app, 120, 60, 40, 62, "left"); tick(app, 2) -- swipe handled once (left), not also as a tap
print(" drag:", label_dump()); assert(label_dump():find("none"), "swipe must cycle exactly once")
local before = label_dump()
send(app, { type = "down", x = 120, y = 60 }); send(app, { type = "up", x = 150, y = 60 }); tick(app, 1) -- 30 px drag, no swipe event
assert(label_dump() == before, "a drag without a swipe must be ignored")
tap(app, 120, 270); tick(app, 2) -- below the rose: no-op
buttons[1].fn(); tick(app, 2); print(" button:", label_dump())
-- portrait rows sit under the dial: ALT/SPD near y=280/298, so tap those to
-- switch units and something well clear of them to prove it is bounded
local u0 = storekv.u_alt
tap(app, 20, 282); tick(app, 2)
assert(storekv.u_alt ~= u0, "a tap on the ALT row must switch its units")
local u1, s1 = storekv.u_alt, storekv.u_spd
tap(app, 20, 440); tick(app, 2) -- well below every row
assert(storekv.u_alt == u1 and storekv.u_spd == s1, "a tap outside the rows must not change units")
local b2 = label_dump(); swipe(app, "right"); swipe(app, "right"); tick(app, 1) -- duplicate within 300 ms
print(" dbl swipe:", label_dump()); assert(label_dump() ~= b2, "first swipe must count")
clock_ms = clock_ms + 400; local b3 = label_dump(); swipe(app, "right"); tick(app, 1); assert(label_dump() ~= b3, "a later swipe must count")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.v4 = function()
cfg = { w = 240, h = 276, caps = { sdk_ext = false, keyboard = false, touch = true, compass = false },
contacts = {}, self_lat = 37.1, self_lon = -122.1 }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 10); print(" base sdk:", label_dump())
swipe(app, "right"); tick(app, 1)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.pager = function()
cfg = { w = 480, h = 178, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false },
contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 }
cfg.gps = function() return nil end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 10); print(" pager:", label_dump())
key(app, "c"); tick(app, 1)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.pager_portrait_jumbo = function()
cfg = { w = 222, h = 436, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false },
contacts = { { name = "WADAMESH-BASE-NODE-EXTRA", type = 2, ago_s = 1, lat = 37.8, lon = -122.4 } }, self_lat = 37.75, self_lon = -122.45 }
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 7, alt_m = 5, speed_kmh = 12.3, course = 215 } end
storekv = {}
wada = build_wada(); wada.ui.text_h = function(sz) return ({ [12] = 21, [14] = 22, [16] = 27 })[sz] or 21 end
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 3); swipe(app, "right"); tick(app, 3); print(" pager-portrait-jumbo:", label_dump())
for _, l in ipairs(labels) do if l.x ~= 0 then assert(#l.text <= 19, "too wide for a 210 px column at 18 px: " .. l.text) end end
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.tanmatsu = function()
cfg = { w = 480, h = 756, caps = { sdk_ext = true, keyboard = true, touch = false, compass = false },
contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 }
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 12, alt_m = 1203 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 5); swipe(app, "right"); tick(app, 5); print(" tanmatsu:", label_dump())
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.audio_api = function()
cfg = { w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false,
audio = true, audio_sd = true, sd_list = true } }
wada = build_wada()
local caps = wada.sys.caps()
assert(caps.audio and caps.audio_wav and caps.audio_mp3 and caps.audio_sd,
"audio capability flags do not match the WAV/MP3 contract")
assert(wada.audio and wada.audio.play and wada.audio.pause and wada.audio.resume and
wada.audio.stop and wada.audio.status, "wada.audio API is incomplete")
assert(wada.audio.play("track.wav"))
local status = wada.audio.status()
assert(status.state == "playing" and status.path == "track.wav" and
status.source == "app" and status.format == "wav", "app-storage play status is wrong")
assert(wada.audio.pause() and wada.audio.status().state == "paused", "pause failed")
assert(wada.audio.resume() and wada.audio.status().state == "playing", "resume failed")
assert(wada.audio.play("next.wav") and wada.audio.status().path == "next.wav",
"a second play must replace the active track")
assert(wada.audio.play("sd:/Music/card.wav"))
assert(wada.audio.status().source == "sd", "sd: source was not reported")
local value, err = wada.audio.play("../escape.wav")
assert(value == nil and err == "bad path", "app sandbox traversal was accepted")
value, err = wada.audio.play("sd:/Music/../escape.wav")
assert(value == nil and err == "bad path", "SD traversal was accepted")
assert(wada.audio.play("track.mp3") and wada.audio.status().format == "mp3",
"MP3 playback was not accepted or reported")
value, err = wada.audio.play("track.flac")
assert(value == nil and err == "unsupported format", "unknown audio format was accepted")
assert(wada.audio.stop() and wada.audio.status().state == "stopped", "stop failed")
assert(wada.audio.play("close.wav"))
audio_host_close()
assert(wada.audio.status().state == "stopped" and audio_log[#audio_log] == "release",
"host close did not release playback")
print(" transport + storage sandbox: PASS (" .. #audio_log .. " host calls)")
if APP_PATH:match("audio_test%.lua$") then
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(#buttons == 5, "audio test app did not create all transport/source buttons")
buttons[1].fn(); assert(wada.audio.status().state == "playing", "Play button failed")
buttons[2].fn(); assert(wada.audio.status().state == "paused", "Pause button failed")
buttons[3].fn(); assert(wada.audio.status().state == "playing", "Resume button failed")
buttons[4].fn(); assert(wada.audio.status().state == "stopped", "Stop button failed")
buttons[5].fn(); buttons[1].fn()
assert(wada.audio.status().source == "app", "source switch did not select app storage")
guarded(BUDGET, app.on_tick)
guarded(BUDGET, app.on_close)
assert(wada.audio.status().state == "stopped", "test app close did not stop playback")
print(" scripts/audio_test.lua UI: PASS")
end
end
-- instruction cost of one heavy tick (redraw forced every tick by spinning the heading)
scenarios.cost = function()
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
contacts = contacts_fixture, self_lat = 37.75, self_lon = -122.45 }
local th = 0
cfg.compass = function() th = th + 7; return (attitude(th, 0, 0)) end
cfg.accel = function() local _, a = attitude(th, 0, 0); return a end
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 9, alt_m = 42, speed_kmh = 10, course = 100 } end
storekv = { cal_ox = BIAS.x, cal_oy = BIAS.y, cal_oz = BIAS.z, cal_r = 0.484 }
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
swipe(app, "right")
local worst = 0
for i = 1, 50 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 150
if i == 25 then clock_ms = clock_ms + 10000 end -- force a contacts refresh inside a tick
local ok, err = pcall(app.on_tick, 150)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d), draw ops so far %d", worst, BUDGET, drawlog.ops))
assert(worst < BUDGET / 4, "tick too expensive")
end
-- ---- Tetris (deploy/apps/tetris) --------------------------------------------
scenarios.tetris_tdeck = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.canvases == 2, "expected 2 canvases (board + side), got " .. widgets.canvases)
assert(widgets.timer_ms == 700, "initial tick rate must be 700 ms, got " .. tostring(widgets.timer_ms))
tick(app, 5)
swipe(app, "right"); swipe(app, "left"); swipe(app, "up"); swipe(app, "down")
tick(app, 5)
print(" t-deck: OK canvases=" .. widgets.canvases .. " timer=" .. tostring(widgets.timer_ms))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.tetris_v4 = function()
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false } }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.canvases == 2, "expected 2 canvases")
tick(app, 20)
print(" v4: OK timer=" .. tostring(widgets.timer_ms))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.tetris_keyboard = function()
cfg = { w = 480, h = 178, caps = { touch = false, keyboard = true } }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 5)
key(app, "left"); key(app, "right"); key(app, "up"); key(app, "down"); key(app, "enter")
tick(app, 5)
print(" keyboard: OK timer=" .. tostring(widgets.timer_ms))
if app.on_close then guarded(BUDGET, app.on_close) end
end
local function tetris_text_has(s)
for i = #drawlog.text, math.max(1, #drawlog.text - 30), -1 do
if drawlog.text[i] == s then return true end
end
return false
end
local function tetris_run_to_gameover(app)
-- hard-drop repeatedly; draw_board() adds "tap to retry" before draw_side()
-- overwrites the tail of drawlog.text, so scan the last 30 entries
for _ = 1, 2000 do
tick(app, 1); swipe(app, "down")
if tetris_text_has("tap to retry") then return true end
end
return false
end
scenarios.tetris_logic = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(tetris_run_to_gameover(app), "game over never triggered in 2000 swipe-drops")
local ops_before = drawlog.ops
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
assert(drawlog.ops > ops_before, "tap after game over must redraw")
assert(widgets.timer_ms == 700, "restart must reset timer to 700 ms")
print(" logic: game-over triggered, restart OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.tetris_hiscore = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = { hiscore = 0 }
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(tetris_run_to_gameover(app), "game over never triggered")
local saved = storekv.hiscore
assert(type(saved) == "number", "hiscore must be saved as a number, got " .. type(saved))
print(" hiscore: saved=" .. tostring(saved))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.tetris_cost = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local worst = 0
for i = 1, 100 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 700
local ok, err = pcall(app.on_tick, 700)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d), draw ops %d", worst, BUDGET, drawlog.ops))
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
end
-- ---- Breakout (deploy/apps/breakout) ----------------------------------------
local function breakout_text_has(s)
for i = #drawlog.text, math.max(1, #drawlog.text - 20), -1 do
if drawlog.text[i] == s then return true end
end
return false
end
local function breakout_drive_to_gameover(app)
-- keep launching (swipe up is a no-op once launched) and ticking;
-- scan backward through the last 20 text entries for "tap to restart"
for _ = 1, 3000 do
swipe(app, "up"); tick(app, 1)
if breakout_text_has("tap to restart") then return true end
end
return false
end
scenarios.breakout_tdeck = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.canvases == 1, "expected 1 canvas, got " .. widgets.canvases)
assert(widgets.timer_ms == 33, "timer must be 33 ms, got " .. tostring(widgets.timer_ms))
swipe(app, "left"); swipe(app, "right"); swipe(app, "up")
tick(app, 20)
print(" t-deck: OK timer=" .. tostring(widgets.timer_ms) .. " ops=" .. drawlog.ops)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.breakout_v4 = function()
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false } }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
swipe(app, "up"); tick(app, 20)
print(" v4: OK ops=" .. drawlog.ops)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.breakout_keyboard = function()
cfg = { w = 480, h = 178, caps = { touch = false, keyboard = true } }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
key(app, "left"); key(app, "right"); key(app, "up")
tick(app, 20)
print(" keyboard: OK ops=" .. drawlog.ops)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.breakout_logic = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(breakout_drive_to_gameover(app), "game over never triggered in 3000 ticks")
local ops_before = drawlog.ops
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
assert(drawlog.ops > ops_before, "tap after game over must redraw")
print(" logic: game-over triggered, restart OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.breakout_hiscore = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = { hiscore = 0 }; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(breakout_drive_to_gameover(app), "game over never triggered")
local saved = storekv.hiscore
assert(type(saved) == "number", "hiscore must be saved as a number, got " .. type(saved))
print(" hiscore: saved=" .. tostring(saved))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.breakout_cost = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false } }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
swipe(app, "up"); tick(app, 2) -- launch
local worst = 0
for i = 1, 100 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 33
local ok, err = pcall(app.on_tick, 33)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d), draw ops %d", worst, BUDGET, drawlog.ops))
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- ---- Trip Odometer (deploy/apps/tripodometer) --------------------------------
scenarios.trip_v4 = function()
-- portrait touch board, no GPS fix → shows "no fix", all zeros
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return nil end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.buttons == 2, "expected 2 buttons (Start/Stop + Reset)")
tick(app, 5)
local dump = label_dump()
print(" no fix:", dump)
assert(dump:find("no fix") or dump:find("no GPS"), "expected no-fix indicator")
assert(dump:find("0:00"), "expected zero elapsed time")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.trip_tdeck = function()
-- landscape T-Deck with GPS fix, start tracking
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 8, alt_m = 42, speed_kmh = 25.0, course = 90.0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 2)
local dump = label_dump()
print(" fix before start:", dump)
assert(dump:find("25") or dump:find("25.0"), "expected speed displayed")
-- start tracking
buttons[1].fn(); tick(app, 3)
dump = label_dump()
print(" tracking:", dump)
assert(dump:find("tracking"), "expected tracking status after Start")
-- stop
buttons[1].fn(); tick(app, 1)
dump = label_dump()
print(" paused:", dump)
assert(dump:find("paused"), "expected paused status after Stop")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.trip_moving = function()
-- distance accumulates; home bearing points back south; min altitude tracked;
-- avg speed is non-zero and ≤ max speed
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
local step = 0
cfg.gps = function()
step = step + 1
-- move north ~22 m per tick (0.0002° ≈ 22 m); speed_kmh = 45 > implied ~40 km/h
-- so avg speed (from haversine) stays below reported max speed
return { lat = 37.75 + step * 0.0002, lon = -122.45,
sats = 8, alt_m = 100 - step, speed_kmh = 45.0 }
end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
buttons[1].fn() -- start tracking
tick(app, 15) -- 15 fixes moving north ~22 m each → ~330 m
local dump = label_dump()
print(" after 15 ticks:", dump)
-- distance > 0
assert(not dump:find("| 0 m |"), "expected non-zero distance after movement")
-- avg speed ≤ max speed (avg derived from Haversine ≈ 37 km/h, mock max = 45 km/h)
local avg_s = dump:match("AVG SPEED | ([%d%.]+) km/h")
local max_s = dump:match("MAX SPEED | ([%d%.]+) km/h")
assert(avg_s and max_s, "expected AVG SPEED and MAX SPEED labels in dump")
assert(tonumber(avg_s) <= tonumber(max_s),
string.format("avg speed %s must be <= max speed %s", avg_s, max_s))
-- home bearing: moved north, home is south → "S"
assert(dump:find(" S"), "expected southward home bearing (1.1)")
-- min altitude: descended from 99 to 85 over 15 steps
assert(dump:find("85") or dump:find("min"), "expected min altitude tracked (1.1)")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.trip_persist = function()
-- on_close saves distance, max speed, elapsed time, min altitude and home;
-- on_open restores all of them including home
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
local step = 0
cfg.gps = function()
step = step + 1
-- no speed_kmh → derived from positions; step*0.0002° ≈ 22 m/2 s ≈ 40 km/h
return { lat = 37.75 + step * 0.0002, lon = -122.45, sats = 7, alt_m = 50 }
end
storekv = {}; wada = build_wada()
local app = load_app()
guarded(BUDGET, app.on_open, cfg.w, cfg.h)
buttons[1].fn(); tick(app, 10) -- run ~200 m, home set on first fix
if app.on_close then guarded(BUDGET, app.on_close) end
assert(storekv.trip_km_x1000 ~= nil and storekv.trip_km_x1000 > 0, "expected trip_km_x1000 saved")
assert(storekv.trip_minalt ~= nil, "expected trip_minalt saved (1.1)")
assert(storekv.trip_home_lat_e6 ~= nil, "expected home lat saved (1.1)")
assert(storekv.trip_home_lon_e6 ~= nil, "expected home lon saved (1.1)")
-- re-open same store → distance and home should be restored
wada = build_wada()
local app2 = load_app()
guarded(BUDGET, app2.on_open, cfg.w, cfg.h)
tick(app2, 2)
local dump = label_dump()
print(" restored:", dump)
assert(not dump:find("| 0 m |"), "expected restored non-zero distance")
-- home bearing should still show (not "acquiring" since home was persisted)
assert(not dump:find("acquiring"), "home should be restored from store, not re-acquiring")
if app2.on_close then guarded(BUDGET, app2.on_close) end
end
scenarios.trip_reset = function()
-- Reset clears distance, time, home bearing, min altitude
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
local step = 0
cfg.gps = function()
step = step + 1
-- no speed_kmh → derived from positions; ensures avg ≤ max always
return { lat = 37.75 + step * 0.0002, lon = -122.45, sats = 6, alt_m = 50 }
end
storekv = {}; wada = build_wada()
local app = load_app()
guarded(BUDGET, app.on_open, cfg.w, cfg.h)
buttons[1].fn(); tick(app, 10) -- accumulate distance
buttons[2].fn(); tick(app, 1) -- Reset
local dump = label_dump()
print(" after reset:", dump)
assert(dump:find("0:00"), "expected zero time after reset")
assert(dump:find("| 0 m |") or dump:find("0.00"), "expected zero distance after reset")
-- home bearing should be cleared (1.1)
assert(dump:find("| -- |") or dump:find("-- |") or dump:find("--"),
"expected no home bearing after reset (1.1)")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.trip_cost = function()
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
local step = 0
cfg.gps = function()
step = step + 1
return { lat = 37.75 + step * 0.0001, lon = -122.45, sats = 8, alt_m = 42, speed_kmh = 10.0 }
end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
buttons[1].fn() -- start tracking
local worst = 0
for i = 1, 100 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 2000
local ok, err = pcall(app.on_tick, 2000)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d)", worst, BUDGET))
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- ---- Ping (deploy/apps/ping) ------------------------------------------------
local function deliver_dm(app, sender, text)
if app.on_message then
guarded(BUDGET, app.on_message, { kind = "dm", sender = sender, text = text, channel = "" })
end
end
-- contacts used by all ping scenarios
local PING_CONTACTS = {
{ name = "Node-Alpha", lat = 48.9000, lon = 2.4000 },
{ name = "Node-Beta", lat = 48.8000, lon = 2.3000 },
}
local MY_GPS = { lat = 48.8566, lon = 2.3522, lat_e6 = 48856600, lon_e6 = 2352200, sats = 9 }
local function gps_fix() return MY_GPS end
scenarios.ping_tdeck = function()
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
contacts = PING_CONTACTS, gps = gps_fix }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.labels >= 3, "expected at least 3 labels (gps + 2 names), got " .. widgets.labels)
assert(widgets.buttons >= 2, "expected 2 Ping buttons, got " .. widgets.buttons)
assert(widgets.scroll, "scroll should be enabled")
assert(widgets.timer_ms, "timer not started")
print(string.format(" labels=%d buttons=%d timer=%dms", widgets.labels, widgets.buttons, widgets.timer_ms))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ping_v4 = function()
cfg = { w = 240, h = 276, caps = { touch = true, sdk_ext = true },
contacts = PING_CONTACTS, gps = gps_fix }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.buttons >= 2, "expected 2 Ping buttons on V4")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ping_no_contacts = function()
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true }, contacts = {} }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.buttons == 0, "no Ping buttons when no contacts")
local found = false
for _, l in ipairs(labels) do if l.text:find("No contacts") then found = true end end
assert(found, "should show 'No contacts' label")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ping_round_trip = function()
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
contacts = PING_CONTACTS, gps = gps_fix }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local btn = buttons[1]; assert(btn and btn.fn, "no ping button")
assert(guarded(BUDGET, btn.fn))
assert(#dm_sent == 1, "expected 1 DM sent, got " .. #dm_sent)
assert(dm_sent[1].to == "Node-Alpha", "wrong target: " .. tostring(dm_sent[1].to))
local ping_msg = dm_sent[1].text
assert(ping_msg:sub(1, 9) == "WADAPING:", "wrong prefix: " .. ping_msg:sub(1, 9))
local ts_s, lat_s, lon_s = ping_msg:sub(10):match("(-?%d+):(-?%d+):(-?%d+)")
assert(ts_s, "could not parse PING message: " .. ping_msg)
print(" sent: " .. ping_msg)
clock_ms = clock_ms + 250
local their_lat6, their_lon6 = 48900000, 2400000
local pong = string.format("WADAPONG:%s:%d:%d", ts_s, their_lat6, their_lon6)
deliver_dm(app, "Node-Alpha", pong)
local result_ok = false
for _, l in ipairs(labels) do
if l.text:find("ms") then
print(" result: " .. l.text)
result_ok = true
end
end
assert(result_ok, "no RTT label found after PONG")
local last_toast = toasts[#toasts]
assert(last_toast and last_toast:find("Node-Alpha", 1, true), "toast must mention sender, got: " .. tostring(last_toast))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ping_auto_reply = function()
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
contacts = PING_CONTACTS, gps = gps_fix }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local remote_ts = 999888
local remote_lat6, remote_lon6 = 48900000, 2400000
local ping_in = string.format("WADAPING:%d:%d:%d", remote_ts, remote_lat6, remote_lon6)
dm_sent = {}
deliver_dm(app, "Node-Alpha", ping_in)
assert(#dm_sent == 1, "expected auto-reply DM, got " .. #dm_sent)
assert(dm_sent[1].to == "Node-Alpha", "reply must go back to sender")
local reply = dm_sent[1].text
assert(reply:sub(1, 9) == "WADAPONG:", "reply must start with WADAPONG:")
-- timestamp in reply must match original
local rts = reply:sub(10):match("(-?%d+):")
assert(rts == tostring(remote_ts),
string.format("reply ts %s != original %d", rts, remote_ts))
print(" auto-reply: " .. reply)
-- non-DM messages are ignored
local before = #dm_sent
guarded(BUDGET, app.on_message, { kind = "channel", sender = "x", text = ping_in, channel = "c" })
assert(#dm_sent == before, "channel messages must not trigger auto-reply")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ping_cost = function()
cfg = { w = 320, h = 240, caps = { touch = true, sdk_ext = true },
contacts = PING_CONTACTS, gps = gps_fix }
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local worst = 0
for _ = 1, 20 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 5000
local ok, err = pcall(app.on_tick, 5000)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d)", worst, BUDGET))
assert(worst < BUDGET / 4, "tick too expensive: " .. worst .. " > " .. BUDGET / 4)
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- ---- SD Scan (deploy/apps/sdscan) --------------------------------------------
local PNG = "\137PNG\r\n\26\n" .. string.rep("\0", 120)
-- A file carrying real DOS + PE headers and nothing else: no code at all.
local function pe_bytes(n, e_lfanew)
e_lfanew = e_lfanew or 0x80
local t = {}
for i = 1, n do t[i] = "\0" end
t[1], t[2] = "M", "Z"
for k = 0, 3 do t[0x3C + 1 + k] = string.char((e_lfanew >> (8 * k)) & 0xFF) end
t[e_lfanew + 1], t[e_lfanew + 2] = "P", "E"
return table.concat(t)
end
local NBSP = "\194\160" -- the worm's blank-looking folder name
-- The M9 card as the advisory describes it, plus wadamesh's own data and tiles.
local function infected_card(extra)
local f = {
["/autorun.inf"] = "[autorun]\r\nopen=" .. NBSP .. "\\svchost.exe\r\n",
["/" .. NBSP .. "/svchost.exe"] = pe_bytes(512),
["/tiles.lnk"] = "L\0\0\0\1\20\2\0" .. string.rep("\0", 80),
["/RECYCLER/S-1-5-21-1004/desktop.dat"] = pe_bytes(300, 0x78), -- a program under a harmless name
["/copyright.png"] = PNG,
["/test file.txt"] = "test",
["/wadamesh/contacts3"] = "MZ" .. string.rep("\165", 200), -- starts with MZ by chance
["/wadamesh/ui_threads_v1.bin"] = string.rep("\1", 300),
["/wadamesh/lang/hu.lang"] = "# ver: 22\n",
["/autorun"] = true, -- an empty "vaccine" folder
}
for z = 8, 10 do for x = 130, 133 do for y = 80, 83 do
f[string.format("/maps/osm/%d/%d/%d.png", z, x, y)] = PNG
end end end
for k, v in pairs(extra or {}) do f[k] = v end
return mktree(f)
end
local function sd_cfg(w, h, extra_caps)
local c = { w = w or 320, h = h or 196,
caps = { sdk_ext = true, keyboard = true, touch = false, sd_list = true, sd_clean = true } }
for k, v in pairs(extra_caps or {}) do c.caps[k] = v end
return c
end
local function press(text)
for i = #buttons, 1, -1 do
if buttons[i].text == text then
assert(guarded(BUDGET, buttons[i].fn), "button '" .. text .. "' failed")
return
end
end
error("no '" .. text .. "' button; screen: " .. label_dump())
end
local function has_button(text)
for _, b in ipairs(buttons) do if b.text == text then return true end end
return false
end
local function screen_has(text) return label_dump():find(text, 1, true) ~= nil end
local function scan_until(app, text, max_ticks)
for _ = 1, max_ticks or 4000 do
if screen_has(text) then return end
tick(app, 1, 50)
end
error("never reached '" .. text .. "'; screen: " .. label_dump())
end
local function found_paths()
local out = {}
for _, l in ipairs(lists) do for _, r in ipairs(l.rows) do out[#out + 1] = r.text end end
return out
end
local function rows_mention(needle)
for _, r in ipairs(found_paths()) do if r:find(needle, 1, true) then return true end end
return false
end
-- Wrapped text on the current screen must end above every button below it.
local function assert_no_overlap(where)
for _, l in ipairs(labels) do
if l.w and l.text ~= "" then
local bottom = l.y + wada.ui.text_lines(l.text, l.w, l.size or 12) * wada.ui.text_h(l.size or 12)
for _, b in ipairs(buttons) do
if b.y > l.y then
assert(bottom <= b.y, string.format("%s: text runs into the '%s' button (%d > %d): %s",
where, b.text, bottom, b.y, l.text:sub(1, 40)))
end
end
end
end
end
local NOTICE_START = "SD Scan only removes files it recognises"
-- SD Scan 1.1: on a keyboard board the focus order is creation order, and the
-- firmware's key navigation collects at most kNavMax = 160 focus stops per
-- screen. So the buttons must be created before (and sit above) any list, and
-- a screen must stay well under 160 stops however many files were found.
local NAV_MAX = 160
local function assert_buttons_first(where)
local stops = #buttons
for _, l in ipairs(lists) do
stops = stops + #l.rows
for _, b in ipairs(buttons) do
assert(b.seq < l.seq, where .. ": the '" .. b.text .. "' button comes after the list in focus order")
assert(b.y + 32 <= l.y, where .. ": the '" .. b.text .. "' button is not above the list")
end
end
assert(stops < NAV_MAX, where .. ": " .. stops .. " focus stops, the firmware collects " .. NAV_MAX)
end
scenarios.sdscan_layouts = function()
for _, dims in ipairs({ { 320, 196 }, { 240, 276 }, { 480, 178 }, { 222, 436 } }) do
reset_world()
cfg = sd_cfg(dims[1], dims[2])
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
local where = dims[1] .. "x" .. dims[2]
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has(NOTICE_START), where .. ": the start screen must carry the notice")
assert_no_overlap(where .. " start")
press("Scan")
scan_until(app, "threats found")
assert_no_overlap(where .. " results")
assert_buttons_first(where .. " results")
press("Remove all")
assert_no_overlap(where .. " confirm")
press("Remove")
scan_until(app, "Removed 4 files.")
assert(screen_has(NOTICE_START), where .. ": the removal screen must carry the notice")
assert(not screen_has("safe to put"), where .. ": do not tell people the card is safe")
assert_no_overlap(where .. " removed")
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
assert(screen_has(NOTICE_START), where .. ": a clean result must carry the notice")
assert_no_overlap(where .. " clean")
end
end
-- The card root of a real infected M9 (photographed 2026-09-18), autorun.inf text
-- as it was on the card: Sality's pattern, with the payload as xlfqf.pif.
local SALITY_AUTORUN = table.concat({
";jedgRfgxKpOwxYkkslpwEyPfQXwXltdBYsJ", "XCmmWo aRguMqaH",
"sHell\\EXplore\\COmmanD=xlfqf.pif", "sHELl\\OpEN\\Default=1",
";qaApt hKlVtplou hjPQkb", "shell\\opEn\\CoMmanD = xlfqf.pif",
";vpWCt WxgC", "ShelL\\AUTopLay\\ComMaNd=xlfqf.pif", ";", "" }, "\r\n")
scenarios.sdscan_real_m9 = function()
cfg = sd_cfg(320, 196)
local f = {
["/autorun.inf"] = SALITY_AUTORUN,
["/xlfqf.pif"] = pe_bytes(4096),
["/copyright.png"] = PNG,
["/bl/list"] = "0",
["/meshcomod/contacts3"] = string.rep("\3", 300),
["/meshcomod/ui_threads_v1.bin"] = string.rep("\1", 300),
}
for x = 130, 133 do for y = 80, 83 do f[string.format("/tiles/8/%d/%d.png", x, y)] = PNG end end
cfg.sdtree = mktree(f)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert(screen_has("2 threats found"), label_dump())
assert(rows_mention("autorun file: /autorun.inf"))
assert(rows_mention("Windows program: /xlfqf.pif"))
press("Remove all")
press("Remove")
scan_until(app, "Removed 2 files.")
assert(not sd_exists("/autorun.inf") and not sd_exists("/xlfqf.pif"))
for _, keep in ipairs({ "/copyright.png", "/bl/list", "/meshcomod/contacts3",
"/meshcomod/ui_threads_v1.bin", "/tiles/8/130/80.png" }) do
assert(sd_exists(keep), "removed something that is not the worm: " .. keep)
end
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
end
scenarios.sdscan_many = function()
cfg = sd_cfg(320, 196)
local f = { ["/autorun.inf"] = SALITY_AUTORUN, ["/copyright.png"] = PNG, ["/meshcomod/contacts3"] = "x" }
for i = 1, 372 do f[string.format("/RECYCLER/p%03d.pif", i)] = pe_bytes(256) end
cfg.sdtree = mktree(f)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert(screen_has("373 threats found"), label_dump())
assert(#lists == 1)
local rows = lists[1].rows
assert(#rows == 61, "expected 60 rows plus the summary row, got " .. #rows)
assert(rows[61].text:find("and 313 more", 1, true), "last row: " .. rows[61].text)
assert_buttons_first("373 findings")
press("Remove all")
press("Remove")
scan_until(app, "Removed 373 files.", 2000)
assert(sd_exists("/copyright.png") and sd_exists("/meshcomod/contacts3"))
assert(not sd_exists("/autorun.inf"))
end
scenarios.sdscan_m9 = function()
cfg = sd_cfg(320, 196)
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.timer_ms and widgets.timer_ms <= 100, "the scan needs a steady tick")
press("Scan")
assert(cfg.awake, "the screen must stay on while scanning")
scan_until(app, "threats found")
assert(not cfg.awake, "keep_awake must be released when the scan ends")
assert(screen_has("4 threats found"), "expected exactly 4 threats; " .. label_dump())
assert(rows_mention("autorun file: /autorun.inf"))
assert(rows_mention("Windows program: /" .. NBSP .. "/svchost.exe"))
assert(rows_mention("Windows shortcut: /tiles.lnk"))
assert(rows_mention("renamed Windows program"), "a program under another name was missed")
assert(not rows_mention("contacts3"), "a data file that starts with MZ is not a program")
assert(screen_has("map tiles skipped"), "a quick scan should say it skipped the tile folders")
for _, c in ipairs(cfg.sd_calls) do assert(c.max <= 24, "pages must stay small: " .. c.max) end
press("Remove all")
assert(screen_has("Remove 4 files?"))
assert(buttons[1].text == "Cancel", "Cancel must come first so a stray Enter deletes nothing")
press("Cancel")
assert(screen_has("4 threats found") and not cfg.sd_removed, "Cancel must not remove anything")
press("Remove all")
press("Remove")
scan_until(app, "Removed 4 files.")
assert(#cfg.sd_removed == 4)
for _, keep in ipairs({ "/copyright.png", "/test file.txt", "/wadamesh/contacts3",
"/wadamesh/ui_threads_v1.bin", "/wadamesh/lang/hu.lang",
"/maps/osm/8/130/80.png", "/autorun" }) do
assert(sd_exists(keep), "removed something that is not a threat: " .. keep)
end
assert(not sd_exists("/autorun.inf") and not sd_exists("/tiles.lnk"))
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
end
scenarios.sdscan_paging_and_full = function()
cfg = sd_cfg(320, 196)
local extra = { ["/maps/osm/8/130/evil.exe"] = pe_bytes(200), ["/wadamesh/history/zz.vbs"] = "WScript.Echo 1" }
for i = 1, 100 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end
cfg.sdtree = infected_card(extra)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert(rows_mention("zz.vbs"), "the 101st file of a folder was never reached: paging broken")
assert(not rows_mention("evil.exe"), "a quick scan should not walk the tiles")
local paged = 0
for _, c in ipairs(cfg.sd_calls) do if c.path == "/wadamesh/history" and c.start > 1 then paged = paged + 1 end end
assert(paged >= 4, "expected the history folder to be read in pages, got " .. paged)
press("Scan again")
press("Full scan")
scan_until(app, "threats found")
assert(rows_mention("evil.exe"), "a full scan must walk the tile folders")
assert(not screen_has("map tiles skipped"))
end
scenarios.sdscan_old_firmware = function()
cfg = sd_cfg(320, 196, { sd_clean = false })
cfg.sdtree = infected_card()
wada = build_wada()
assert(not wada.sd.remove and not wada.sd.check)
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has("can only find them"), "the intro should say removal needs newer firmware")
press("Scan")
scan_until(app, "threats found")
assert(screen_has("3 threats found"), "by name: autorun, program, shortcut; " .. label_dump())
assert(not has_button("Remove all"), "no Remove button without firmware support")
assert(has_button("Scan again"))
assert(screen_has("format the card"))
end
scenarios.sdscan_no_access = function()
cfg = sd_cfg(320, 196, { sd_list = false, sd_clean = false })
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has("no SD card access"), label_dump())
assert(not has_button("Scan"))
end
scenarios.sdscan_no_card = function()
cfg = sd_cfg(320, 196)
cfg.sd_nocard = true
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "No SD card found.")
assert(has_button("Scan again"))
end
scenarios.sdscan_remove_fails = function()
cfg = sd_cfg(320, 196)
cfg.sdtree = infected_card()
cfg.sd_remove_fail = { ["/tiles.lnk"] = "remove failed" }
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
press("Remove all")
press("Remove")
scan_until(app, "could not be removed")
assert(screen_has("Removed 3, 1 could not be removed."), label_dump())
assert(rows_mention("/tiles.lnk") and rows_mention("remove failed"))
end
scenarios.sdscan_portrait = function()
cfg = sd_cfg(240, 276, { keyboard = false, touch = true })
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert_buttons_first("portrait")
end
scenarios.sdscan_cost = function()
cfg = sd_cfg(320, 196)
local extra = {}
for i = 1, 400 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end
cfg.sdtree = infected_card(extra)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Full scan")
local worst, ticks = 0, 0
while not screen_has("threats found") and ticks < 5000 do
local n = 0
debug.sethook(function() n = n + 100 end, "", 100)
clock_ms = clock_ms + 50
local ok, err = pcall(app.on_tick, 50)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
ticks = ticks + 1
end
print(string.format(" full scan of %d files: %d ticks, worst tick ~%d instructions (budget %d)",
400 + 60, ticks, worst, BUDGET))
assert(worst < BUDGET / 4, "tick too expensive")
end
-- ---- ProTreck (deploy/apps/protreck) --------------------------------------------
local function pt_has_text(s)
for i = #drawlog.text, math.max(1, #drawlog.text - 40), -1 do
if drawlog.text[i] == s then return true end
end
return false
end
scenarios.protreck_tdeck = function()
cfg = { w = 320, h = 196, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return { lat = 48.85, lon = 2.35, sats = 8, alt_m = 35, speed_kmh = 0, course = 0 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.canvases == 1, "expected 1 canvas")
assert(widgets.timer_ms == 500, "tick rate must be 500 ms, got " .. tostring(widgets.timer_ms))
tick(app, 5)
-- swipe through all 5 tabs (ASTRO→CMPAS→CHRONO→TIMER→ALTI→ASTRO)
swipe(app, "left"); tick(app, 2) -- CMPAS
swipe(app, "left"); tick(app, 2) -- CHRONO
swipe(app, "left"); tick(app, 2) -- TIMER
swipe(app, "left"); tick(app, 2) -- ALTI
swipe(app, "left"); tick(app, 2) -- back to ASTRO
print(" t-deck: OK canvases=" .. widgets.canvases .. " timer=" .. tostring(widgets.timer_ms))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_v4 = function()
cfg = { w = 240, h = 276, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return { lat = 37.75, lon = -122.45, sats = 6, alt_m = 120, speed_kmh = 5, course = 90 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 3)
swipe(app, "left"); tick(app, 2) -- CMPAS
swipe(app, "left"); tick(app, 2) -- CHRONO
swipe(app, "right"); tick(app, 2) -- back CMPAS
swipe(app, "right"); tick(app, 2) -- back ASTRO
swipe(app, "right"); tick(app, 2) -- wrap to ALTI (tab 5)
if app.on_close then guarded(BUDGET, app.on_close) end
print(" v4: OK")
end
scenarios.protreck_no_gps = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return nil end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 5)
-- must render without crash even when GPS is absent on all tabs
swipe(app, "left"); tick(app, 2) -- CMPAS (no heading)
swipe(app, "left"); tick(app, 2) -- CHRONO
swipe(app, "left"); tick(app, 2) -- TIMER
swipe(app, "left"); tick(app, 2) -- ALTI
print(" no-gps: OK (no crash)")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_chrono = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return nil end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
-- switch to CHRONO tab (tab 3: skip CMPAS first)
swipe(app, "left"); swipe(app, "left"); tick(app, 1)
-- start chrono (tap = down + up at same position)
local ops0 = drawlog.ops
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 3)
-- lap
swipe(app, "up"); tick(app, 1)
assert(pt_has_text("Lap 1"), "lap must appear in draw after swipe-up")
-- stop
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
-- reset
swipe(app, "down"); tick(app, 1)
print(" chrono: start / lap / stop / reset OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_timer = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return nil end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
-- switch to TIMER (tab 4)
swipe(app, "left"); swipe(app, "left"); swipe(app, "left"); tick(app, 1)
-- cycle preset a few times
swipe(app, "up"); swipe(app, "up"); tick(app, 1)
-- start timer (tap = down + up at same position)
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 3)
-- stop
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
-- reset
swipe(app, "down"); tick(app, 1)
assert(type(storekv.tmr_pi) == "number", "preset index must persist as a number")
print(" timer: preset cycle / start / stop / reset OK preset_i=" .. tostring(storekv.tmr_pi))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_alti = function()
local alt = 1200
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function()
alt = alt + 1
return { lat = 45.8, lon = 6.9, sats = 10, alt_m = alt, speed_kmh = 3, course = 45 }
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
-- switch to ALTI (tab 5)
swipe(app, "left"); swipe(app, "left"); swipe(app, "left"); swipe(app, "left"); tick(app, 1)
tick(app, 20) -- accumulate samples
assert(storekv.alti_max ~= nil, "alti_max must be saved to store after GPS samples")
assert(type(storekv.alti_max) == "number", "alti_max must be a number")
-- reset min/max
swipe(app, "down"); tick(app, 2)
print(" alti: sampling OK max=" .. tostring(storekv.alti_max))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_cost = function()
local alt = 500
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() alt = alt + 0.5; return { lat = 48.85, lon = 2.35, sats = 8, alt_m = alt, speed_kmh = 2, course = 0 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
-- switch to ALTI (heaviest draw: graph over 60 samples) - tab 5
swipe(app, "left"); swipe(app, "left"); swipe(app, "left"); swipe(app, "left")
local worst = 0
for i = 1, 80 do
local n = 0
debug.sethook(function() n = n + 1000 end, "", 1000)
clock_ms = clock_ms + 500
local ok, err = pcall(app.on_tick, 500)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
end
print(string.format(" worst tick ~%d instructions (budget %d), ops %d", worst, BUDGET, drawlog.ops))
assert(worst < BUDGET / 4, "tick too expensive: " .. worst)
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_alarm = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return nil end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 2)
-- short press: cycle alarm slot A1 → A2
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 }) -- same clock → dur=0 → cycle
tick(app, 1)
assert(pt_has_text("A2"), "A2 must be highlighted after first tap (cycle to slot 2)")
-- swipe up: +15 min on A2 (default 08:00 → 08:15)
swipe(app, "up"); tick(app, 1)
assert(storekv.al2h == 8 and storekv.al2m == 15,
string.format("A2 must be 08:15 after swipe-up, got %s:%s", tostring(storekv.al2h), tostring(storekv.al2m)))
-- swipe down: −15 min on A2 (08:15 → 08:00)
swipe(app, "down"); tick(app, 1)
assert(storekv.al2h == 8 and storekv.al2m == 0, "A2 must be 08:00 after swipe-down")
-- long press (hold ≥500ms): toggle A2 on/off
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
clock_ms = clock_ms + 600 -- simulate 600ms hold
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
assert(storekv.al2on == 1, "A2 must be ON after long-press toggle")
-- long press again: toggle A2 off
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
clock_ms = clock_ms + 600
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
assert(storekv.al2on == 0, "A2 must be OFF after second long-press toggle")
print(" alarm: 3-slot cycle + long-press toggle + swipe ±15min OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_compass = function()
-- x=1, y=1 → atan(c.y, -c.x) = atan(1,-1) = 135°
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true, compass = true } }
cfg.gps = function() return { lat = 45.0, lon = 9.0, sats = 7, alt_m = 200, speed_kmh = 4, course = 135 } end
cfg.compass = function() return { x = 1, y = 1, z = 0 } end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 2)
-- switch to CMPAS (tab 2)
swipe(app, "left"); tick(app, 2)
assert(drawlog.ops > 10, "CMPAS tab must perform draw ops")
-- heading readout must appear (135 → text "135")
local found_hdg = false
for i = #drawlog.text, math.max(1, #drawlog.text-20), -1 do
if drawlog.text[i] == "135" then found_hdg = true; break end
end
assert(found_hdg, "heading value '135' must appear in draw text")
-- tap: set waypoint from current GPS position (tap = down + up at same position)
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
assert(storekv.wpt_lat ~= nil and storekv.wpt_lat ~= "",
"tap on CMPAS must save waypoint to store")
assert(math.abs(tonumber(storekv.wpt_lat) - 45.0) < 0.01, "saved waypoint lat must match GPS")
-- bearing line must now appear (wpt_lat is set)
tick(app, 2)
-- tap again: clear waypoint
guarded(BUDGET, app.on_input, { type = "down", x = 0, y = 0 })
guarded(BUDGET, app.on_input, { type = "up", x = 0, y = 0 })
tick(app, 1)
assert(storekv.wpt_lat == "" or storekv.wpt_lat == nil, "second tap must clear waypoint")
print(" compass: rose draw + heading readout + waypoint set/clear OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_deniv = function()
-- 8 GPS calls are consumed by on_open+swipes (draw_status_bar etc.) before the
-- first on_tick altimeter sample arrives at step 9; put 110 there so alti_max
-- is captured, then let the sequence end and return nil for the reset check.
local alt_seq = { 100, 102, 105, 103, 101, 104, 108, 106, 110, 104 }
local step = 0
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function()
step = step + 1
if step > #alt_seq then return nil end -- stop providing GPS after the sequence
return { lat = 46.0, lon = 7.0, sats = 9, alt_m = alt_seq[step], speed_kmh = 2, course = 0 }
end
storekv = {}
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
-- switch to ALTI
swipe(app, "left"); swipe(app, "left"); swipe(app, "left"); swipe(app, "left")
tick(app, #alt_seq + 2)
-- déniv+ should be positive (we climbed overall: 100→110)
-- we can check that alti_max was saved and min/max differ
assert(storekv.alti_max ~= nil and tonumber(storekv.alti_max) >= 108,
"alti_max should be at least 108, got " .. tostring(storekv.alti_max))
-- reset clears state
swipe(app, "down"); tick(app, 2)
assert(tonumber(storekv.alti_min) >= 99990, "alti_min must be reset to ~99999")
print(" deniv: sampling + reset OK max=" .. tostring(storekv.alti_max))
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_local = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792411200, -- 2026-10-19 12:00 UTC
datetime = { year = 2026, month = 10, day = 19, hour = 14, min = 0, sec = 0, wday = 1 } }
cfg.gps = function() return { lat = 48.85, lon = 2.35, sats = 8, alt_m = 35, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("14:00"), "status bar must show local time")
assert(pt_has_text("UTC+2"), "status bar must show the local UTC offset")
assert(pt_has_text("SUN"), "local sun section must not be labelled UTC")
assert(pt_has_text("Rise 08:27"), "local sunrise (Paris, UTC+2)")
assert(pt_has_text("Set 18:43"), "local sunset (Paris, UTC+2)")
assert(pt_has_text("Twl 07:49"), "local civil twilight (Paris, UTC+2)")
print(" astro local: local clock + rise/set/twilight OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_old_fw = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792411200 }
cfg.gps = function() return { lat = 48.85, lon = 2.35, sats = 8, alt_m = 35, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
wada.sys.datetime = nil -- simulate a host where datetime() is unavailable
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("12:00"), "UTC time must still be shown")
assert(pt_has_text("UTC"), "fallback must label the time UTC")
assert(pt_has_text("SUN (UTC)"), "fallback sun heading must say UTC")
assert(pt_has_text("Rise 06:27"), "UTC sunrise on old firmware")
assert(pt_has_text("Set 16:43"), "UTC sunset on old firmware")
assert(pt_has_text("Twl 05:49"), "UTC civil twilight on old firmware")
print(" astro old fw: UTC fallback OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_east = function()
-- UTC+13 and the local date is already tomorrow; the event hours must land
-- on that local day.
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792436400, -- 2026-10-19 19:00 UTC
datetime = { year = 2026, month = 10, day = 20, hour = 8, min = 0, sec = 0, wday = 2 } }
cfg.gps = function() return { lat = -36.85, lon = 174.76, sats = 8, alt_m = 20, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("08:00"), "local time must be shown")
assert(pt_has_text("UTC+13"), "UTC+13 offset must be shown")
assert(pt_has_text("Rise 06:30"), "Auckland sunrise in UTC+13")
assert(pt_has_text("Set 19:40"), "Auckland sunset in UTC+13")
assert(pt_has_text("Twl 05:59"), "Auckland civil twilight in UTC+13")
print(" astro east: UTC+13 / next-day offset OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_west = function()
-- UTC date is already tomorrow while the local date is still today; the
-- status bar must follow the local clock and the times must be local.
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792483200, -- 2026-10-20 08:00 UTC
datetime = { year = 2026, month = 10, day = 19, hour = 22, min = 0, sec = 0, wday = 1 } }
cfg.gps = function() return { lat = 21.30, lon = -157.86, sats = 7, alt_m = 10, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("22:00"), "local time must be shown")
assert(pt_has_text("UTC-10"), "UTC-10 offset must be shown")
assert(pt_has_text("Rise 06:33"), "Honolulu sunrise in UTC-10")
assert(pt_has_text("Set 17:58"), "Honolulu sunset in UTC-10")
assert(pt_has_text("Twl 06:07"), "Honolulu civil twilight in UTC-10")
print(" astro west: UTC-10 / previous-day offset OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_half_hour = function()
-- India is UTC+5:30; the status bar must not round it to a whole hour.
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792411200,
datetime = { year = 2026, month = 10, day = 19, hour = 17, min = 30, sec = 0, wday = 1 } }
cfg.gps = function() return { lat = 28.61, lon = 77.21, sats = 8, alt_m = 216, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("17:30"), "local time must be shown")
assert(pt_has_text("UTC+5:30"), "half-hour offset must be shown exactly")
print(" astro half-hour: UTC+5:30 OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_astro_tz_change = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true },
epoch = 1792411200,
datetime = { year = 2026, month = 10, day = 19, hour = 14, min = 0, sec = 0, wday = 1 } }
cfg.gps = function() return { lat = 48.85, lon = 2.35, sats = 8, alt_m = 35, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("Rise 08:27"), "UTC+2 sunrise")
-- The sun times are cached in UTC; the next draw must re-read the offset.
cfg.datetime = { year = 2026, month = 10, day = 19, hour = 13, min = 0, sec = 0, wday = 1 }
drawlog.text = {}
tick(app, 1)
assert(pt_has_text("13:00"), "clock must follow the new zone")
assert(pt_has_text("UTC+1"), "offset must follow the new zone")
assert(pt_has_text("Rise 07:27"), "UTC+1 sunrise after the zone change")
assert(pt_has_text("Set 17:43"), "UTC+1 sunset after the zone change")
assert(pt_has_text("Twl 06:49"), "UTC+1 civil twilight after the zone change")
assert(not pt_has_text("Rise 08:27"), "the stale UTC+2 sunrise must be gone")
print(" astro tz change: offset re-read at draw time OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.protreck_no_clock = function()
cfg = { w = 320, h = 240, caps = { touch = true, keyboard = false, sdk_ext = true } }
cfg.gps = function() return { lat = 48.85, lon = 2.35, sats = 8, alt_m = 35, speed_kmh = 0, course = 0 } end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
tick(app, 1)
assert(pt_has_text("--:--"), "status bar must show no clock")
assert(pt_has_text("No clock"), "ASTRO must distinguish no clock from no GPS")
assert(not pt_has_text("No GPS"), "GPS is present, so it must not say No GPS")
print(" astro: no clock message OK")
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- ---- MapFetch scenarios -----------------------------------------------------
local function mapfetch_open(w, h, gps_lat, gps_lon, map_tiles)
cfg = {
w = w, h = h,
caps = { sdk_ext = true, keyboard = true, touch = true },
map_tiles = map_tiles or 0,
}
if gps_lat then cfg.gps = function() return { lat = gps_lat, lon = gps_lon, lat_e6 = 0, lon_e6 = 0 } end end
storekv = {}; wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, w, h))
return app
end
scenarios.mapfetch_tdeck = function()
local app = mapfetch_open(320, 240, 48.8566, 2.3522) -- T-Deck, GPS Paris
assert(widgets.labels >= 2, "need top+bottom labels")
assert(map_obj ~= nil, "map view must be created")
local found = false
for _, l in ipairs(labels) do if l.text:find("5km") then found = true end end
assert(found, "setup label should show default 5km radius")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.mapfetch_no_map = function()
cfg = { w = 320, h = 240, caps = { sdk_ext = false, keyboard = false, touch = true } }
storekv = {}; wada = build_wada()
local app = load_app()
guarded(BUDGET, app.on_open, 320, 240)
assert(map_obj == nil, "no map view on non-ext board")
local found = false
for _, l in ipairs(labels) do if l.text:find("No map") or l.text:find("not available") then found = true end end
assert(found, "should show 'no map support' label when caps.map is false")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.mapfetch_no_gps = function()
local app = mapfetch_open(320, 240, nil, nil) -- no GPS
send(app, { type = "down", x = 160, y = 120 })
local found = false
for _, l in ipairs(labels) do if l.text:find("GPS") or l.text:find("fix") then found = true end end
assert(found, "should warn about missing GPS when starting")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.mapfetch_swipe = function()
local app = mapfetch_open(320, 240, 48.8566, 2.3522)
local lbl_before = labels[1] and labels[1].text or ""
swipe(app, "up") -- increase radius
local lbl_after = labels[1] and labels[1].text or ""
assert(lbl_before ~= lbl_after, "swipe up should change radius display")
swipe(app, "down") -- decrease radius back
swipe(app, "left") -- increase max zoom
swipe(app, "right")-- decrease max zoom back
assert(map_obj ~= nil, "map view still present after swipes")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.mapfetch_logic = function()
local app = mapfetch_open(320, 240, 48.8566, 2.3522, 1) -- map_tiles=1 (all cached)
send(app, { type = "down", x = 160, y = 120 }) -- start download
local prev = #map_centers
for _ = 1, 5 do tick(app, 1, 3000) end
assert(#map_centers > prev, "map:center should be called as tiles are visited")
for _, c in ipairs(map_centers) do
assert(type(c.lat) == "number" and type(c.lon) == "number", "tile center must be numeric")
assert(c.z >= 10 and c.z <= 15, "zoom must be in expected range")
end
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.mapfetch_done = function()
local app = mapfetch_open(320, 240, 48.8566, 2.3522, 1)
swipe(app, "down") -- radius: 5km → 2km
swipe(app, "right") -- zoom: z14 → z13
send(app, { type = "down", x = 160, y = 120 }) -- start
local done = false
for _ = 1, 200 do
tick(app, 1, 3000)
for _, l in ipairs(labels) do if l.text:find("Done") then done = true end end
if done then break end
end
assert(done, "app should reach Done state after all tiles visited")
if app.on_close then guarded(BUDGET, app.on_close) end
assert(not cfg.awake, "keep_awake must be off after done")
end
scenarios.mapfetch_cost = function()
local app = mapfetch_open(320, 240, 48.8566, 2.3522)
send(app, { type = "down", x = 160, y = 120 })
local ops0 = drawlog.ops
tick(app, 1, 3000)
local cost = drawlog.ops - ops0
assert(cost < 500, string.format("on_tick draw ops %d >= 500", cost))
if app.on_close then guarded(BUDGET, app.on_close) end
end
-- ---- MapFetch scenarios end --------------------------------------------------
-- ---- inputlen (web/scripts/apps/inputlen.lua): the SDK test app for wada.ui.input's
-- max_len and wada.sys.random's ranges; the web host build runs it against the real
-- LuaAppHost.cpp (web/scripts/lua-input.wada), this against the mock.
local function inputlen_open()
cfg = { w = 320, h = 240, caps = { sdk_ext = true, keyboard = true, touch = true } }
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
return app
end
local function label_with(prefix)
for _, l in ipairs(labels) do if l.text:sub(1, #prefix) == prefix then return l.text end end
return nil
end
local function button_named(name)
for _, b in ipairs(buttons) do if b.text == name then return b end end
error("no button " .. name)
end
scenarios.inputlen_random = function()
inputlen_open()
print(" " .. tostring(label_with("random")))
assert((label_with("random") or ""):find("random OK", 1, true), "random ranges")
assert(label_with("caps().input_len = true"), "caps().input_len")
button_named("Full").fn()
print(" " .. tostring(label_with("full range")))
assert((label_with("full range") or ""):find("100/100 integers, (-1,max) gave true", 1, true), "full range")
-- the argument checks: a fraction is not an integer (luaL_checkinteger)
assert(not pcall(wada.sys.random, 2.5), "random(2.5) must raise")
assert(wada.sys.random(3.0) >= 1, "random(3.0) is random(3)")
end
scenarios.inputlen_input = function()
inputlen_open()
button_named("Long").fn()
assert(input_prompt and input_prompt.max_len == 300, "Long asks for max_len 300")
answer_input(("abcdefghij"):rep(24) .. "0123456789")
print(" " .. tostring(label_with("max_len 300")))
assert(label_with("max_len 300 -> 250 chars, ends '456789'"), "250 typed, 250 back")
button_named("Default").fn()
assert(input_prompt and input_prompt.max_len == 63, "no max_len = 63")
answer_input(("x"):rep(99) .. "Y")
print(" " .. tostring(label_with("default")))
assert(label_with("default -> 63 chars"), "default caps at 63")
button_named("Long").fn()
answer_input(nil)
assert(label_with("max_len 300 -> nil"), "cancel gives nil")
-- one prompt at a time; max_len clamps
wada.ui.input("a", "", function() end, 99999)
assert(input_prompt.max_len == 2000, "max_len capped at 2000")
assert(not pcall(wada.ui.input, "b", "", function() end), "a second prompt must raise")
answer_input("ok")
wada.ui.input("c", "", function() end, 0)
assert(input_prompt.max_len == 1, "max_len at least 1")
answer_input(nil)
end
-- ---- Ebook (deploy/apps/ebook) ------------------------------------------------
-- The app reads an .epub through wada.fs, so the scenarios build real zip files
-- (stored entries, as scripts/ebook-prep.py writes them) in the mock folder.
local function zip_build(files, method)
local out, cd, off = {}, {}, 0
for _, f in ipairs(files) do
local name, data = f[1], f[2]
local m = f[3] or method or 0
local lh = string.pack("<I4I2I2I2I2I2I4I4I4I2I2", 0x04034B50, 20, 0, m, 0, 0x21, 0, #data, #data, #name, 3) ..
name .. "xyz"
out[#out + 1] = lh .. data
cd[#cd + 1] = string.pack("<I4I2I2I2I2I2I2I4I4I4I2I2I2I2I2I4I4", 0x02014B50, 20, 20, 0, m, 0, 0x21, 0,
#data, #data, #name, 0, 0, 0, 0, 0, off) .. name
off = off + #lh + #data
end
local cds = table.concat(cd)
return table.concat(out) .. cds ..
string.pack("<I4I2I2I2I2I4I4I2", 0x06054B50, 0, 0, #files, #files, #cds, off, 0)
end
local EB_WORDS = { "the", "river", "lantern", "quietly", "across", "winter", "harbour", "remembered", "a", "small",
"boat", "und", "caf\xC3\xA9", "voyage", "of", "long", "shadows", "and", "salt", "wind" }
-- n deterministic words; `esc` returns the markup form of the same text
local function eb_para(seed, n)
local plain, markup = {}, {}
local x = seed
for i = 1, n do
x = (x * 1103515245 + 12345) % 2147483648
local w = EB_WORDS[(x >> 8) % #EB_WORDS + 1]
if i % 11 == 0 then plain[#plain + 1] = "R&D"; markup[#markup + 1] = "R&amp;D"
elseif i % 13 == 0 then plain[#plain + 1] = "it\xE2\x80\x99s"; markup[#markup + 1] = "it&#8217;s"
elseif i % 17 == 0 then plain[#plain + 1] = "\xE2\x80\x94" .. w; markup[#markup + 1] = "&mdash;<em>" .. w .. "</em>"
else plain[#plain + 1] = w; markup[#markup + 1] = (i % 5 == 0) and ("<i>" .. w .. "</i>") or w end
end
return table.concat(plain, " "), table.concat(markup, " ")
end
-- Returns the file list and the whitespace-free text the whole book should show.
local function eb_book(opts)
opts = opts or {}
local big = opts.big or 70
local expect = {}
local function add(s) expect[#expect + 1] = s end
local c1 = { '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>No show</title>',
"<style>p { color: red }</style></head><body><!-- a comment > with a bracket --><h1>Chapter One</h1>" }
add("Chapter One")
for i = 1, 6 do
local p, m = eb_para(i, 40 + i * 9)
c1[#c1 + 1] = '<p class="x">' .. m .. "</p>\n"; add(p)
end
c1[#c1 + 1] = '<ul><li>first item</li><li>second<br/>item</li></ul><p><img src="a.png" alt=" A map&#8217;s\n legend "/></p>' ..
'<div><svg xmlns="http://www.w3.org/2000/svg"><title>hidden</title><image xlink:href="cover.png" width="1" height="1"/></svg></div>' ..
'<p>before<img src="b.png"/>after</p><p><img src="c.png" alt="' .. ("long "):rep(40) .. '"/></p>'
add("\xE2\x80\xA2 first item"); add("\xE2\x80\xA2 second\nitem"); add("<image:Amap\xE2\x80\x99slegend>")
add("<image>"); add("before"); add("<image>"); add("after")
add("<image:" .. ("long"):rep(20) .. "\xE2\x80\xA6>") -- 100 bytes of a longer alt, then an ellipsis
c1[#c1 + 1] = "</body></html>"
local c2 = { '<html><body><h2 id="t">Chapter Two</h2>' }
add("Chapter Two")
for i = 1, big do
local p, m = eb_para(100 + i, 60 + (i % 7) * 20)
c2[#c2 + 1] = "<p>" .. m .. "</p>\n"; add(p)
end
c2[#c2 + 1] = "</body></html>"
local c3 = '<html><body><h1>Last</h1><p>The end &amp; more.</p></body></html>'
add("Last"); add("The end & more.")
local files = {
{ "mimetype", "application/epub+zip" },
{ "META-INF/container.xml", '<container><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="x"/></rootfiles></container>' },
{ "OEBPS/ch1.xhtml", table.concat(c1) }, { "OEBPS/ch2.xhtml", table.concat(c2) }, { "OEBPS/ch3.xhtml", c3 },
{ "OEBPS/cover.png", ("\0"):rep(300) },
}
local manifest = '<item id="a" href="ch1.xhtml" media-type="application/xhtml+xml"/>' ..
'<item id="b" href="ch2.xhtml" media-type="application/xhtml+xml"/>' ..
'<item id="c" href="ch%33.xhtml" media-type="application/xhtml+xml"/>' ..
'<item id="cover" href="cover.png" media-type="image/png"/>'
local spine = '<itemref idref="a"/><itemref idref="b"/><itemref idref="c" linear="yes"/>'
if opts.ncx then
files[#files + 1] = { "OEBPS/toc.ncx", '<ncx><navMap><navPoint><navLabel><text>Alpha &amp; Omega</text></navLabel>' ..
'<content src="ch1.xhtml"/></navPoint><navPoint><navLabel><text>Bravo</text></navLabel>' ..
'<content src="ch2.xhtml#t"/></navPoint></navMap></ncx>' }
manifest = manifest .. '<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>'
spine = '<spine toc="ncx">' .. spine .. "</spine>"
else
files[#files + 1] = { "OEBPS/nav.xhtml", '<html><body><nav epub:type="toc"><ol>' ..
'<li><a href="ch1.xhtml">First <b>Part</b></a></li><li><a href="ch2.xhtml#t">Second</a></li></ol></nav>' ..
'<nav epub:type="landmarks"><a href="ch3.xhtml">Nope</a></nav></body></html>' }
manifest = manifest .. '<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml"/>'
spine = "<spine>" .. spine .. "</spine>"
end
files[#files + 1] = { "OEBPS/content.opf", '<package><metadata><dc:title>The Test Book</dc:title>' ..
"<dc:creator>A. Writer</dc:creator></metadata><manifest>" .. manifest .. "</manifest>" .. spine .. "</package>" }
return files, table.concat(expect, "\n"):gsub("%s+", "")
end
-- Word-wrapping text_lines (the firmware wraps at word breaks; the default mock only
-- estimates), still monotonic in the length of the text.
-- The real one is C; this one is Lua, so the cost probe subtracts the instructions
-- it spends (eb_cost.n counts every hook tick, eb_cost.mock the ones inside here).
local eb_cost = nil
local function eb_wrap(str, width, sz)
checkstr(str, "text_lines"); checkint(width, "text_lines width")
local c0 = eb_cost and eb_cost.n
local per = math.max(1, math.floor(width / (((sz or 14) + 4) * 0.48)))
local n = 0
str = tostring(str)
if #str > 0 and str:byte(-1) == 10 then str = str:sub(1, -2) end -- like the firmware, no line for a trailing newline
for part in (str .. "\n"):gmatch("(.-)\n") do
local lines, cur = 1, #part:match("^ *")
for word in part:gmatch("%S+") do
local wl = 0
for _ in word:gmatch("[%z\1-\127\194-\244]") do wl = wl + 1 end
if cur == 0 then cur = wl
elseif cur + 1 + wl <= per then cur = cur + 1 + wl
else lines, cur = lines + 1, wl end
while cur > per do lines, cur = lines + 1, cur - per end
end
n = n + lines
end
if c0 then eb_cost.mock = eb_cost.mock + (eb_cost.n - c0) end
return n
end
-- The firmware's storeGet reads the default from stack slot 2, which is the store
-- table once it has been pushed, so store.get(key) of a missing key returns that
-- TABLE rather than nil (beta_101). Reproduce it: the app must not trust the result.
local function eb_wada()
wada = build_wada()
wada.ui.text_lines = eb_wrap
local get = wada.store.get
wada.store.get = function(k, d)
local v = get(k, d)
if v == nil then return storekv end
return v
end
end
local function eb_start(w, h, files, caps, name)
cfg = { w = w, h = h, caps = caps or { touch = true, keyboard = true, sdk_ext = true } }
storekv = {}
eb_wada()
if files then fsfiles[name or "book.epub"] = files end
end
local function eb_page()
for _, l in ipairs(labels) do if l.x == 4 and l.y == 4 and l.w then return l.text end end
return nil
end
local function eb_status()
for _, l in ipairs(labels) do if l.text:match("^%d+/%d+ %d+%%$") then return l.text end end
end
-- Tick until a page (not a progress message) is showing.
local function eb_wait(app)
for _ = 1, 3000 do
local t = eb_page()
if t and t ~= "Opening..." and t ~= "Loading..." then return t end
tick(app, 1, 40)
end
error("the page never appeared (last: " .. tostring(eb_page()) .. ")")
end
local function eb_row(label)
for _, l in ipairs(lists) do
for _, r in ipairs(l.rows) do if r.text:find(label, 1, true) then return r end end
end
end
local function eb_open_from_library(app, label)
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
local row = assert(eb_row(label), "no library row " .. label .. ": " .. label_dump())
assert(guarded(BUDGET, row.fn))
return eb_wait(app)
end
local function eb_next(app) key(app, "right"); tick(app, 1, 40) end
scenarios.ebook_book = function()
local files, expect = eb_book()
eb_start(320, 240, zip_build(files))
local app = load_app()
eb_open_from_library(app, "book")
assert(label_dump():find("1/3", 1, true), "status shows chapter 1 of 3: " .. label_dump())
-- forward through the whole book, collecting every page
local pages, seen = { eb_page() }, 1
while true do
local before = #toasts
key(app, "right")
local t = eb_wait(app)
if #toasts > before then assert(toasts[#toasts] == "End of book"); break end
pages[#pages + 1] = t
assert(#t > 0 and valid_utf8(t), "page " .. #pages .. " is empty or cuts a character")
assert(#pages < 2000, "never reaches the end")
end
assert(eb_status():match("^3/3 100%%$"), "100% at the end: " .. tostring(eb_status()))
local got = table.concat(pages, "\n"):gsub("%s+", "")
assert(got == expect, "text lost or repeated across pages/sections\n got " .. #got .. " bytes, expected " .. #expect)
print(string.format(" %d pages, %d bytes of text, all present once", #pages, #got))
assert(#pages > 20, "the big chapter should span many pages")
-- and backward: the same pages in reverse, across section and chapter edges
for i = #pages - 1, 1, -1 do
key(app, "left")
local t = eb_wait(app)
assert(t == pages[i], "page " .. i .. " differs going backward:\n" .. t .. "\n--- vs ---\n" .. pages[i])
end
local before = #toasts
key(app, "left")
assert(toasts[#toasts] == "Start of book" and #toasts == before + 1, "start of book toast")
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.ebook_toc_and_menu = function()
for _, ncx in ipairs({ false, true }) do
local files = eb_book({ ncx = ncx })
eb_start(320, 240, zip_build(files))
local app = load_app()
eb_open_from_library(app, "book")
key(app, "enter") -- menu
assert(label_dump():find("The Test Book", 1, true) and label_dump():find("A. Writer", 1, true), "title/author: " .. label_dump())
assert(guarded(BUDGET, eb_row("Chapters").fn))
assert(eb_row(ncx and "1. Alpha & Omega" or "1. First Part"), "chapter 1 title: " .. label_dump())
assert(eb_row(ncx and "2. Bravo" or "2. Second"), "chapter 2 title (fragment stripped)")
assert(eb_row("3. Section 3"), "an untitled chapter gets a number")
assert(guarded(BUDGET, eb_row("2. ").fn))
local t = eb_wait(app)
assert(t:find("Chapter Two", 1, true), "chapter 2 opens at its start: " .. t:sub(1, 60))
assert(eb_status():match("^2/3"), "status after jump: " .. eb_status())
-- the menu's size row cycles 12 -> 14 -> 16 and the page is repaginated at the same spot
key(app, "m")
local row = eb_row("Text size: 14")
assert(row, "default size 14")
assert(guarded(BUDGET, row.fn))
assert(eb_row("Text size: 16") and storekv.sz == 16, "size row relabelled and stored")
assert(guarded(BUDGET, eb_row("Continue").fn))
local t2 = eb_wait(app)
assert(t2 ~= nil and #t2 > 0, "repaginated page shows")
app.on_close()
assert(storekv.pos and storekv.pos:find("book.epub|", 1, true), "position stored: " .. tostring(storekv.pos))
end
end
scenarios.ebook_resume = function()
local files = eb_book()
eb_start(320, 240, zip_build(files))
local app = load_app()
eb_open_from_library(app, "book")
for _ = 1, 30 do eb_next(app); eb_wait(app) end -- into the big chapter, past a section edge
local where, st = eb_page(), eb_status()
app.on_close()
-- a fresh run of the app picks the book up where it was left
eb_wada()
fsfiles["book.epub"] = zip_build(files)
app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(eb_wait(app) == where, "resumes on the same page")
assert(eb_status() == st, "and the same status")
app.on_close()
end
scenarios.ebook_input = function()
local files = eb_book()
-- touch only (a T-Deck Max / phone-like board): taps on the thirds, swipes
eb_start(240, 320, zip_build(files), { touch = true, keyboard = false, sdk_ext = true, epaper = true })
local app = load_app()
eb_open_from_library(app, "book")
local p1 = eb_page()
tap(app, 200, 100); local p2 = eb_wait(app)
assert(p2 ~= p1, "tap on the right third: next page")
tap(app, 20, 100); assert(eb_wait(app) == p1, "tap on the left third: previous page")
swipe(app, "left"); assert(eb_wait(app) == p2, "swipe left (finger moved left): next")
swipe(app, "right"); assert(eb_wait(app) == p1, "swipe right: previous")
swipe(app, "down"); assert(eb_wait(app) == p2, "trackball down: next")
swipe(app, "up"); assert(eb_wait(app) == p1, "trackball up: previous")
drag(app, 100, 100, 20, 100, "left"); assert(eb_wait(app) == p2, "a drag turns once, and its release is not a tap")
tap(app, 200, 315); assert(eb_wait(app) == p2, "a tap in the button row is ignored")
-- the footer buttons (what a jog wheel or encoder activates): next is created first
assert(buttons[1].text == "\xEF\x81\x94", "the next button gets default focus")
assert(guarded(BUDGET, buttons[1].fn)); assert(eb_wait(app) ~= p2, "next button")
assert(guarded(BUDGET, buttons[2].fn)); assert(eb_wait(app) == p2, "prev button")
tap(app, 120, 100)
assert(eb_row("Continue"), "centre tap opens the menu")
app.on_close()
end
-- Pictures: placeholders everywhere, and the three Images modes when the firmware has
-- wada.ui.image (mocked here; it is not released yet).
local function eb_pic_book()
local pic = "\137PNG-pretend-image-bytes"
local ch = '<html><body><h1>One</h1><p>Intro text.</p><p><img src="pic.png" alt="A diagram"/></p>' ..
'<p>Between words.</p><p><img src="missing.png" alt="Gone"/></p><p>The end.</p><p>Lead<img src="pic.png" alt="Inline"/>trail</p></body></html>'
local files = {
{ "mimetype", "application/epub+zip" },
{ "META-INF/container.xml", '<container><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="x"/></rootfiles></container>' },
{ "OEBPS/ch1.xhtml", ch }, { "OEBPS/pic.png", pic },
{ "OEBPS/content.opf", '<package><metadata><dc:title>Pics</dc:title></metadata><manifest>' ..
'<item id="a" href="ch1.xhtml" media-type="application/xhtml+xml"/></manifest>' ..
'<spine><itemref idref="a"/></spine></package>' },
}
return zip_build(files), pic
end
local function eb_images_start(with_api, result)
local zipped, pic = eb_pic_book()
eb_start(320, 240, zipped)
local calls = { n = 0, removed = 0 }
if with_api then
wada.ui.image = function(file, off, len, x, y, w, h)
for _, v in ipairs({ off, len, x, y, w, h }) do checkint(v, "ui.image arg") end
calls.n = calls.n + 1
calls[#calls + 1] = { file = file, off = off, len = len, x = x, y = y, w = w, h = h }
if result == "fail" then return nil, "unsupported" end
return { remove = function() calls.removed = calls.removed + 1 end }
end
end
local app = load_app()
eb_open_from_library(app, "book")
return app, calls, zipped, pic
end
local function eb_set_images(app, want) -- cycle the menu row until it reads `want`
key(app, "m")
for _ = 1, 3 do
local row = assert(eb_row("Images:"), "Images row: " .. label_dump())
if row.text:find(want, 1, true) then break end
assert(guarded(BUDGET, row.fn))
end
assert(eb_row("Images: " .. want), "Images row reads " .. want)
assert(guarded(BUDGET, eb_row("Continue").fn))
return eb_wait(app)
end
scenarios.ebook_images = function()
-- no wada.ui.image (every firmware today): a placeholder line, no menu row, taps on it do nothing
local app, calls = eb_images_start(false)
local p = eb_page()
assert(p:find("<image: A diagram>", 1, true) and p:find("<image: Gone>", 1, true), "placeholders with alt text: " .. p)
-- a placeholder sits on a line of its own with a blank line above and below, even mid-sentence
local pad = p:match("\n\n( *)<image: A diagram>\n\n")
local want = (320 - 2 * 4 - wada.ui.text_w("<image: A diagram>", 14)) // 2 // wada.ui.text_w(" ", 14)
assert(pad and #pad == want and want > 0, "centred with blank lines around it: " .. tostring(pad and #pad) .. " spaces, wanted " .. want)
key(app, "right")
local p2 = eb_page()
assert(p2:find("Lead\n\n *<image: Inline>\n\n") and p2:find("\n\n *trail"), "an image inside a paragraph gets its own line: " .. p2)
key(app, "left"); assert(eb_page() == p, "back on the first page")
tap(app, 160, 4 + 3 * 18 + 1)
assert(not eb_row("Continue") and eb_page() == p, "a tap on a placeholder does nothing")
key(app, "i"); assert(eb_page() == p and calls.n == 0, "the i key does nothing")
tap(app, 160, 5); assert(eb_row("Continue"), "a centre tap elsewhere still opens the menu")
assert(not eb_row("Images"), "no Images row without wada.ui.image")
app.on_close()
-- with it: the default is "never" (the placeholder only), then "always" and "on tap"
app, calls = eb_images_start(true)
p = eb_page()
assert(p:find("<image: A diagram>", 1, true), "default shows placeholders")
tap(app, 160, 4 + 3 * 18 + 1)
assert(not eb_row("Continue") and calls.n == 0, "never: a tap on a placeholder does nothing")
key(app, "m"); assert(eb_row("Images: never"), "default mode: " .. label_dump())
assert(not eb_row("View image"), "no View image row in never mode")
-- always: the picture gets a page of its own, the others stay text
eb_set_images(app, "always")
assert(storekv.img == "always", "mode stored")
local seen, texts = nil, {}
for _ = 1, 8 do
local pg = eb_page()
if calls.n > 0 and not seen then seen = #texts + 1 end
texts[#texts + 1] = pg
local before = #toasts
key(app, "right"); tick(app, 3, 40)
if #toasts > before then break end
end
assert(seen, "an image page was drawn")
local c = calls[1]
assert(c.file == "book.epub" and c.x == 4 and c.y == 4 and c.w == 312 and c.h == 194, "image box: " .. c.x .. "," .. c.y .. " " .. c.w .. "x" .. c.h)
local _, zpic = eb_pic_book()
local zipped = fsfiles["book.epub"]
assert(zipped:sub(c.off + 1, c.off + c.len) == zpic, "offset and length address the picture bytes in the zip")
assert(texts[seen] == "", "an image page has no text")
assert(texts[seen - 1] and not texts[seen - 1]:find("image", 1, true), "text before it is on its own page")
assert(texts[seen + 1] and texts[seen + 1]:find("Between words.", 1, true), "text after it follows")
assert(calls.removed >= 1, "the picture is removed when the page turns")
assert(texts[seen + 1]:find("<image: Gone>", 1, true), "a picture the book lacks stays a placeholder")
-- a firmware that refuses the picture: the placeholder text shows instead
app, calls = eb_images_start(true, "fail")
eb_set_images(app, "always")
local shown = false
for _ = 1, 6 do
if eb_page():find("<image: A diagram>", 1, true) then shown = true; break end
key(app, "right"); tick(app, 3, 40)
end
assert(shown and calls.n >= 1, "a refused picture falls back to its placeholder")
-- on tap: the placeholder opens the picture fit to the screen; any tap or key comes back
app, calls = eb_images_start(true)
p = eb_set_images(app, "on tap")
local before = #calls
tap(app, 160, 4 + 3 * 18 + 1)
assert(#calls == before + 1, "a tap on the placeholder shows the picture")
c = calls[#calls]
assert(c.x == 0 and c.y == 0 and c.w == 320 and c.h == 240, "fit to the whole screen")
tap(app, 160, 100); assert(eb_wait(app) == p, "a tap goes back to the same page")
tap(app, 30, 4 + 3 * 18 + 1); assert(#calls == before + 1 and eb_page() ~= nil and not eb_row("Continue"), "the left third turns pages, it does not open the picture")
eb_set_images(app, "on tap")
key(app, "i"); assert(#calls == before + 2, "the i key shows the picture")
key(app, "enter"); assert(eb_wait(app) == p, "any key goes back")
key(app, "m")
assert(guarded(BUDGET, assert(eb_row("View image")).fn)); assert(#calls == before + 3, "the menu row shows the picture")
tap(app, 160, 100); eb_wait(app)
-- a picture the book does not contain: a note, and back on the page
local tb = #toasts
tap(app, 160, 4 + 7 * 18 + 1)
assert(toasts[#toasts] == "Cannot show this image" and #toasts == tb + 1, "a missing picture says so")
assert(eb_wait(app) == p, "and the page is back")
app.on_close()
assert(storekv.img == "tap", "mode stored: " .. tostring(storekv.img))
end
scenarios.ebook_errors = function()
-- empty folder: a hint, and a README that creates the folder on first run
eb_start(320, 240, nil)
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(label_dump():find("No books", 1, true), "empty library hint: " .. label_dump())
assert(fsfiles["README.txt"] and fsfiles["README.txt"]:find("ebook%-prep"), "README written")
-- a compressed book says how to fix it
local files = eb_book()
eb_start(320, 240, zip_build(files, 8))
app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(guarded(BUDGET, eb_row("book").fn))
for _ = 1, 200 do tick(app, 1, 40) end
assert(label_dump():find("ebook-prep.py", 1, true), "compressed message: " .. label_dump())
-- not a zip at all, a truncated zip, and a name over 32 characters (unreadable)
for _, junk in ipairs({ "this is not a zip file, just text that is long enough", zip_build(files):sub(1, 400) }) do
eb_start(320, 240, junk)
app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(guarded(BUDGET, eb_row("book").fn))
for _ = 1, 200 do tick(app, 1, 40) end
assert(label_dump():find("Cannot open this book", 1, true), "bad file: " .. label_dump())
assert(eb_row("") == nil and #buttons == 1, "an error screen offers a way back")
assert(guarded(BUDGET, buttons[1].fn))
assert(label_dump():find("Books", 1, true), "back to the library")
end
-- old firmware: no ui.clear
eb_start(320, 240, zip_build(files))
wada.ui.clear = nil
app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(label_dump():find("newer firmware", 1, true), "gated: " .. label_dump())
end
scenarios.ebook_cost = function()
-- a tag-dense chapter (one tag per few characters) and a long unbroken text run
local dense = { "<html><body>" }
for i = 1, 600 do dense[#dense + 1] = "<p><b>a</b><i>b</i><span>c</span> word" .. i .. "<br/>x</p>" end
dense[#dense + 1] = "<p>" .. ("lorem ipsum "):rep(900) .. "</p><p>" .. ("z"):rep(7000) .. "</p></body></html>"
local files = eb_book()
files[3] = { "OEBPS/ch1.xhtml", table.concat(dense) }
eb_start(320, 240, zip_build(files))
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(guarded(BUDGET, eb_row("book").fn))
local worst = 0
local function probe(fn, ...)
eb_cost = { n = 0, mock = 0 }
debug.sethook(function() eb_cost.n = eb_cost.n + 100; if eb_cost.n > 200 * BUDGET then error("callback never returns", 0) end end, "", 100)
local ok, err = pcall(fn, ...)
debug.sethook()
local n = eb_cost.n - eb_cost.mock
eb_cost = nil
assert(ok, err)
if n > worst then worst = n end
end
local steps = 0
for _ = 1, 8000 do
clock_ms = clock_ms + 40
probe(app.on_tick, 40)
steps = steps + 1
local t = eb_page()
if t and t ~= "Opening..." and t ~= "Loading..." then
probe(app.on_input, { type = "key", key = "right" })
if #toasts > 0 then break end
end
end
assert(#toasts > 0 and toasts[#toasts] == "End of book", "never reached the end of the dense book")
print(string.format(" worst callback ~%d instructions (budget %d), %d ticks", worst, BUDGET, steps))
assert(worst < BUDGET / 4, "callback too expensive: " .. worst .. " > " .. BUDGET / 4)
app.on_close()
end
-- True (and says so) when this app has no scenarios of its own. Returning a
-- truthy value makes the dispatch below pick an EMPTY list rather than running
-- someone else's tests against it.
local function UNTESTED_APPS()
io.write("== no scenarios for this app; nothing was tested\n")
return true
end
local order = APP_PATH:find("/sdscan/", 1, true)
and { "sdscan_real_m9", "sdscan_many", "sdscan_m9", "sdscan_layouts", "sdscan_paging_and_full", "sdscan_old_firmware", "sdscan_no_access",
"sdscan_no_card", "sdscan_remove_fails", "sdscan_portrait", "sdscan_cost" }
-- The start/stop relabel is 1.3 behaviour; 1.1 and 1.2 keep the fixed label, so
-- they run the utf8 scenario alone as before.
or APP_PATH:find("/wardrive/1.3/", 1, true)
and { "wardrive_utf8", "wardrive_start_stop", "wardrive_start_stop_old_firmware" }
or APP_PATH:find("/wardrive/", 1, true)
and { "wardrive_utf8" }
or APP_PATH:find("/tetris/", 1, true)
and { "tetris_tdeck", "tetris_v4", "tetris_keyboard", "tetris_logic", "tetris_hiscore", "tetris_cost" }
or APP_PATH:find("/ebook/", 1, true)
and { "ebook_book", "ebook_toc_and_menu", "ebook_resume", "ebook_input", "ebook_images", "ebook_errors", "ebook_cost" }
or APP_PATH:find("/breakout/", 1, true)
and { "breakout_tdeck", "breakout_v4", "breakout_keyboard", "breakout_logic", "breakout_hiscore", "breakout_cost" }
or APP_PATH:find("/tripodometer/", 1, true)
and { "trip_v4", "trip_tdeck", "trip_moving", "trip_persist", "trip_reset", "trip_cost" }
-- The ASTRO local-time scenarios target 1.3+; 1.2 is the published version
-- and keeps its original list.
or APP_PATH:find("/protreck/1.2/", 1, true)
and { "protreck_tdeck", "protreck_v4", "protreck_no_gps", "protreck_chrono", "protreck_timer", "protreck_alti", "protreck_alarm", "protreck_compass", "protreck_deniv", "protreck_cost" }
or APP_PATH:find("/protreck/", 1, true)
and { "protreck_tdeck", "protreck_v4", "protreck_no_gps", "protreck_chrono", "protreck_timer", "protreck_alti",
"protreck_alarm", "protreck_compass", "protreck_deniv", "protreck_astro_local", "protreck_astro_old_fw",
"protreck_astro_east", "protreck_astro_west", "protreck_astro_half_hour", "protreck_astro_tz_change",
"protreck_no_clock", "protreck_cost" }
or APP_PATH:find("/ping/", 1, true)
and { "ping_tdeck", "ping_v4", "ping_no_contacts", "ping_round_trip", "ping_auto_reply", "ping_cost" }
or APP_PATH:find("/inputlen.lua", 1, true)
and { "inputlen_random", "inputlen_input" }
or APP_PATH:find("/mapfetch/", 1, true)
and { "mapfetch_tdeck", "mapfetch_no_map", "mapfetch_no_gps", "mapfetch_swipe", "mapfetch_logic", "mapfetch_done", "mapfetch_cost" }
-- The compass list below is gpscompass's own, NOT a default. An app with no
-- branch used to fall through to it and "fail" on tests written for a
-- different app -- six of the store apps do, so a full run came back red and
-- a real regression would not have stood out. Name them: they are untested,
-- which is worth saying out loud, but it is not a failure of the app.
or ((not APP_PATH:find("/gpscompass/", 1, true)) and UNTESTED_APPS() and {})
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" }
for _, name in ipairs(order) do
if SCENARIO == "all" or SCENARIO == name then
print("== " .. name)
reset_world()
local ok, err = pcall(scenarios[name])
if not ok then fails = fails + 1; print(" SCENARIO FAILED: " .. tostring(err)) end
end
end
print(fails == 0 and "ALL OK" or ("FAILURES: " .. fails))
if fails ~= 0 then error("harness failures") end