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
+41
View File
@@ -0,0 +1,41 @@
"use strict";
const assert = require("assert").strict;
const { compile } = require("@clusterio/lib");
/**
* Generate a flat array of tests from a matrix of inputs.
*
* @template {any[][]} T
* @param {T} arrays - A list of arrays representing the different values for each argument
* @returns {Array<{ [K in keyof T]: T[K][number] }>}
* An array of tuples, each containing one value from each input array.
*/
function testMatrix(...arrays) {
return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]);
}
/**
* Test that a class is round trip json serialisable across multiple test cases.
*
* @template {any[]} T - Constructor arguments
* @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods.
* @param {T[]} tests - The tests inputs to pass to the class constructor.
*/
function testRoundTripJsonSerialisable(Class, tests) {
const validate = compile(Class.jsonSchema);
for (const test of tests) {
const original = new Class(...test);
const serialised = JSON.stringify(original);
const jsonObject = JSON.parse(serialised);
const reconstructed = Class.fromJSON(jsonObject);
assert.deepEqual(reconstructed, original, JSON.stringify(test));
if (!validate(jsonObject)) {
throw validate.errors;
}
}
}
module.exports = {
testMatrix,
testRoundTripJsonSerialisable,
};
+109
View File
@@ -0,0 +1,109 @@
--[[-- Test framework for plugin module tests
Test files declare named tests with `Test.test(name, function(env) ... end)`.
The runner creates a fresh environment for every test, so tests are
independent and can not leak state into each other.
]]
local Framework = {}
--- Create a test registry, one is made per test file
function Framework.new()
--- @class Test
local Test = {
tests = {}, -- { name, fn } in declaration order
results = {}, -- { name, ok, detail? } 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: table)
function Test.test(name, fn)
Test.tests[#Test.tests + 1] = { name = name, fn = fn }
end
--- Record one check within the current test
--- @param ok any Truthy when the check passed
--- @param name string
--- @param detail string?
function Test.check(ok, name, detail)
Test.results[#Test.results + 1] = {
name = current_test and (current_test .. ": " .. name) or name,
ok = not not ok,
detail = detail,
}
end
--- Shallow array equality
function Test.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
--- Recursive table equality, keys checked from both sides
function Test.deep_eq(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for key, value in pairs(a) do
if not Test.deep_eq(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
--- Names of an array of roles
function Test.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 Test.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 against a fresh environment
--- @param make_env fun(): table
function Test.run(make_env)
for _, test in ipairs(Test.tests) do
current_test = test.name
local ok, err = pcall(test.fn, make_env())
if not ok then
Test.check(false, "did not error", tostring(err))
end
end
current_test = nil
end
--- Encode the results as JSON for the javascript side
function Test.results_json()
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(Test.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 Test
end
return Framework
+63
View File
@@ -0,0 +1,63 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs");
const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
/**
* Run one lua test file in its own lua state and return its results.
*
* The state is created here, on first use by the caller, so every test file
* gets an independent set of stubs. Three chunks run in order, each taking one
* argument and returning one value:
*
* 1. The plugin's env.lua, receiving this directory so it can load the shared
* stubs and framework, returning its environment module.
* 2. The test file, receiving the environment module, declaring its tests.
* 3. The tests then run, each against a fresh environment, and the results
* are returned as JSON.
*
* @param {string} envFile - Absolute path of the plugin's test/lua/env.lua.
* @param {string} testFile - Absolute path of the lua test file to run.
* @returns {{ name: string, ok: boolean, detail?: string }[]}
*/
function runLuaTests(envFile, testFile) {
const L = lauxlib.luaL_newstate();
lualib.luaL_openlibs(L);
const load = (file) => {
const code = fs.readFileSync(file);
const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${file}`));
if (status !== lua.LUA_OK) {
throw new Error(to_jsstring(lua.lua_tostring(L, -1)));
}
};
const call = () => {
if (lua.lua_pcall(L, 1, 1, 0) !== lua.LUA_OK) {
throw new Error(to_jsstring(lua.lua_tostring(L, -1)));
}
};
load(envFile);
lua.lua_pushstring(L, to_luastring(__dirname));
call();
// The environment stays on the stack and is passed to the test chunk
load(testFile);
lua.lua_insert(L, -2);
call();
const json = to_jsstring(lua.lua_tostring(L, -1));
return JSON.parse(json);
}
/** Report the results of a lua test file through a tap test object. */
function reportLuaTests(t, envFile, testFile) {
const results = runLuaTests(envFile, testFile);
t.ok(results.length > 0, `${path.basename(testFile)} produced results`);
for (const result of results) {
t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {});
}
t.end();
}
module.exports = { runLuaTests, reportLuaTests };
+118
View File
@@ -0,0 +1,118 @@
--[[-- Generic factorio stubs for plugin module tests
Provides the surface a clusterio module touches, recording what the module does
to it. Every stub raises an error when an unimplemented property is read, which
mirrors the game api and catches mistakes in tests early.
Plugins compose these from their own test/lua/env.lua, adding stubs of their
own through `stubs.requires` and `Stubs.strict`.
]]
local Stubs = {}
--- Give a table an index metamethod which raises on unknown properties
--- @generic T : table
--- @param name string Shown in the error message
--- @param tbl T
--- @param allowed_nil string[]? Properties which may be read while unset
--- @return T
function Stubs.strict(name, tbl, allowed_nil)
local allowed = {}
for _, key in pairs(allowed_nil or {}) do
allowed[key] = true
end
return setmetatable(tbl, {
__index = function(_, key)
if allowed[key] then return nil end
error(name .. " does not implement: " .. tostring(key), 2)
end,
})
end
--- Create an independent set of stubs, installed as the lua globals
function Stubs.new()
local stubs = {
events = {}, -- events raised through script.raise_event
printed = {}, -- game.print and player.print messages
sent = {}, -- payloads sent through the clusterio api
sounds = {}, -- sounds played to players
}
local next_event_id = 100
script = Stubs.strict("script", {
generate_event_name = function()
next_event_id = next_event_id + 1
return next_event_id
end,
raise_event = function(id, data)
stubs.events[#stubs.events + 1] = { id = id, data = data }
end,
register_metatable = function() end,
})
defines = Stubs.strict("defines", {
events = Stubs.strict("defines.events", {
on_player_joined_game = 1,
on_multiplayer_init = 2,
}),
})
local players_by_name, players_by_index, connected = {}, {}, {}
game = Stubs.strict("game", {
tick = 1,
players = players_by_name,
connected_players = connected,
print = function(message) stubs.printed[#stubs.printed + 1] = message end,
get_player = function(key) return players_by_name[key] or players_by_index[key] end,
}, { "player" })
--- Add a player to the stubbed game
function stubs.add_player(name, index, is_connected)
local player = Stubs.strict("LuaPlayer " .. name, {
name = name,
index = index,
connected = is_connected ~= false,
valid = true,
play_sound = function(opts)
stubs.sounds[#stubs.sounds + 1] = name .. ":" .. opts.path
end,
print = function(message)
stubs.printed[#stubs.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
stubs.server = Stubs.strict("server player", { index = 0, name = "<server>" })
--- Modules resolved by the stubbed require, extend before loading the module
stubs.requires = {
["modules/clusterio/api"] = Stubs.strict("clusterio api", {
send_json = function(channel, data)
stubs.sent[#stubs.sent + 1] = { channel = channel, data = data }
end,
}),
["modules/clusterio/compat"] = Stubs.strict("clusterio compat", { script_data = {} }),
["modules/exp_util/async"] = Stubs.strict("exp_util async", {
register = function(callback)
return function(...) return callback(...) end
end,
}),
}
require = function(name)
return assert(stubs.requires[name], "Unexpected require: " .. name)
end
--- Forget everything recorded so far
function stubs.reset_log()
stubs.events, stubs.printed, stubs.sent, stubs.sounds = {}, {}, {}, {}
end
return stubs
end
return Stubs