Files
bbassieandClaude Fable 5 08fe6b688e Type the test environments for autocomplete
Building on the class annotations from the smoke test changes:

- The suite is generic over its environment: Framework.suite is
  @generic T : Stubs with the extension returning T, and Suite<T> passes T to
  every test function. A test file declares
  `local Suite = ... --- @type Suite<ExpRoles.TestEnv>` and env autocompletes
  from there.
- The stubs declare their recorded fields, add_player returns Stubs.Player,
  and the server is one.
- ExpRoles.TestEnv : Stubs declares the extension fields, so env.R returns
  ExpRoles.Role and env.Roles is the module.

The typing immediately paid for itself by flagging real drift in the tests,
now fixed: _script_data is package private so the env exposes script_data()
instead, the join event carries its name and tick, the assignment record type
admits is_deleted, admin_state is a file local rather than an undeclared
field, and the event assertions which indexed [1] without a nil check compare
the whole event list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 06:09:40 +00:00

169 lines
5.5 KiB
Lua

--[[-- Test framework for plugin module tests
A suite is a collection of named tests. Test files declare them with
`Suite.test(name, function(env) ... end)`, naming each test after the function
or method it covers. Every test function receives a fresh environment: the
stubs extended by the function the suite was created with, so tests are
independent and can not leak state into each other.
]]
local source = assert(debug.getinfo(1, "S")).source
local shared_root = assert(source:match("^@(.*)/framework%.lua$"))
local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))()
--- @class Framework
local Framework = {}
--- Turn a value into a string for failure messages
local function repr(value, depth)
if type(value) ~= "table" then return tostring(value) end
if depth > 3 then return "{...}" end
local parts = {}
for key, entry in pairs(value) do
parts[#parts + 1] = tostring(key) .. " = " .. repr(entry, depth + 1)
end
return "{ " .. table.concat(parts, ", ") .. " }"
end
--- Create a suite of tests, one is made per test file
--- @generic T : Stubs
--- @param extend_env fun(env: Stubs): T Extends the stubs given to each test
--- @return Suite<T>
function Framework.suite(extend_env)
--- @class Suite<T>
local Suite = {
tests = {}, --- @type { name: string, fn: fun(env: T) }[] In declaration order
results = {}, --- @type { name: string, ok: boolean, detail: string? }[] Accumulated across tests
}
local current_test --- @type string?
--- Declare a named test, its function receives a fresh environment
--- @param name string
--- @param fn fun(env: T)
function Suite.test(name, fn)
Suite.tests[#Suite.tests + 1] = { name = name, fn = fn }
end
--- Record a passing check within the current test
--- @param name string
function Suite.pass(name)
Suite.results[#Suite.results + 1] = {
name = current_test and (current_test .. ": " .. name) or name,
ok = true,
}
end
--- Record a failing check within the current test
--- @param name string
--- @param detail string?
function Suite.fail(name, detail)
Suite.results[#Suite.results + 1] = {
name = current_test and (current_test .. ": " .. name) or name,
ok = false,
detail = detail,
}
end
--- Check a condition
--- @param ok any Truthy when the check passed
--- @param name string
--- @param detail string?
function Suite.check(ok, name, detail)
if ok then
Suite.pass(name)
else
Suite.fail(name, detail)
end
end
--- Recursive table equality, keys checked from both sides
local function deep_equal(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for key, value in pairs(a) do
if not deep_equal(value, b[key]) then return false end
end
for key in pairs(b) do
if a[key] == nil then return false end
end
return true
end
--- Check two values are equal, comparing tables recursively
function Suite.eq(a, b, name)
Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1)))
end
--- Check a table has no entries
function Suite.empty(value, name)
Suite.check(next(value) == nil, name, repr(value, 1))
end
--- Check a function raises an error containing the message
--- @param fn fun()
--- @param message string
--- @param name string
function Suite.throws(fn, message, name)
local ok, err = pcall(fn)
if ok then
Suite.fail(name, "did not error")
elseif not tostring(err):find(message, 1, true) then
Suite.fail(name, ("%s does not contain %s"):format(tostring(err), message))
else
Suite.pass(name)
end
end
--- Names of an array of values with a name property, such as roles,
--- players, forces, or events
--- @param values { name: string }[]
--- @return string[]
function Suite.names(values)
local rtn = {}
for index, value in ipairs(values) do
rtn[index] = value.name
end
return rtn
end
--- Sorted copy of an array of strings
function Suite.sorted(list)
local rtn = {}
for index, value in ipairs(list) do rtn[index] = value end
table.sort(rtn)
return rtn
end
--- Run every declared test, each against a fresh environment, and return
--- the results as JSON for the javascript side
function Suite.run()
for _, test in ipairs(Suite.tests) do
current_test = test.name
local ok, err = pcall(function()
test.fn(extend_env(Stubs.new()))
end)
if not ok then
Suite.fail("did not error", tostring(err))
end
end
current_test = nil
local function escape(value)
return (value:gsub('[%c"\\]', function(c)
return string.format("\\u%04x", c:byte())
end))
end
local parts = {}
for index, result in ipairs(Suite.results) do
local detail = result.detail and string.format(',"detail":"%s"', escape(result.detail)) or ""
parts[index] = string.format('{"name":"%s","ok":%s%s}', escape(result.name), tostring(result.ok), detail)
end
return "[" .. table.concat(parts, ",") .. "]"
end
return Suite
end
return Framework