Address review on the test harness

- The generic parts move to test/ in the repository root so other plugins can
  reuse them: the factorio and clusterio stubs, the test framework, the
  fengari runner, and clusterio's testMatrix and round trip helpers. A plugin
  composes them from its own env.lua, which adds its stubs and fixtures.
- Tests are declared with Test.test(name, fn) and every test function
  receives a fresh environment, so nothing carries over between them. A test
  which errors is reported as a failure rather than aborting the file.
- Every stub raises on properties it does not implement, which mirrors the
  game api. game.player is the one property allowed to read as nil.
- Test.deep_eq compares tables recursively with keys checked from both sides.
- The message records round trip through the same testMatrix and
  testRoundTripJsonSerialisable helpers the clusterio tests use, covering
  every optional field combination of the records, events and requests.
- controller.test.js and instance.test.js cover the node side of the plugin
  against faked controller and instance internals: property creation and
  sweeping, record building, broadcasts, subscription replay, assignment
  validation, auto assignment and its blocking role, seeding, the initialise
  payload, sync mode gating, and the rejection rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-08-22 16:45:51 +00:00
co-authored by Claude Fable 5
parent 59a0e0be13
commit 24d36db601
14 changed files with 1106 additions and 434 deletions
+21 -130
View File
@@ -1,14 +1,23 @@
--[[-- Test environment for module/control.lua
Stubs the factorio globals and the clusterio modules, then loads a fresh copy
of the roles module. Each call to Env.new() is independent.
Composes the shared stubs and framework from the repository test folder with
the fixtures for this plugin. Each call to Env.new() is an independent
environment with a fresh copy of the roles module.
Run as a chunk by test/helpers/lua.js, receiving the module directory and
Run as a chunk by test/lua/runner.js, receiving the shared folder and
returning this table, which is in turn passed to each test chunk.
]]
local module_root = ... --- @type string
local shared_root = ... --- @type string
local source = assert(debug.getinfo(1, "S")).source
local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$"))
local Env = {}
local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))()
local Framework = assert(loadfile(shared_root .. "/framework.lua"))()
local Env = {
--- Test registry for the file being run, see framework.lua
Test = Framework.new(),
}
--- Standard fixture: permission names sent once and referenced by zero based index
Env.permission_names = {
@@ -44,83 +53,12 @@ end
--- Create an independent environment with a fresh copy of the roles module
function Env.new()
local env = {
events = {}, -- events raised through script.raise_event
printed = {}, -- game.print and player.print messages
sent = {}, -- payloads sent to the controller
sounds = {}, -- sounds played to players
results = {}, -- test results collected by env.check
}
local env = Stubs.new()
local next_event_id = 100
script = {
generate_event_name = function()
next_event_id = next_event_id + 1
return next_event_id
end,
raise_event = function(id, data)
env.events[#env.events + 1] = { id = id, data = data }
end,
register_metatable = function() end,
}
defines = { events = { on_player_joined_game = 1, on_multiplayer_init = 2 } }
local players_by_name, players_by_index, connected = {}, {}, {}
game = {
tick = 1,
player = nil,
players = players_by_name,
connected_players = connected,
print = function(message) env.printed[#env.printed + 1] = message end,
get_player = function(key) return players_by_name[key] or players_by_index[key] end,
}
local stubs = {
["modules/clusterio/api"] = {
send_json = function(channel, data)
env.sent[#env.sent + 1] = { channel = channel, data = data }
end,
},
["modules/clusterio/compat"] = { script_data = {} },
["modules/exp_util/async"] = {
register = function(callback)
return function(...) return callback(...) end
end,
},
}
require = function(name)
return assert(stubs[name], "Unexpected require: " .. name)
end
--- Add a player to the stubbed game
function env.add_player(name, index, is_connected)
local player = {
name = name,
index = index,
connected = is_connected ~= false,
valid = true,
play_sound = function(opts)
env.sounds[#env.sounds + 1] = name .. ":" .. opts.path
end,
print = function(message)
env.printed[#env.printed + 1] = { to = name, message }
end,
}
players_by_name[name] = player
players_by_index[index] = player
if player.connected then connected[#connected + 1] = player end
return player
end
--- A player object which represents the server
env.server = setmetatable({ index = 0, name = "<server>" }, {
__index = function(_, key) error("Server player field accessed: " .. tostring(key)) end,
})
env.Roles = assert(loadfile(module_root .. "/control.lua"))()
env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))()
env.Roles.on_server_startup()
--- Get a role by name, failing the test file when it does not exist
--- Get a role by name, failing the test when it does not exist
function env.R(name)
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
end
@@ -134,60 +72,13 @@ function Env.new()
}
end
--- Forget everything recorded so far
function env.reset_log()
env.events, env.printed, env.sent, env.sounds = {}, {}, {}, {}
end
--- Record one test result
function env.check(ok, name, detail)
env.results[#env.results + 1] = { name = name, ok = not not ok, detail = detail }
end
--- Shallow array equality
function env.eq(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
if #a ~= #b then return false end
for i = 1, #a do
if a[i] ~= b[i] then return false end
end
return true
end
--- Names of an array of roles
function env.names(roles)
local rtn = {}
for index, role in ipairs(roles) do
rtn[index] = role.name
end
return rtn
end
--- Sorted copy of an array of strings
function env.sorted(list)
local rtn = {}
for index, value in ipairs(list) do rtn[index] = value end
table.sort(rtn)
return rtn
end
return env
end
--- Encode the results of an environment as JSON for the javascript side
function Env.results_json(env)
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(env.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, ",") .. "]"
--- Run the declared tests, each against a fresh environment
function Env.finish()
Env.Test.run(Env.new)
return Env.Test.results_json()
end
return Env