diff --git a/template/src/lib/bindings.lua b/template/src/lib/bindings.lua index 72d89d7..d9a0834 100644 --- a/template/src/lib/bindings.lua +++ b/template/src/lib/bindings.lua @@ -111,6 +111,22 @@ local PROXY_BINDING_END = 5999 --- @field displayName string --- @field class string +--- Calls `fn` with each saved binding that has its ID, and `skipped` with the namespace and key of any other entry. +--- @param saved table> The saved bindings. +--- @param fn fun(binding: Binding) +--- @param skipped? fun(namespace: any, key: any) +local function eachBinding(saved, fn, skipped) + for namespace, keys in pairs(saved) do + for key, binding in pairs(type(keys) == "table" and keys or {}) do + if Select(binding, "bindingId") ~= nil then + fn(binding) + elseif skipped then + skipped(namespace, key) + end + end + end +end + --- Creates a new Bindings instance. --- @return Bindings bindings A new Bindings instance. function Bindings:new() @@ -123,17 +139,17 @@ end --- If the binding does not exist, it creates a new one with a unique ID. --- @param namespace string The namespace of the binding. --- @param key string The key of the binding. ---- @param type string The type of the binding (e.g., "CONTROL" or "PROXY"). +--- @param bindingType string The type of the binding (e.g., "CONTROL" or "PROXY"). --- @param provider boolean Whether the binding is a provider. --- @param displayName string The display name of the binding. --- @param class string The class of the binding. --- @return Binding|nil binding The binding object or nil if the binding could not be created. -function Bindings:getOrAddDynamicBinding(namespace, key, type, provider, displayName, class) +function Bindings:getOrAddDynamicBinding(namespace, key, bindingType, provider, displayName, class) log:trace( "Binding:getOrAddDynamicBinding(%s, %s, %s, %s, %s, %s)", namespace, key, - type, + bindingType, provider, displayName, class @@ -141,25 +157,26 @@ function Bindings:getOrAddDynamicBinding(namespace, key, type, provider, display local bindings = self:getBindings() --- @type Binding|nil local binding = Select(bindings, namespace, key) - if binding == nil then - local bindingId = self:_getNextBindingId(type) + if Select(binding, "bindingId") == nil or binding.type == nil or binding.class == nil then + -- A saved record missing a field the restore needs is replaced, keeping its ID if it has one. + local bindingId = Select(binding, "bindingId") or self:_getNextBindingId(bindingType) if bindingId == nil then return nil end binding = { key = key, bindingId = bindingId, - type = type, + type = bindingType, provider = provider, displayName = displayName, class = class, } --- @cast binding Binding - bindings[namespace] = bindings[namespace] or {} + bindings[namespace] = type(bindings[namespace]) == "table" and bindings[namespace] or {} bindings[namespace][key] = binding self:_saveBindings(bindings) - C4:AddDynamicBinding(bindingId, type, provider, displayName, class, false, false) + C4:AddDynamicBinding(bindingId, bindingType, provider, displayName, class, false, false) elseif binding.displayName ~= displayName or binding.provider ~= provider or binding.class ~= class then -- Control4 has no in-place rename, so re-add under the same id, with the record's own -- type (its id came from that type's range and restoreBindings re-adds from it). Wiring @@ -292,25 +309,28 @@ end function Bindings:restoreBindings() log:trace("Binding:restoreBindings()") local deviceBindings = GetDeviceBindings(tointeger(C4:GetDeviceID())) - for _, keys in pairs(self:getBindings()) do - for _, binding in pairs(keys) do - deviceBindings[binding.bindingId] = nil - -- AddDynamicBinding raises on a nil name, which an older driver build could save; the - -- record keeps its id, and the driver's next getOrAddDynamicBinding for it adds it named. - if binding.displayName ~= nil then - log:debug("Restoring %s binding %s", binding.class, binding.displayName) - C4:AddDynamicBinding( - binding.bindingId, - binding.type, - binding.provider, - binding.displayName, - binding.class, - false, - false - ) - end + eachBinding(self:getBindings(), function(binding) + deviceBindings[binding.bindingId] = nil + -- AddDynamicBinding raises on a nil name, which an older driver build could save, and a record without its + -- type or class can't be added as it was. Either keeps its id, and the driver's next getOrAddDynamicBinding + -- for it adds it whole. + if binding.type == nil or binding.class == nil then + log:warn("Skipping saved binding %s: it has no type or class", binding.bindingId) + elseif binding.displayName ~= nil then + log:debug("Restoring %s binding %s", binding.class, binding.displayName) + C4:AddDynamicBinding( + binding.bindingId, + binding.type, + binding.provider, + binding.displayName, + binding.class, + false, + false + ) end - end + end, function(namespace, key) + log:warn("Skipping saved binding %s/%s: it has no binding ID", namespace, key) + end) -- Delete unknown bindings inside our managed ranges, but never the driver's own -- static driver.xml connections, which share the id space (see staticConnectionIds). local static = staticConnectionIds() @@ -325,26 +345,24 @@ end --- Retrieves the next available binding ID for a given type. Ensures that the --- ID is unique and within the allowed range. --- @private ---- @param type string The type of the binding (e.g., "CONTROL" or "PROXY"). +--- @param bindingType string The type of the binding (e.g., "CONTROL" or "PROXY"). --- @return integer|nil bindingId The next available binding ID or nil if the maximum is exceeded. -function Bindings:_getNextBindingId(type) - log:trace("Binding:_getNextBindingId(%s)", type) +function Bindings:_getNextBindingId(bindingType) + log:trace("Binding:_getNextBindingId(%s)", bindingType) --- @type table local currentBindings = {} - for _, keys in pairs(self:getBindings()) do - for _, binding in pairs(keys) do - currentBindings[binding.bindingId] = true - end - end + eachBinding(self:getBindings(), function(binding) + currentBindings[binding.bindingId] = true + end) local nextId, maxId = CONTROL_BINDING_START, CONTROL_BINDING_END - if type == "PROXY" then + if bindingType == "PROXY" then nextId, maxId = PROXY_BINDING_START, PROXY_BINDING_END end while currentBindings[nextId] ~= nil and nextId <= maxId do nextId = nextId + 1 end if nextId > maxId then - log:error("maximum %s bindings exceeded", type) + log:error("maximum %s bindings exceeded", bindingType) return nil end return nextId @@ -371,14 +389,12 @@ end --- This does not affect static bindings defined in driver.xml. function Bindings:reset() log:trace("Bindings:reset()") - for _, nsBindings in pairs(self:getBindings()) do - for _, binding in pairs(nsBindings) do - log:debug("Removing binding '%s' (id=%s)", binding.displayName, binding.bindingId) - C4:RemoveDynamicBinding(binding.bindingId) - RFP[binding.bindingId] = nil - OBC[binding.bindingId] = nil - end - end + eachBinding(self:getBindings(), function(binding) + log:debug("Removing binding '%s' (id=%s)", binding.displayName, binding.bindingId) + C4:RemoveDynamicBinding(binding.bindingId) + RFP[binding.bindingId] = nil + OBC[binding.bindingId] = nil + end) self:_saveBindings(nil) end diff --git a/template/src/lib/conditionals.lua b/template/src/lib/conditionals.lua index 18c321e..c7c7a51 100644 --- a/template/src/lib/conditionals.lua +++ b/template/src/lib/conditionals.lua @@ -28,6 +28,19 @@ local CONDITIONAL_ID_START = 10 --- @field conditionalId number --- @field name string +--- Calls `fn` with each saved conditional that has its ID, skipping any other saved entry. +--- @param saved table> The saved conditionals. +--- @param fn fun(conditional: Conditional) +local function eachConditional(saved, fn) + for _, keys in pairs(saved) do + for _, conditional in pairs(type(keys) == "table" and keys or {}) do + if Select(conditional, "conditionalId") ~= nil then + fn(conditional) + end + end + end +end + --- Creates a new instance of the `Conditionals` class. --- @return Conditionals conditionals A new instance of the `Conditionals` class. function Conditionals:new() @@ -54,7 +67,7 @@ function Conditionals:upsertConditional(namespace, key, conditional, testFunctio conditional.conditionalId = conditionalId conditional.name = "CONDITIONAL_" .. conditionalId - conditionals[namespace] = conditionals[namespace] or {} + conditionals[namespace] = type(conditionals[namespace]) == "table" and conditionals[namespace] or {} conditionals[namespace][key] = conditional TC[conditional.name] = testFunction @@ -96,13 +109,9 @@ end function Conditionals:_getNextConditionalId() log:trace("Conditionals:_getNextConditionalId()") local currentConditionals = {} - for _, keys in pairs(self:getConditionals()) do - for _, conditional in pairs(keys) do - if Select(conditional, "conditionalId") ~= nil then - currentConditionals[conditional.conditionalId] = true - end - end - end + eachConditional(self:getConditionals(), function(conditional) + currentConditionals[conditional.conditionalId] = true + end) local nextId = CONDITIONAL_ID_START while currentConditionals[nextId] ~= nil do nextId = nextId + 1 @@ -130,12 +139,10 @@ end --- Resets all conditionals, removing them from the system and clearing persisted storage. function Conditionals:reset() log:trace("Conditionals:reset()") - for _, nsConditionals in pairs(self:getConditionals()) do - for _, conditional in pairs(nsConditionals) do - log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) - TC[conditional.name] = nil - end - end + eachConditional(self:getConditionals(), function(conditional) + log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) + TC[conditional.name] = nil + end) self:_saveConditionals(nil) end @@ -146,11 +153,9 @@ local conditionals = Conditionals:new() function GetConditionals() log:trace("GetConditionals()") local progConditionals = {} - for _, keys in pairs(conditionals:getConditionals()) do - for _, conditional in pairs(keys) do - progConditionals[tostring(conditional.conditionalId)] = conditional - end - end + eachConditional(conditionals:getConditionals(), function(conditional) + progConditionals[tostring(conditional.conditionalId)] = conditional + end) return progConditionals end diff --git a/template/src/lib/diagnostics.lua b/template/src/lib/diagnostics.lua index b8c5d80..5cfbca1 100644 --- a/template/src/lib/diagnostics.lua +++ b/template/src/lib/diagnostics.lua @@ -4,6 +4,9 @@ --- --- While enabled it owns ON_HANDLER_ERROR, ON_TIMER_ERROR and ON_UNHANDLED_REJECTION, the hooks the vendored --- dispatchers, timers and deferred call on an error. A driver adds its own points to each report with onReport. +--- +--- An error's message and traceback are stored with each newline, tab and carriage return written as a literal \n, +--- \t and \r, which keeps the point on one line, so whatever reads them back unescapes them. local log = require("lib.logging") local http = require("lib.http") @@ -209,7 +212,26 @@ function Diagnostics:onReport(callback) self._onReport = callback end ---- Captures an error and writes it directly to the logs database. +--- Splits an error into its message and traceback. An HTTP rejection from lib.http is a table whose `error` is +--- already redacted. +--- @param err any What the dispatcher, timer or rejection passed. +--- @return string message +--- @return string traceback +local function splitError(err) + if type(err) == "table" then + err = type(err.error) == "string" and err.error or type(err.message) == "string" and err.message or "a table" + end + local text = tostring(err) + local marker = "\nstack traceback:\n" + local at = text:find(marker, 1, true) + if at then + return text:sub(1, at - 1), text:sub(at + #marker) + end + return text, "" +end + +--- Captures an error and writes it directly to the logs database. A message given with no traceback is split, so +--- one that is a whole debug.traceback() is stored as its message and its traceback. --- Rate-limited to ERROR_RATE_LIMIT writes per ERROR_RATE_WINDOW seconds. --- @param message string --- @param traceback string|nil @@ -219,8 +241,11 @@ function Diagnostics:captureError(message, traceback, source) return end - message = tostring(message or "unknown error") - traceback = tostring(traceback or "") + if traceback == nil or traceback == "" then + message, traceback = splitError(message or "unknown error") + end + message = tostring(message) + traceback = tostring(traceback) source = tostring(source or "unknown") local now = os.time() @@ -247,24 +272,6 @@ function Diagnostics:captureError(message, traceback, source) self:_writeErrorLog(message, traceback, source, now) end ---- Splits an error into its message and traceback. An HTTP rejection from lib.http is a table whose `error` is ---- already redacted. ---- @param err any What the dispatcher, timer or rejection passed. ---- @return string message ---- @return string traceback -local function splitError(err) - if type(err) == "table" then - err = type(err.error) == "string" and err.error or type(err.message) == "string" and err.message or "a table" - end - local text = tostring(err) - local marker = "\nstack traceback:\n" - local at = text:find(marker, 1, true) - if at then - return text:sub(1, at - 1), text:sub(at + #marker) - end - return text, "" -end - --- Packs every value, nils included. --- @return table local function pack(...) diff --git a/template/src/lib/events.lua b/template/src/lib/events.lua index 2d5e156..0874ba5 100644 --- a/template/src/lib/events.lua +++ b/template/src/lib/events.lua @@ -28,6 +28,22 @@ local EVENT_ID_END = 999 --- @field name string --- @field description string +--- Calls `fn` with each saved event that has its ID, and `skipped` with the namespace and key of any other entry. +--- @param saved table> The saved events. +--- @param fn fun(event: Event) +--- @param skipped? fun(namespace: any, key: any) +local function eachEvent(saved, fn, skipped) + for namespace, keys in pairs(saved) do + for key, event in pairs(type(keys) == "table" and keys or {}) do + if Select(event, "eventId") ~= nil then + fn(event) + elseif skipped then + skipped(namespace, key) + end + end + end +end + --- Creates a new `Events` instance. --- @return Events events A new `Events` instance. function Events:new() @@ -48,24 +64,26 @@ function Events:getOrAddEvent(namespace, key, name, description) local events = self:getEvents() --- @type Event|nil local event = Select(events, namespace, key) - if event == nil then + if Select(event, "eventId") == nil then local eventId = self:_getNextEventId() + -- Declared before it is saved: a declaration C4 refuses must not leave a record that every restart replays. + C4:AddEvent(eventId, name, description) event = { eventId = eventId, name = name, description = description, } - events[namespace] = events[namespace] or {} + events[namespace] = type(events[namespace]) == "table" and events[namespace] or {} events[namespace][key] = event self:_saveEvents(events) - C4:AddEvent(eventId, name, description) elseif event.name ~= name or event.description ~= description then + --- @cast event Event -- Programming binds to the ID, so changed text keeps it; AddEvent on a known ID updates it. + C4:AddEvent(event.eventId, name, description) event.name = name event.description = description self:_saveEvents(events) - C4:AddEvent(event.eventId, name, description) end return event end @@ -117,14 +135,12 @@ function Events:restoreEvents() log:trace("Events:restoreEvents()") --- @type table local usedEventIds = {} - for _, keys in pairs(self:getEvents()) do - for _, event in pairs(keys) do - if Select(event, "eventId") ~= nil then - usedEventIds[event.eventId] = true - C4:AddEvent(event.eventId, event.name, event.description) - end - end - end + eachEvent(self:getEvents(), function(event) + usedEventIds[event.eventId] = true + C4:AddEvent(event.eventId, event.name, event.description) + end, function(namespace, key) + log:warn("Skipping saved event %s/%s: it has no event ID", namespace, key) + end) for id = EVENT_ID_START, EVENT_ID_END do if usedEventIds[id] == nil then log:trace("Deleting non-configured event %s, if it exists", id) @@ -140,13 +156,9 @@ function Events:_getNextEventId() log:trace("Events:_getNextEventId()") --- @type table local currentEvents = {} - for _, keys in pairs(self:getEvents()) do - for _, event in pairs(keys) do - if Select(event, "eventId") ~= nil then - currentEvents[event.eventId] = true - end - end - end + eachEvent(self:getEvents(), function(event) + currentEvents[event.eventId] = true + end) local nextId = EVENT_ID_START while currentEvents[nextId] ~= nil do nextId = nextId + 1 @@ -174,12 +186,10 @@ end --- Resets all dynamic events, removing them from the system and clearing persisted storage. function Events:reset() log:trace("Events:reset()") - for _, nsEvents in pairs(self:getEvents()) do - for _, event in pairs(nsEvents) do - log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) - C4:DeleteEvent(event.eventId) - end - end + eachEvent(self:getEvents(), function(event) + log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) + C4:DeleteEvent(event.eventId) + end) self:_saveEvents(nil) end diff --git a/template/src/lib/persist.lua b/template/src/lib/persist.lua index 16a0a75..9c2a3e6 100644 --- a/template/src/lib/persist.lua +++ b/template/src/lib/persist.lua @@ -253,7 +253,7 @@ function Persist:setWriteBehind(key, ms) self._writeBehind[key] = ms end ---- Closes a `defer()` scope, then returns or rethrows what pcall gave it. +--- Closes a `defer()` scope, then returns or rethrows what xpcall gave it. --- @private local function leaveDefer(self, ok, ...) self._deferDepth = self._deferDepth - 1 @@ -263,15 +263,31 @@ local function leaveDefer(self, ok, ...) return ... end +--- Adds the stack to an error raised inside `defer()`, unless an inner scope already did. +--- @param err any +--- @return any +local function withTraceback(err) + if type(err) == "string" and not err:find("\nstack traceback:\n", 1, true) then + return debug.traceback(err, 2) + end + return err +end + --- Calls `fn(...)` with sets of write-behind keys held for their next flush. ---- Scopes nest; an error from `fn` is rethrown after the scope closes. +--- Scopes nest; an error from `fn` is rethrown after the scope closes, with the stack where it was raised. --- @param fn function The function to call. --- @param ... any Arguments for `fn`. --- @return any ... What `fn` returns. function Persist:defer(fn, ...) log:trace("Persist:defer(%s)", fn) self._deferDepth = self._deferDepth + 1 - return leaveDefer(self, pcall(fn, ...)) + local args = { n = select("#", ...), ... } + return leaveDefer( + self, + xpcall(function() + return fn(unpack(args, 1, args.n)) + end, withTraceback) + ) end --- Writes pending write-behind values to storage now. diff --git a/template/src/lib/utils.lua b/template/src/lib/utils.lua index 5c545e5..8fffe91 100644 --- a/template/src/lib/utils.lua +++ b/template/src/lib/utils.lua @@ -124,7 +124,7 @@ end local REDACTED = "***REDACTED***" --- Logs and invokes a C4 method, trimming trailing nil arguments. The argument at place `secret` is traced as ---- REDACTED. +--- REDACTED. A nil before the last argument given is refused with an error, as the controller refuses it. --- @param methodName string The C4 method name --- @param secret integer|nil The place of an argument that holds a secret --- @param ... any Arguments to pass @@ -145,6 +145,11 @@ local function C4CallHiding(methodName, secret, ...) end log:trace("C4:" .. methodName .. "(" .. table.concat(fmtParts, ", ") .. ")", unpack(shown, 1, numArgs)) + for i = 1, lastNonNil do + if args[i] == nil then + error(string.format("C4:%s refused: argument %d is nil before argument %d", methodName, i, lastNonNil), 0) + end + end return C4[methodName](C4, unpack(args, 1, lastNonNil)) end @@ -163,9 +168,12 @@ end --- @param allowEmptyValues? boolean Allows empty strings as parameter values (optional). Defaults to false. --- @param logCommand? boolean If false, prevents logging of the command's content (optional). Defaults to true. function SendToDevice(deviceId, strCommand, tParams, allowEmptyValues, logCommand) - if logCommand ~= nil then + if allowEmptyValues ~= nil or logCommand ~= nil then allowEmptyValues = allowEmptyValues == true end + if logCommand ~= nil then + logCommand = logCommand == true + end return C4Call("SendToDevice", deviceId, strCommand, tParams, allowEmptyValues, logCommand) end @@ -178,6 +186,7 @@ end function SendToProxy(idBinding, strCommand, tParams, strMessage, allowEmptyValues) if allowEmptyValues ~= nil then strMessage = strMessage or "COMMAND" + allowEmptyValues = allowEmptyValues == true end return C4Call("SendToProxy", idBinding, strCommand, tParams, strMessage, allowEmptyValues) end @@ -203,6 +212,7 @@ end function SendSecretToProxy(idBinding, strCommand, tParams, strMessage, allowEmptyValues) if allowEmptyValues ~= nil then strMessage = strMessage or "COMMAND" + allowEmptyValues = allowEmptyValues == true end return C4CallHiding("SendToProxy", 3, idBinding, strCommand, tParams, strMessage, allowEmptyValues) end diff --git a/template/test/test_diagnostics_reporting.lua b/template/test/test_diagnostics_reporting.lua index 2389068..7fc1878 100644 --- a/template/test/test_diagnostics_reporting.lua +++ b/template/test/test_diagnostics_reporting.lua @@ -195,6 +195,19 @@ sent = posted(function() end) T.contains("backslashes and quotes are escaped", sent[1].body, 'message="ends in a backslash \\\\ and a \\"quote\\""') +T.section("A message that holds its own traceback is split") +sent = posted(function() + diag._errorWindowStart = os.time() + diag._errorWriteCount = 0 + diag:captureError("lib/example.lua:9: boom\nstack traceback:\n\t[C]: in function 'error'", nil, "test") + diag:captureError("lib/example.lua:9: boom\nstack traceback:\n\t[C]: in function 'error'", "", "test") + diag:captureError("lib/example.lua:9: boom", "\tgiven", "test") +end) +T.contains("its message alone", sent[1].body, 'message="lib/example.lua:9: boom"') +T.contains("its traceback in its own field", sent[1].body, "traceback=\"\\t[C]: in function 'error'\"") +T.contains("an empty traceback the same", sent[2].body, "traceback=\"\\t[C]: in function 'error'\"") +T.contains("a traceback given is kept", sent[3].body, 'traceback="\\tgiven"') + T.section("Disabling clears the hooks and the timers") diag:setEnabled(false) T.eq("the handler hook", ON_HANDLER_ERROR, nil) diff --git a/template/test/test_persist_write_behind.lua b/template/test/test_persist_write_behind.lua index 1cc2a4f..8780471 100644 --- a/template/test/test_persist_write_behind.lua +++ b/template/test/test_persist_write_behind.lua @@ -166,6 +166,16 @@ T.raises("an error in fn is rethrown", function() end, "boom") p:set("Hot", { n = 2 }) T.eq("and the scope is closed after it", calls, { "set Hot" }) +local _, err = pcall(p.defer, p, function() + p:defer(function() + error("deep") + end) +end) +T.contains("with the stack where it was raised", err, "deep\nstack traceback:\n") +T.eq("named once, however many scopes it passes", select(2, tostring(err):gsub("stack traceback:", "")), 1) +local rejection = { error = "a table" } +_, err = pcall(p.defer, p, error, rejection) +T.eq("an error that is not a string is rethrown as it is", err, rejection) -- ── lib/values.lua ─────────────────────────────────────────────────────────── diff --git a/template/test/test_saved_entries_without_ids.lua b/template/test/test_saved_entries_without_ids.lua index 350a67c..eb32332 100644 --- a/template/test/test_saved_entries_without_ids.lua +++ b/template/test/test_saved_entries_without_ids.lua @@ -1,6 +1,6 @@ --- What lib/conditionals.lua and lib/events.lua do with a saved record that comes back without its id, or as --- something other than a table. Restoring events and finding the next free id indexed by that id, so one such --- record threw "table index is nil" at the start of OnDriverLateInit and stopped the driver from starting. +-- What lib/conditionals.lua, lib/events.lua and lib/bindings.lua do with a saved record that comes back without +-- its id, or as something other than a table. Their restores indexed by that id, so one such record threw +-- "table index is nil" at the start of OnDriverInit or OnDriverLateInit and stopped the driver from starting. -- -- Run from the driver root: -- make test @@ -10,18 +10,21 @@ local T = require("testlib") require("c4_shim") -require("drivers-common-public.global.handlers") -- TC +require("drivers-common-public.global.handlers") -- TC, RFP, OBC +local log = require("lib.logging") local persist = require("lib.persist") ---- Saves records under key the way a driver might find them: one whole, one without its id and one that is not a ---- table. Then fresh modules read them back, as after a restart. -local function savedAfterRestart(key, whole, noId) - persist:set(key, { Buttons = { whole = whole, noId = noId, notATable = "x" } }) +--- Saves records under key the way a driver might find them: the given ones, one that is not a table, and a +--- namespace that is not a table either. Then fresh modules read them back, as after a restart. +local function savedAfterRestart(key, records) + records.notATable = "x" + persist:set(key, { Buttons = records, Broken = "x" }) ShimPersistLand() T.unload("^lib%.") ShimResetEvents() persist = require("lib.persist") + log = require("lib.logging") end --- The keys of t, sorted. @@ -34,12 +37,26 @@ local function keys(t) return out end +--- Everything lib.logging prints at warning level while fn runs, and whether fn ran without raising. +local function warned(fn) + log:setLogLevel("2 - Warning") + log:setLogMode("Print") + local ok, err, output = T.capture(fn) + log:setLogMode("") + return ok, err, output +end + T.section("a saved conditional without its id is skipped") -savedAfterRestart( - "Conditionals", - { conditionalId = 10, name = "CONDITIONAL_10", type = "BOOL", condition_statement = "IS", description = "Whole" }, - { name = "CONDITIONAL_LOST", type = "BOOL", condition_statement = "IS", description = "Lost its id" } -) +savedAfterRestart("Conditionals", { + whole = { + conditionalId = 10, + name = "CONDITIONAL_10", + type = "BOOL", + condition_statement = "IS", + description = "Whole", + }, + noId = { name = "CONDITIONAL_LOST", type = "BOOL", condition_statement = "IS", description = "Lost its id" }, +}) local conditionals = require("lib.conditionals") local ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", "new", { type = "BOOL", @@ -50,18 +67,100 @@ local ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", end) T.truthy("a new one is added", ok, added) T.eq("under the next free id", ok and added.conditionalId, 11) +ok, added = pcall(conditionals.upsertConditional, conditionals, "Broken", "first", { + type = "BOOL", + condition_statement = "IS", + description = "First", +}, function() + return true +end) +T.eq("one in a namespace that was not a table replaces it", ok and added.conditionalId, 12) +local listed +ok, listed = pcall(GetConditionals) +T.truthy("GetConditionals runs", ok, listed) +T.eq("and lists only the ones with their ids", keys(ok and listed or nil), { "10", "11", "12" }) +T.truthy("reset runs", pcall(conditionals.reset, conditionals)) +T.eq("and removes the new one's test", TC.CONDITIONAL_11, nil) -T.section("a saved event without its id is skipped") -savedAfterRestart( - "Events", - { eventId = 10, name = "Whole", description = "When whole" }, - { name = "Lost", description = "Lost its id" } -) +T.section("a saved event without its id is skipped, and the driver adding it again gives it one") +savedAfterRestart("Events", { + whole = { eventId = 10, name = "Whole", description = "When whole" }, + noId = { name = "Lost", description = "Lost its id" }, +}) local events = require("lib.events") -T.truthy("restoreEvents runs", pcall(events.restoreEvents, events)) -T.eq("and declares only the whole one", keys(ShimEvents()), { 10 }) +local _, err, output = warned(function() + events:restoreEvents() +end) +T.eq("restoreEvents runs", err, nil) +T.contains("and says what it skipped", output, "Skipping saved event Buttons/noId: it has no event ID") +T.eq("declaring only the whole one", keys(ShimEvents()), { 10 }) ok, added = pcall(events.getOrAddEvent, events, "Buttons", "new", "New", "When new") T.truthy("a new one is added", ok, added) T.eq("under the next free id", ok and added.eventId, 11) +ok, added = pcall(events.getOrAddEvent, events, "Buttons", "noId", "Lost", "Lost its id") +T.eq("the one without an id gets the next", ok and added.eventId, 12) +T.eq("and is declared", ShimEvents()[12] and ShimEvents()[12].name, "Lost") +ok, added = pcall(events.getOrAddEvent, events, "Broken", "first", "First", "When first") +T.eq("one in a namespace that was not a table replaces it", ok and added.eventId, 13) + +T.section("an event C4 refuses to declare is not saved") +T.falsy("a nil name is refused", pcall(events.getOrAddEvent, events, "Buttons", "nameless", nil, "No name")) +ShimPersistLand() +T.eq("and nothing is saved for it", Select(events:getEvents(), "Buttons", "nameless"), nil) +T.truthy("so the next restore runs", pcall(events.restoreEvents, events)) +T.truthy("reset runs", pcall(events.reset, events)) +T.eq("and deletes every declared event", keys(ShimEvents()), {}) + +T.section("a saved binding without its id, type or class is skipped, and the driver adding it again adds it whole") +ShimResetDynamicBindings() +ShimSetStaticBindings({}) +-- What Director still holds from before the restart: the binding with no class saved, and a connection on it. +C4:AddDynamicBinding(5013, "PROXY", true, "No class", "RELAY", false, false) +C4:Bind(C4:GetDeviceID(), 5013, 700, 1, "RELAY") +savedAfterRestart("ConnectionBindings", { + whole = { key = "whole", bindingId = 5012, type = "PROXY", provider = true, displayName = "Whole", class = "RELAY" }, + noId = { key = "noId", type = "PROXY", provider = true, displayName = "No id", class = "RELAY" }, + noClass = { key = "noClass", bindingId = 5013, type = "PROXY", provider = true, displayName = "No class" }, + noType = { key = "noType", bindingId = 5014, provider = true, displayName = "No type", class = "RELAY" }, +}) +local bindings = require("lib.bindings") +_, err, output = warned(function() + bindings:restoreBindings() +end) +T.eq("restoreBindings runs", err, nil) +T.contains( + "and says it skipped the one with no id", + output, + "Skipping saved binding Buttons/noId: it has no binding ID" +) +T.contains("and the one with no class", output, "Skipping saved binding 5013: it has no type or class") +T.contains("and the one with no type", output, "Skipping saved binding 5014: it has no type or class") +T.eq("adding only the whole one, and keeping the one Director holds", keys(ShimDynamicBindings()), { 5012, 5013 }) +--- getOrAddDynamicBinding's binding, or nil if it raised. +local function added(...) + local ok, binding = pcall(bindings.getOrAddDynamicBinding, bindings, ...) + return ok and binding or nil +end +local binding = added("Buttons", "new", "PROXY", true, "New", "RELAY") +T.eq("a new one takes the next id not saved", binding and binding.bindingId, 5015) +binding = added("Buttons", "noClass", "PROXY", true, "No class", "RELAY") +T.eq("the one with no class is added whole at its own id", { + binding and binding.bindingId, + binding and binding.class, + ShimDynamicBindings()[5013] and ShimDynamicBindings()[5013].class, +}, { 5013, "RELAY", "RELAY" }) +T.eq("keeping its connection", #ShimConnections(), 1) +binding = added("Buttons", "noType", "PROXY", true, "No type", "RELAY") +T.eq("the one with no type, added again as it was, is added whole at its own id", { + binding and binding.bindingId, + binding and binding.type, + ShimDynamicBindings()[5014] and ShimDynamicBindings()[5014].type, +}, { 5014, "PROXY", "PROXY" }) +binding = added("Buttons", "noId", "PROXY", true, "No id", "RELAY") +T.eq("the one with no id is given the next", binding and binding.bindingId, 5016) +binding = added("Broken", "first", "PROXY", true, "First", "RELAY") +T.eq("one in a namespace that was not a table replaces it", binding and binding.bindingId, 5017) +T.truthy("reset runs", pcall(bindings.reset, bindings)) +T.eq("and removes every binding it added", keys(ShimDynamicBindings()), {}) T.finish() diff --git a/template/test/test_secret_sends_trace.lua b/template/test/test_secret_sends_trace.lua index c620135..0ed1ab4 100644 --- a/template/test/test_secret_sends_trace.lua +++ b/template/test/test_secret_sends_trace.lua @@ -120,4 +120,58 @@ SendToDevice(42, "REFRESH", {}) SendToProxy(5001, "LOCK_STATUS_CHANGED", {}) T.eq("and nothing is added when nothing follows", { sent[1][2], sent[2][2] }, { 3, 3 }) +T.section("a flag given as something other than a boolean reaches C4 as one") +sent = {} +calls = { + { + "SendToProxy, allowing empty values with a string", + function() + SendToProxy(5001, "LOCK_STATUS_CHANGED", {}, "NOTIFY", "yes") + end, + }, + { + "SendSecretToProxy, allowing empty values with a number", + function() + SendSecretToProxy(5001, "USER_ADDED", {}, "NOTIFY", 1) + end, + }, + { + "SendToDevice, allowing empty values with a string", + function() + SendToDevice(42, "REFRESH", {}, "yes") + end, + }, + { + "SendToDevice, asked to log with a number", + function() + SendToDevice(42, "REFRESH", {}, true, 0) + end, + }, +} +for _, call in ipairs(calls) do + T.truthy("C4 accepts " .. call[1], pcall(call[2])) +end +T.eq( + "only true allows empty values", + { Select(sent, 1, 7), Select(sent, 2, 7), Select(sent, 3, 6), Select(sent, 4, 6) }, + { false, false, false, true } +) +T.eq("and only true lets C4 log the command", Select(sent, 4, 7), false) + +T.section("a nil before an argument that is given is refused before it reaches C4") +sent = {} +T.raises("SendToProxy with no parameters before a message type", function() + SendToProxy(5001, "LOCK_STATUS_CHANGED", nil, "NOTIFY") +end, "C4:SendToProxy refused: argument 3 is nil before argument 4") +T.raises("SendSecretToDevice with no parameters", function() + SendSecretToDevice(42, "ARM", nil) +end, "C4:SendToDevice refused: argument 3 is nil before argument 5") +T.raises("SendToNetwork with no port", function() + SendToNetwork(6001, nil, "GET / HTTP/1.1") +end, "C4:SendToNetwork refused: argument 2 is nil before argument 3") +T.raises("SendUIRequest with no request", function() + SendUIRequest(42, nil, {}) +end, "C4:SendUIRequest refused: argument 2 is nil before argument 3") +T.eq("and none reaches C4", sent, {}) + T.finish()