Files
slate32/test/fake_device.lua
T
evan 1aa3a59ca9 feat: give /.lua/main.lua the whole screen and the app contract
The firmware knew where apps, data and modules lived, called four globals,
and painted a status bar into a strip it clipped every app out of. None of
that was its business, and the viewport made the bar something an app could
neither compose with nor replace.

It now loads one file. main.lua mounts the route inside its own node tree,
so the bar is a sibling of the app rather than chrome painted over it: one
layout, one hit test, no inset arithmetic and no invalidation flags on the
C++ side. Apps and main.lua are tables -- they share a lua_State, so globals
would collide -- and a screen changes by rebuilding from node().
2026-08-04 21:15:21 -04:00

463 lines
12 KiB
Lua

-- Stand-in for the firmware bindings, so host tests exercise the real ui.lua.
--
-- One definition of the binding surface: when a binding is added or renamed, this is
-- the file that changes. Tests mutate the returned table to steer the device, and may
-- overwrite any stub function outright for a single assertion.
local device = {
-- Text metrics chosen to match the firmware's default GLCD font, since layout
-- assertions are written in real pixels.
fontHeight = 8,
charWidth = 6,
now = 0,
rotation = 0,
theme = "light",
clockSynced = false,
tickInterval = 0,
appName = "test",
textSize = 1,
canGoBack = false,
timezone = "UTC0",
raw = nil, -- pending raw touch reading, {x, y} or nil
networks = {}, -- what wifi.scan() returns
bleDevices = {}, -- what ble.scan() returns
status = { state = "disconnected", ssid = "", ip = "", rssi = 0 },
painted = {}, -- every drawText call, in order
calibration = nil, -- last settings.setCalibration()
freeHeap = 200000,
totalHeap = 320000,
largestBlock = 100000,
connected = nil, -- last wifi.connect()
backed = false,
launched = nil, -- last sys.launch()/replace(), {path, arg, replace}
saveFails = false, -- make every persisting call report failure
}
local function saved()
return not device.saveFails
end
function device.install()
device.painted = {}
device.lines = {}
device.roundRects = {}
device.files = device.files or {}
device.timers = {}
device.nextTimer = 1
-- The widget tree lives in the firmware. Geometry -- layout, hit testing, style
-- inheritance -- is asserted in test/ui_layout_test.cpp against the same C++ the panel
-- runs, so this fake deliberately implements none of it. What it does keep is the
-- structure an app builds, which is what app logic is actually about: a test taps the
-- control labelled "Rotation" rather than the pixel it happened to land on.
local nodes = {}
device.nodes = nodes
device.drawn = 0
device.invalidated = {}
device.tapTarget = nil
node = setmetatable({
reset = function()
for index in ipairs(nodes) do
nodes[index] = nil
end
end,
create = function(parent, spec)
nodes[#nodes + 1] = {
parent = parent,
label = spec.label,
kind = spec.type,
interactive = spec.interactive or spec.capture,
pressed = false,
}
return #nodes
end,
setPressed = function(id, pressed)
nodes[id].pressed = pressed and true or false
end,
isPressed = function(id)
return nodes[id].pressed
end,
attach = function(parent, child)
nodes[child].parent = parent
end,
getParent = function(id)
return nodes[id].parent
end,
getLabel = function(id)
return nodes[id].label
end,
setLabel = function(id, text)
nodes[id].label = text
end,
getCount = function()
return #nodes
end,
layout = function()
return true
end,
-- Any point is inside, because which node a point covers is geometry and geometry is
-- not this file's business. Tests choose the target with device.tap().
getRect = function()
return 0, 0, 10000, 10000
end,
hit = function()
return device.tapTarget
end,
draw = function()
device.drawn = device.drawn + 1
end,
-- Which nodes were asked to repaint, so a test can assert that a tick touched the
-- clock and nothing else.
invalidate = function(id)
device.invalidated[#device.invalidated + 1] = id
end,
}, {
__index = function()
return function() end
end,
})
gui = {
-- Font roles are scales of the one built-in font, matching the firmware's GuiProvider.
FONT_SMALL = 1,
FONT_UI = 2,
FONT_BODY = 2,
FONT_LARGE = 3,
STYLE_NORMAL = 0,
STYLE_BOLD = 0,
color = function(r, g, b)
return r * 65536 + g * 256 + b
end,
-- Rotation swaps the frame, exactly as TFT_eSPI reports it.
getWidth = function()
return device.rotation % 180 == 0 and 320 or 480
end,
getHeight = function()
return device.rotation % 180 == 0 and 480 or 320
end,
clear = function() end,
fillRect = function() end,
drawRect = function() end,
fillCircle = function() end,
drawLine = function(...)
device.lines[#device.lines + 1] = { ... }
end,
roundRect = function(...)
device.roundRects[#device.roundRects + 1] = { ... }
end,
drawPixel = function() end,
drawCircle = function() end,
fillPolygon = function() end,
drawBmp = function()
return nil, "no decoder"
end,
drawText = function(font, x, y, label)
device.painted[#device.painted + 1] = { label = label, x = x, y = y, font = font }
end,
getFontHeight = function(font)
return device.fontHeight * (font or 1)
end,
getTextWidth = function(font, text)
return #text * device.charWidth * (font or 1)
end,
setRotation = function(degrees)
device.rotation = degrees
end,
getRotation = function()
return device.rotation
end,
}
sys = {
getMillis = function()
return device.now
end,
back = function()
device.backed = true
end,
canGoBack = function()
return device.canGoBack == true
end,
getAppID = function()
return device.appName
end,
getAppTitle = function()
return device.appName
end,
setAppTitle = function(name)
device.appName = name
end,
getAppDataPath = function()
return "/.lua/data/" .. device.appName
end,
getAPIVersion = function()
return 1
end,
hasFeature = function(name)
return name == "touch"
end,
getMemory = function()
return device.freeHeap, device.totalHeap, device.largestBlock
end,
isClockSynced = function()
return device.clockSynced
end,
launch = function(path, arg)
device.launched = { path = path, arg = arg, replace = false }
end,
replace = function(path, arg)
device.launched = { path = path, arg = arg, replace = true }
end,
}
settings = {
getRotation = function()
return device.rotation
end,
setRotation = function(degrees)
if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then
return false
end
device.rotation = degrees
return saved()
end,
setCalibration = function(...)
device.calibration = { ... }
return saved()
end,
getTimezone = function()
return device.timezone
end,
setTimezone = function(tz)
device.timezone = tz
return saved()
end,
getTheme = function()
return device.theme
end,
setTheme = function(name)
device.theme = name
return saved()
end,
}
input = {
isTouched = function()
return device.raw ~= nil
end,
getTouch = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
getRawTouch = function()
if not device.raw then
return nil
end
return device.raw[1], device.raw[2]
end,
}
fs = {
MAX_READ_BYTES = 65536,
readFile = function(path)
return device.files[path]
end,
writeFile = function(path, value)
if device.saveFails then
return nil, "read only"
end
device.files[path] = value
return true
end,
exists = function(path)
return device.files[path] ~= nil
end,
listFiles = function()
return {}
end,
listDirs = function()
return {}
end,
fileSize = function(path)
local value = device.files[path]
return value and #value or nil, value and nil or "missing"
end,
}
-- Timers fire only when a test asks, so a callback's effect is asserted where it happens.
timer = {
after = function(ms, callback)
return device.addTimer(ms, callback, false)
end,
every = function(ms, callback)
return device.addTimer(ms, callback, true)
end,
cancel = function(id)
local found = device.timers[id] ~= nil
device.timers[id] = nil
return found
end,
}
wifi = {
scan = function()
return device.networks
end,
getStatus = function()
return device.status
end,
connect = function(ssid, password)
device.wifiReconnect = ssid == nil
device.connected = { ssid, password }
if ssid then
device.status.ssid = ssid
end
device.status.state = "connecting"
return saved()
end,
disconnect = function()
device.status.state = "disconnected"
end,
forget = function()
device.connected = nil
device.status = { state = "disconnected", ssid = "", ip = "", rssi = 0 }
return saved()
end,
}
ble = {
init = function(name)
device.bleInitialized, device.bleName = true, name
return true
end,
deinit = function()
device.bleInitialized = false
device.bleName, device.bleConnected, device.bleAdvertising = nil, nil, nil
end,
isInitialized = function()
return device.bleInitialized == true
end,
scan = function()
return device.bleDevices
end,
connect = function(address)
device.bleConnected = address
return true
end,
disconnect = function()
device.bleConnected = nil
end,
isConnected = function()
return device.bleConnected ~= nil
end,
read = function()
return "test"
end,
write = function(service, characteristic, value)
device.bleWrite = { service, characteristic, value }
return true
end,
startAdvertising = function(name)
device.bleAdvertising = name
return true
end,
stopAdvertising = function()
device.bleAdvertising = nil
end,
}
log = { debug = function() end, info = function() end, error = function() end }
return device
end
-- The first node whose text starts with `prefix`, whether or not anything can press it.
function device.labelled(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then
return id
end
end
end
-- The nearest ancestor of that text that can be pressed: a card's label is a child of the
-- button, and it is the button a finger lands on.
function device.find(prefix)
for id, entry in ipairs(device.nodes) do
if entry.label and entry.label:sub(1, #prefix) == prefix then
local target = id
while target and not device.nodes[target].interactive do
target = device.nodes[target].parent
end
if target then
return target
end
end
end
end
-- Presses the control labelled `prefix`, the way a test means it: by what it says, not by
-- where it landed.
-- Registers a timer and hands back its id, mirroring timer.after/every.
function device.addTimer(intervalMs, callback, repeating)
local id = device.nextTimer
device.nextTimer = id + 1
device.timers[id] = { callback = callback, intervalMs = intervalMs, repeating = repeating }
return id
end
-- Fires every registered timer once, which is what a test means by "time passed".
function device.fireTimers()
for id, entry in pairs(device.timers) do
if not entry.repeating then
device.timers[id] = nil
end
entry.callback()
end
end
function device.tap(prefix)
local target = device.find(prefix)
if not target then
error("no control labelled '" .. prefix .. "'", 2)
end
device.press(target, 0, 0)
end
-- Presses a node at a point, for the widgets that have no label to aim at. getRect()
-- answers the same box here as it does inside the widget, so a custom painter's own
-- geometry decides what was hit, exactly as it does on the panel.
function device.press(id, x, y)
local ui = require "ui"
device.tapTarget = id
ui.down(x, y)
ui.up(x, y)
device.tapTarget = nil
end
-- Loads and mounts an app the way /.lua/main.lua does on the panel, minus the chrome:
-- the bar is a node like any other and is asserted on its own.
function device.start(path, arg)
local ui = require "ui"
local app = dofile(path)
device.app = app
if app.init then
app.init(arg)
end
ui.mount(app.node)
return app
end
function device.findKind(kind)
for id, entry in ipairs(device.nodes) do
if entry.kind == kind then
return id
end
end
end
return device