minetest/games/devtest/mods/unittests/init.lua
Lars Müller 7f1316236b
Silence failing raycast unit test (#15644)
The cause for the test failure is an edge case bug
in the raycast implementation (perfectly diagonal raycasts).

This is fixed by switching to a continuous random distribution
which makes it extremely unlikely that the buggy edge case occurs.

Additionally, devtest unit test failures now print their random seed
to be easier to reproduce in the future.
2025-01-08 10:56:05 +01:00

248 lines
6.4 KiB
Lua

unittests = {}
unittests.list = {}
-- name: Name of the test
-- func:
-- for sync: function(player, pos), should error on failure
-- for async: function(callback, player, pos)
-- MUST call callback() or callback("error msg") in case of error once test is finished
-- this means you cannot use assert() in the test implementation
-- opts: {
-- player = false, -- Does test require a player?
-- map = false, -- Does test require map access?
-- async = false, -- Does the test run asynchronously? (read notes above!)
-- random = false, -- Does the test use math.random directly or indirectly?
-- }
function unittests.register(name, func, opts)
local def = table.copy(opts or {})
def.name = name
def.func = func
table.insert(unittests.list, def)
end
function unittests.on_finished(all_passed)
-- free to override
end
-- Calls invoke with a callback as argument
-- Suspends coroutine until that callback is called
-- Return values are passed through
local function await(invoke)
local co = coroutine.running()
assert(co)
local called_early = true
invoke(function(...)
if called_early == true then
called_early = {...}
else
coroutine.resume(co, ...)
co = nil
end
end)
if called_early ~= true then
-- callback was already called before yielding
return unpack(called_early)
end
called_early = nil
return coroutine.yield()
end
local function printf(fmt, ...)
print(fmt:format(...))
end
function unittests.run_one(idx, counters, out_callback, player, pos)
local def = unittests.list[idx]
local seed
if def.random then
seed = core.get_us_time()
math.randomseed(seed)
end
if not def.player then
player = nil
elseif player == nil then
out_callback(false)
return false
end
if not def.map then
pos = nil
elseif pos == nil then
out_callback(false)
return false
end
local tbegin = core.get_us_time()
local function done(status, err)
local tend = core.get_us_time()
local ms_taken = (tend - tbegin) / 1000
if not status then
core.log("error", err)
end
printf("[%s] %s - %dms", status and "PASS" or "FAIL", def.name, ms_taken)
if seed and not status then
printf("Random was seeded to %d", seed)
end
counters.time = counters.time + ms_taken
counters.total = counters.total + 1
if status then
counters.passed = counters.passed + 1
end
end
if def.async then
core.log("info", "[unittest] running " .. def.name .. " (async)")
def.func(function(err)
done(err == nil, err)
out_callback(true)
end, player, pos)
else
core.log("info", "[unittest] running " .. def.name)
local status, err = pcall(def.func, player, pos)
done(status, err)
out_callback(true)
end
return true
end
local function wait_for_player(callback)
if #core.get_connected_players() > 0 then
return callback(core.get_connected_players()[1])
end
local first = true
core.register_on_joinplayer(function(player)
if first then
callback(player)
first = false
end
end)
end
local function wait_for_map(pos, callback)
local function check()
if core.get_node(pos).name ~= "ignore" then
callback()
else
core.after(0, check)
end
end
check()
end
-- This runs in a coroutine so it uses await()
function unittests.run_all()
local counters = { time = 0, total = 0, passed = 0 }
-- Run standalone tests first
for idx = 1, #unittests.list do
local def = unittests.list[idx]
def.done = await(function(cb)
unittests.run_one(idx, counters, cb, nil, nil)
end)
end
-- Wait for a player to join, run tests that require a player
local player = await(wait_for_player)
for idx = 1, #unittests.list do
local def = unittests.list[idx]
if not def.done then
def.done = await(function(cb)
unittests.run_one(idx, counters, cb, player, nil)
end)
end
end
-- Wait for the world to generate/load, run tests that require map access
local pos = player:get_pos():round():offset(0, 5, 0)
core.forceload_block(pos, true, -1)
await(function(cb)
wait_for_map(pos, cb)
end)
for idx = 1, #unittests.list do
local def = unittests.list[idx]
if not def.done then
def.done = await(function(cb)
unittests.run_one(idx, counters, cb, player, pos)
end)
end
end
-- Print stats
assert(#unittests.list == counters.total)
print(string.rep("+", 80))
local passed = counters.total == counters.passed
printf("Devtest Unit Test Results: %s", passed and "PASSED" or "FAILED")
printf(" %d / %d failed tests.",
counters.total - counters.passed, counters.total)
printf(" Testing took %dms total.", counters.time)
print(string.rep("+", 80))
unittests.on_finished(counters.total == counters.passed)
return counters.total == counters.passed
end
--------------
local modpath = core.get_modpath("unittests")
dofile(modpath .. "/misc.lua")
dofile(modpath .. "/player.lua")
dofile(modpath .. "/crafting.lua")
dofile(modpath .. "/itemdescription.lua")
dofile(modpath .. "/async_env.lua")
dofile(modpath .. "/entity.lua")
dofile(modpath .. "/get_version.lua")
dofile(modpath .. "/itemstack_equals.lua")
dofile(modpath .. "/content_ids.lua")
dofile(modpath .. "/metadata.lua")
dofile(modpath .. "/raycast.lua")
dofile(modpath .. "/inventory.lua")
dofile(modpath .. "/load_time.lua")
dofile(modpath .. "/on_shutdown.lua")
dofile(modpath .. "/color.lua")
--------------
local function send_results(name, ok)
core.chat_send_player(name,
core.colorize(ok and "green" or "red",
(ok and "All devtest unit tests passed." or
"There were devtest unit test failures.") ..
" Check the console for detailed output."))
end
if core.settings:get_bool("devtest_unittests_autostart", false) then
local test_results = nil
core.after(0, function()
-- CI adds a mod which sets `unittests.on_finished`
-- to write status information to the filesystem
local old_on_finished = unittests.on_finished
unittests.on_finished = function(ok)
for _, player in ipairs(core.get_connected_players()) do
send_results(player:get_player_name(), ok)
end
test_results = ok
old_on_finished(ok)
end
coroutine.wrap(unittests.run_all)()
end)
core.register_on_joinplayer(function(player)
if test_results == nil then
return -- tests haven't completed yet
end
send_results(player:get_player_name(), test_results)
end)
else
core.register_chatcommand("unittests", {
privs = {basic_privs=true},
description = "Runs devtest unittests (may modify player or map state)",
func = function(name, param)
unittests.on_finished = function(ok)
send_results(name, ok)
end
coroutine.wrap(unittests.run_all)()
return true, ""
end,
})
end