feat: add testing utilities including context, mock functions, and snapshot handling

This commit is contained in:
2025-12-30 11:03:15 +00:00
parent 0f69e33d9b
commit 09482b0d0b
12 changed files with 695 additions and 475 deletions

View File

@@ -0,0 +1,57 @@
-- Test context utilities
-- Provides context builder for sharing state in tests
local table_utils = require("table_utils")
---@class TestContextModule
local M = {}
---@class TestContext
---@field get fun(key: string): any Get a context value
---@field set fun(key: string, value: any) Set a context value
---@field with fun(overrides: table): table Get context merged with overrides
---@field reset fun() Reset context to initial state
---@field getAll fun(): table Get a clone of all context values
---Create a test context for sharing state
---@param initial? table Initial context values
---@return TestContext Context object with get/set/with/reset methods
function M.createContext(initial)
local ctx = initial or {}
return {
---Get a context value by key
---@param key string Key to look up
---@return any Value at key
get = function(key)
return ctx[key]
end,
---Set a context value
---@param key string Key to set
---@param value any Value to store
set = function(key, value)
ctx[key] = value
end,
---Get context merged with overrides
---@param overrides table Values to merge
---@return table Merged context
with = function(overrides)
return table_utils.merge(ctx, overrides)
end,
---Reset context to initial state
reset = function()
ctx = initial and table_utils.clone(initial) or {}
end,
---Get a clone of all context values
---@return table Clone of context
getAll = function()
return table_utils.clone(ctx)
end
}
end
return M

View File

@@ -0,0 +1,102 @@
-- Fake timer utilities for testing
-- Allows controlling time in tests
---@class FakeTimersModule
local M = {}
---@class FakeTimers
---@field now fun(): number Get current fake time
---@field schedule fun(callback: function, delay: number): number Schedule callback
---@field cancel fun(id: number) Cancel scheduled callback
---@field advance fun(ms: number) Advance time by milliseconds
---@field runAll fun() Run all pending timers
---@field reset fun() Reset timer state
---Create a fake timer system for testing time-dependent code
---@return FakeTimers Timer control object
function M.useFakeTimers()
local timers = {
now = 0,
scheduled = {}
}
return {
---Get current fake time
---@return number Current fake timestamp
now = function()
return timers.now
end,
---Schedule a callback (like setTimeout)
---@param callback function Function to call
---@param delay number Delay in milliseconds
---@return number Timer ID for cancellation
schedule = function(callback, delay)
local id = #timers.scheduled + 1
timers.scheduled[id] = {
callback = callback,
time = timers.now + delay,
id = id
}
return id
end,
---Cancel a scheduled callback
---@param id number Timer ID to cancel
cancel = function(id)
timers.scheduled[id] = nil
end,
---Advance time and run scheduled callbacks
---@param ms number Milliseconds to advance
advance = function(ms)
local targetTime = timers.now + ms
local pending = {}
for _, timer in pairs(timers.scheduled) do
if timer.time <= targetTime then
pending[#pending + 1] = timer
end
end
table.sort(pending, function(a, b) return a.time < b.time end)
for _, timer in ipairs(pending) do
timers.now = timer.time
timer.callback()
timers.scheduled[timer.id] = nil
end
timers.now = targetTime
end,
---Run all pending timers
runAll = function()
while next(timers.scheduled) do
local nextTimer
local nextTime = math.huge
for id, timer in pairs(timers.scheduled) do
if timer.time < nextTime then
nextTime = timer.time
nextTimer = timer
end
end
if nextTimer then
timers.now = nextTimer.time
nextTimer.callback()
timers.scheduled[nextTimer.id] = nil
end
end
end,
---Reset timer state
reset = function()
timers.now = 0
timers.scheduled = {}
end
}
end
return M

View File

@@ -1,46 +1,32 @@
-- Test helper utilities
-- Additional utilities for writing tests
-- Test helper utilities facade
-- Re-exports all helper modules for backward compatibility
--
-- Split into focused modules:
-- test_data.lua - Test data generation (generateTestData)
-- wait.lua - Async wait utilities (waitFor)
-- snapshot.lua - Snapshot testing (createSnapshot, serialize)
-- table_utils.lua - Table utilities (clone, merge, keys, values, size)
-- string_utils.lua - String utilities (trim, split, startsWith, endsWith)
-- context.lua - Test context builder (createContext)
local test_data = require("test_data")
local wait = require("wait")
local snapshot = require("snapshot")
local table_utils = require("table_utils")
local string_utils = require("string_utils")
local context = require("context")
---@class HelpersModule
local M = {}
-- Generate test data
function M.generateTestData(template, count)
local data = {}
count = count or 10
for i = 1, count do
local item = {}
for k, v in pairs(template) do
if type(v) == "function" then
item[k] = v(i)
elseif type(v) == "string" and v:match("^%$") then
-- Template variables
local varName = v:sub(2)
if varName == "index" then
item[k] = i
elseif varName == "random" then
item[k] = math.random(1, 1000)
elseif varName == "uuid" then
item[k] = string.format("%08x-%04x-%04x-%04x-%012x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xffff),
math.random(0, 0xffff),
math.random(0, 0xffffffffffff))
else
item[k] = v
end
else
item[k] = v
end
end
data[#data + 1] = item
end
return data
end
-- Re-export test data generation
M.generateTestData = test_data.generateTestData
-- Create parameterized test cases
-- Re-export parameterized helper
---Create parameterized test cases
---@param cases table[] Array of test case objects
---@param testFn fun(testCase: table) Test function receiving each case
---@return function Runner function that executes all cases
function M.parameterized(cases, testFn)
return function()
for _, testCase in ipairs(cases) do
@@ -49,173 +35,27 @@ function M.parameterized(cases, testFn)
end
end
-- Wait for condition (for async-like testing)
function M.waitFor(condition, options)
options = options or {}
local timeout = options.timeout or 1000
local interval = options.interval or 10
local startTime = os.clock() * 1000
while (os.clock() * 1000 - startTime) < timeout do
if condition() then
return true
end
-- Note: In sandbox, we can't actually sleep, but this provides the pattern
end
if options.throwOnTimeout ~= false then
error("waitFor timed out after " .. timeout .. "ms")
end
return false
end
-- Re-export wait utilities
M.waitFor = wait.waitFor
-- Snapshot testing helper
function M.createSnapshot(name)
local snapshots = {}
return {
-- Record a snapshot
record = function(key, value)
snapshots[key] = M.serialize(value)
end,
-- Match against recorded snapshot
match = function(key, value)
local serialized = M.serialize(value)
if snapshots[key] then
return snapshots[key] == serialized
end
-- First run - record the snapshot
snapshots[key] = serialized
return true
end,
-- Get all snapshots
getSnapshots = function()
return snapshots
end,
-- Update a snapshot
update = function(key, value)
snapshots[key] = M.serialize(value)
end
}
end
-- Re-export snapshot utilities
M.createSnapshot = snapshot.createSnapshot
M.serialize = snapshot.serialize
-- Serialize value for comparison
function M.serialize(value, seen)
seen = seen or {}
local t = type(value)
if t == "nil" then
return "nil"
elseif t == "boolean" then
return value and "true" or "false"
elseif t == "number" then
return tostring(value)
elseif t == "string" then
return string.format("%q", value)
elseif t == "table" then
if seen[value] then
return "<circular>"
end
seen[value] = true
local parts = {}
local keys = {}
for k in pairs(value) do
keys[#keys + 1] = k
end
table.sort(keys, function(a, b)
return tostring(a) < tostring(b)
end)
for _, k in ipairs(keys) do
parts[#parts + 1] = "[" .. M.serialize(k, seen) .. "]=" .. M.serialize(value[k], seen)
end
return "{" .. table.concat(parts, ",") .. "}"
else
return "<" .. t .. ">"
end
end
-- Re-export table utilities
M.table = table_utils
-- Table utilities for testing
M.table = {}
-- Re-export string utilities
M.string = string_utils
function M.table.clone(t)
if type(t) ~= "table" then return t end
local copy = {}
for k, v in pairs(t) do
copy[k] = M.table.clone(v)
end
return setmetatable(copy, getmetatable(t))
end
function M.table.merge(...)
local result = {}
for _, t in ipairs({...}) do
if type(t) == "table" then
for k, v in pairs(t) do
result[k] = v
end
end
end
return result
end
function M.table.keys(t)
local keys = {}
for k in pairs(t) do
keys[#keys + 1] = k
end
return keys
end
function M.table.values(t)
local values = {}
for _, v in pairs(t) do
values[#values + 1] = v
end
return values
end
function M.table.size(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
-- String utilities for testing
M.string = {}
function M.string.trim(s)
return s:match("^%s*(.-)%s*$")
end
function M.string.split(s, delimiter)
delimiter = delimiter or "%s"
local result = {}
for match in (s .. delimiter):gmatch("(.-)" .. delimiter) do
result[#result + 1] = match
end
return result
end
function M.string.startsWith(s, prefix)
return s:sub(1, #prefix) == prefix
end
function M.string.endsWith(s, suffix)
return suffix == "" or s:sub(-#suffix) == suffix
end
-- Re-export context builder
M.createContext = context.createContext
-- Assertion shortcuts for common patterns
---Assert that a function throws an error
---@param fn function Function expected to throw
---@param expectedMessage? string Optional message to check for
---@return any The error that was thrown
function M.assertThrows(fn, expectedMessage)
local success, err = pcall(fn)
if success then
@@ -230,6 +70,8 @@ function M.assertThrows(fn, expectedMessage)
return err
end
---Assert that a function does not throw
---@param fn function Function that should not throw
function M.assertDoesNotThrow(fn)
local success, err = pcall(fn)
if not success then
@@ -237,31 +79,4 @@ function M.assertDoesNotThrow(fn)
end
end
-- Test context builder
function M.createContext(initial)
local ctx = initial or {}
return {
get = function(key)
return ctx[key]
end,
set = function(key, value)
ctx[key] = value
end,
with = function(overrides)
return M.table.merge(ctx, overrides)
end,
reset = function()
ctx = initial and M.table.clone(initial) or {}
end,
getAll = function()
return M.table.clone(ctx)
end
}
end
return M

View File

@@ -0,0 +1,123 @@
-- Mock function creator
-- Creates trackable mock functions with call history
---@class MockFn
local M = {}
---@class MockFunction
---@field getCallCount fun(): number Get number of times called
---@field wasCalled fun(): boolean Check if function was called
---@field wasCalledWith fun(...): boolean Check if called with specific args
---@field getCall fun(index: number): any[] Get args from specific call
---@field getLastCall fun(): any[] Get args from last call
---@field getCalls fun(): any[][] Get all call args
---@field getResults fun(): any[][] Get all return values
---@field reset fun() Clear call history
---@field mockReturnValue fun(value: any) Set return value
---@field mockReturnValueOnce fun(value: any) Set one-time return value
---@field mockImplementation fun(fn: function) Set implementation
---@field mockRestore fun() Restore to original state
---Create a mock function that tracks calls
---@param implementation? function Optional implementation
---@return MockFunction Callable mock with tracking methods
function M.fn(implementation)
local mock = {
calls = {},
results = {},
implementation = implementation
}
local callable = function(...)
local args = {...}
mock.calls[#mock.calls + 1] = args
local result
if mock.implementation then
result = {mock.implementation(...)}
else
result = {}
end
mock.results[#mock.results + 1] = result
return table.unpack(result)
end
return setmetatable({}, {
__call = function(_, ...) return callable(...) end,
__index = {
getCallCount = function()
return #mock.calls
end,
wasCalled = function()
return #mock.calls > 0
end,
wasCalledWith = function(...)
local expectedArgs = {...}
for _, callArgs in ipairs(mock.calls) do
local match = true
for i, expected in ipairs(expectedArgs) do
if callArgs[i] ~= expected then
match = false
break
end
end
if match then return true end
end
return false
end,
getCall = function(index)
return mock.calls[index]
end,
getLastCall = function()
return mock.calls[#mock.calls]
end,
getCalls = function()
return mock.calls
end,
getResults = function()
return mock.results
end,
reset = function()
mock.calls = {}
mock.results = {}
end,
mockReturnValue = function(value)
mock.implementation = function() return value end
end,
mockReturnValueOnce = function(value)
local originalImpl = mock.implementation
local called = false
mock.implementation = function(...)
if not called then
called = true
return value
elseif originalImpl then
return originalImpl(...)
end
end
end,
mockImplementation = function(fn)
mock.implementation = fn
end,
mockRestore = function()
mock.implementation = nil
mock.calls = {}
mock.results = {}
end
}
})
end
return M

View File

@@ -0,0 +1,36 @@
-- Mock object creator
-- Creates objects with multiple mock methods
local mock_fn = require("mock_fn")
---@class MockObjectModule
local M = {}
---@class MockObject
---@field _mocks table<string, MockFunction> Internal mock storage
---@field _resetAll fun() Reset all mocks on this object
---Create a mock object with multiple mock functions
---@param methods? table<string, function> Method names to implementations
---@return MockObject Object with mock methods
function M.mockObject(methods)
local obj = {}
local mocks = {}
for name, impl in pairs(methods or {}) do
mocks[name] = mock_fn.fn(impl)
obj[name] = function(...) return mocks[name](...) end
end
obj._mocks = mocks
obj._resetAll = function()
for _, mock in pairs(mocks) do
mock.reset()
end
end
return obj
end
return M

View File

@@ -1,248 +0,0 @@
-- Mock and spy utilities for testing
-- Allows tracking function calls and replacing implementations
local M = {}
-- Create a mock function
function M.fn(implementation)
local mock = {
calls = {},
results = {},
implementation = implementation
}
-- The callable mock function
local callable = function(...)
local args = {...}
mock.calls[#mock.calls + 1] = args
local result
if mock.implementation then
result = {mock.implementation(...)}
else
result = {}
end
mock.results[#mock.results + 1] = result
return table.unpack(result)
end
-- Attach mock metadata to the function via a metatable
return setmetatable({}, {
__call = function(_, ...) return callable(...) end,
__index = {
-- Get call count
getCallCount = function()
return #mock.calls
end,
-- Check if called
wasCalled = function()
return #mock.calls > 0
end,
-- Check if called with specific args
wasCalledWith = function(...)
local expectedArgs = {...}
for _, callArgs in ipairs(mock.calls) do
local match = true
for i, expected in ipairs(expectedArgs) do
if callArgs[i] ~= expected then
match = false
break
end
end
if match then return true end
end
return false
end,
-- Get specific call args
getCall = function(index)
return mock.calls[index]
end,
-- Get last call args
getLastCall = function()
return mock.calls[#mock.calls]
end,
-- Get all calls
getCalls = function()
return mock.calls
end,
-- Get all results
getResults = function()
return mock.results
end,
-- Clear call history
reset = function()
mock.calls = {}
mock.results = {}
end,
-- Set return value
mockReturnValue = function(value)
mock.implementation = function() return value end
end,
-- Set return values in sequence
mockReturnValueOnce = function(value)
local originalImpl = mock.implementation
local called = false
mock.implementation = function(...)
if not called then
called = true
return value
elseif originalImpl then
return originalImpl(...)
end
end
end,
-- Set implementation
mockImplementation = function(fn)
mock.implementation = fn
end,
-- Restore original (for spies)
mockRestore = function()
mock.implementation = nil
mock.calls = {}
mock.results = {}
end
}
})
end
-- Create a spy on an existing object method
function M.spyOn(obj, methodName)
local original = obj[methodName]
if type(original) ~= "function" then
error("Cannot spy on non-function: " .. methodName)
end
local spy = M.fn(original)
-- Add restore functionality
local meta = getmetatable(spy).__index
local originalRestore = meta.mockRestore
meta.mockRestore = function()
obj[methodName] = original
originalRestore()
end
-- Replace the method
obj[methodName] = function(...)
return spy(...)
end
return spy
end
-- Create a mock object with multiple mock functions
function M.mockObject(methods)
local obj = {}
local mocks = {}
for name, impl in pairs(methods or {}) do
mocks[name] = M.fn(impl)
obj[name] = function(...) return mocks[name](...) end
end
obj._mocks = mocks
obj._resetAll = function()
for _, mock in pairs(mocks) do
mock.reset()
end
end
return obj
end
-- Timer mocks for testing time-dependent code
function M.useFakeTimers()
local timers = {
now = 0,
scheduled = {}
}
return {
-- Get current fake time
now = function()
return timers.now
end,
-- Schedule a callback (like setTimeout)
schedule = function(callback, delay)
local id = #timers.scheduled + 1
timers.scheduled[id] = {
callback = callback,
time = timers.now + delay,
id = id
}
return id
end,
-- Cancel a scheduled callback
cancel = function(id)
timers.scheduled[id] = nil
end,
-- Advance time and run scheduled callbacks
advance = function(ms)
local targetTime = timers.now + ms
-- Sort by scheduled time
local pending = {}
for _, timer in pairs(timers.scheduled) do
if timer.time <= targetTime then
pending[#pending + 1] = timer
end
end
table.sort(pending, function(a, b) return a.time < b.time end)
-- Run each callback at its scheduled time
for _, timer in ipairs(pending) do
timers.now = timer.time
timer.callback()
timers.scheduled[timer.id] = nil
end
timers.now = targetTime
end,
-- Run all pending timers
runAll = function()
while next(timers.scheduled) do
local nextTimer
local nextTime = math.huge
for id, timer in pairs(timers.scheduled) do
if timer.time < nextTime then
nextTime = timer.time
nextTimer = timer
end
end
if nextTimer then
timers.now = nextTimer.time
nextTimer.callback()
timers.scheduled[nextTimer.id] = nil
end
end
end,
-- Reset timers
reset = function()
timers.now = 0
timers.scheduled = {}
end
}
end
return M

View File

@@ -0,0 +1,102 @@
-- Snapshot testing utilities
-- Provides record/match snapshot functionality
---@class SnapshotModule
local M = {}
-- Forward declaration for serialize
local serialize
---Serialize a value to a deterministic string representation
---@param value any Value to serialize
---@param seen? table Circular reference tracker
---@return string Serialized string
function M.serialize(value, seen)
seen = seen or {}
local t = type(value)
if t == "nil" then
return "nil"
elseif t == "boolean" then
return value and "true" or "false"
elseif t == "number" then
return tostring(value)
elseif t == "string" then
return string.format("%q", value)
elseif t == "table" then
if seen[value] then
return "<circular>"
end
seen[value] = true
local parts = {}
local keys = {}
for k in pairs(value) do
keys[#keys + 1] = k
end
table.sort(keys, function(a, b)
return tostring(a) < tostring(b)
end)
for _, k in ipairs(keys) do
parts[#parts + 1] = "[" .. M.serialize(k, seen) .. "]=" .. M.serialize(value[k], seen)
end
return "{" .. table.concat(parts, ",") .. "}"
else
return "<" .. t .. ">"
end
end
---@class Snapshot
---@field record fun(key: string, value: any) Record a snapshot value
---@field match fun(key: string, value: any): boolean Match against recorded snapshot
---@field getSnapshots fun(): table<string, string> Get all recorded snapshots
---@field update fun(key: string, value: any) Update a snapshot value
---Create a snapshot testing helper
---@param name string Name for this snapshot set
---@return Snapshot Snapshot object with record/match methods
function M.createSnapshot(name)
local snapshots = {}
return {
---Record a snapshot value
---@param key string Snapshot identifier
---@param value any Value to record
record = function(key, value)
snapshots[key] = M.serialize(value)
end,
---Match value against recorded snapshot
---@param key string Snapshot identifier
---@param value any Value to compare
---@return boolean True if matches or first run
match = function(key, value)
local serialized = M.serialize(value)
if snapshots[key] then
return snapshots[key] == serialized
end
-- First run - record the snapshot
snapshots[key] = serialized
return true
end,
---Get all recorded snapshots
---@return table<string, string>
getSnapshots = function()
return snapshots
end,
---Update a snapshot value
---@param key string Snapshot identifier
---@param value any New value to record
update = function(key, value)
snapshots[key] = M.serialize(value)
end
}
end
return M

View File

@@ -0,0 +1,37 @@
-- Spy utilities for testing
-- Allows spying on existing object methods
local mock_fn = require("mock_fn")
---@class SpyModule
local M = {}
---Create a spy on an existing object method
---@param obj table Object containing the method
---@param methodName string Name of method to spy on
---@return MockFunction Spy with tracking methods and mockRestore
function M.spyOn(obj, methodName)
local original = obj[methodName]
if type(original) ~= "function" then
error("Cannot spy on non-function: " .. methodName)
end
local spy = mock_fn.fn(original)
-- Add restore functionality
local meta = getmetatable(spy).__index
local originalRestore = meta.mockRestore
meta.mockRestore = function()
obj[methodName] = original
originalRestore()
end
-- Replace the method
obj[methodName] = function(...)
return spy(...)
end
return spy
end
return M

View File

@@ -0,0 +1,43 @@
-- String utilities for testing
-- Provides trim, split, startsWith, endsWith functions
---@class StringUtils
local M = {}
---Trim whitespace from both ends of a string
---@param s string String to trim
---@return string Trimmed string
function M.trim(s)
return s:match("^%s*(.-)%s*$")
end
---Split a string by delimiter
---@param s string String to split
---@param delimiter? string Delimiter pattern (default whitespace)
---@return string[] Array of parts
function M.split(s, delimiter)
delimiter = delimiter or "%s"
local result = {}
for match in (s .. delimiter):gmatch("(.-)" .. delimiter) do
result[#result + 1] = match
end
return result
end
---Check if string starts with prefix
---@param s string String to check
---@param prefix string Prefix to look for
---@return boolean True if s starts with prefix
function M.startsWith(s, prefix)
return s:sub(1, #prefix) == prefix
end
---Check if string ends with suffix
---@param s string String to check
---@param suffix string Suffix to look for
---@return boolean True if s ends with suffix
function M.endsWith(s, suffix)
return suffix == "" or s:sub(-#suffix) == suffix
end
return M

View File

@@ -0,0 +1,67 @@
-- Table utilities for testing
-- Provides clone, merge, keys, values, size functions
---@class TableUtils
local M = {}
---Deep clone a table
---@param t table Table to clone
---@return table Cloned table
function M.clone(t)
if type(t) ~= "table" then return t end
local copy = {}
for k, v in pairs(t) do
copy[k] = M.clone(v)
end
return setmetatable(copy, getmetatable(t))
end
---Merge multiple tables into one
---@vararg table Tables to merge (later tables override earlier)
---@return table Merged table
function M.merge(...)
local result = {}
for _, t in ipairs({...}) do
if type(t) == "table" then
for k, v in pairs(t) do
result[k] = v
end
end
end
return result
end
---Get all keys from a table
---@param t table Source table
---@return any[] Array of keys
function M.keys(t)
local keys = {}
for k in pairs(t) do
keys[#keys + 1] = k
end
return keys
end
---Get all values from a table
---@param t table Source table
---@return any[] Array of values
function M.values(t)
local values = {}
for _, v in pairs(t) do
values[#values + 1] = v
end
return values
end
---Get the number of entries in a table (including non-array keys)
---@param t table Table to count
---@return number Number of key-value pairs
function M.size(t)
local count = 0
for _ in pairs(t) do
count = count + 1
end
return count
end
return M

View File

@@ -0,0 +1,50 @@
-- Test data generation utilities
-- Provides functions for generating test fixtures
---@class TestDataGenerator
local M = {}
---@class TestDataTemplate
---@field [string] any Field template (value, function, or $variable)
---Generate test data from a template
---@param template TestDataTemplate Template defining field patterns
---@param count? number Number of items to generate (default 10)
---@return table[] Array of generated test data items
function M.generateTestData(template, count)
local data = {}
count = count or 10
for i = 1, count do
local item = {}
for k, v in pairs(template) do
if type(v) == "function" then
item[k] = v(i)
elseif type(v) == "string" and v:match("^%$") then
-- Template variables
local varName = v:sub(2)
if varName == "index" then
item[k] = i
elseif varName == "random" then
item[k] = math.random(1, 1000)
elseif varName == "uuid" then
item[k] = string.format("%08x-%04x-%04x-%04x-%012x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xffff),
math.random(0, 0xffff),
math.random(0, 0xffffffffffff))
else
item[k] = v
end
else
item[k] = v
end
end
data[#data + 1] = item
end
return data
end
return M

View File

@@ -0,0 +1,36 @@
-- Wait utilities for async-like testing
-- Provides waitFor condition polling
---@class WaitModule
local M = {}
---@class WaitForOptions
---@field timeout? number Timeout in milliseconds (default 1000)
---@field interval? number Polling interval in ms (default 10)
---@field throwOnTimeout? boolean Whether to throw on timeout (default true)
---Wait for a condition to become true
---@param condition fun(): boolean Function returning true when condition is met
---@param options? WaitForOptions Configuration options
---@return boolean True if condition was met, false if timed out (when throwOnTimeout=false)
function M.waitFor(condition, options)
options = options or {}
local timeout = options.timeout or 1000
local interval = options.interval or 10
local startTime = os.clock() * 1000
while (os.clock() * 1000 - startTime) < timeout do
if condition() then
return true
end
-- Note: In sandbox, we can't actually sleep, but this provides the pattern
end
if options.throwOnTimeout ~= false then
error("waitFor timed out after " .. timeout .. "ms")
end
return false
end
return M