Files
slate32/test/fake_device.lua
T
evan b3b5a9b2a3 feat(settings): persist the theme through the settings binding
Keeps one writer for saved state: ui.setTheme() writes through
settings.setTheme() and then reloads the palette C cannot see.
2026-08-04 20:44:13 -04:00

436 lines
11 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,
fullscreen = 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.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,
}
return #nodes
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,
}, {
__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,
setFullscreen = function(on)
device.fullscreen = on and true or false
end,
}
sys = {
getMillis = function()
return device.now
end,
back = function()
device.backed = 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)
device.tapTarget = id
on_touch_down(x, y)
on_touch_up(x, y)
device.tapTarget = nil
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