-- 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, } 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, -- ui.lua persists the theme through fs, so the files table is real storage. 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